Compare commits

..
Author SHA1 Message Date
Sojan Jose 950ebcacd4 fix: keep agent bot takeover guard human-only 2026-06-28 20:49:26 -07:00
Sojan Jose b9834c3a18 feat: block replies during agent bot ownership 2026-06-28 20:10:46 -07:00
Sojan JoseandGitHub d0b1c055e8 chore: Track cloud plan activation conversions (#14834)
## Summary
- track cloud plan activation conversions when an attributed account
moves from the configured default cloud plan to a paid plan
- use the Stripe webhook event time as the activation timestamp so the
30-day signup attribution window reflects the actual upgrade event
- send the Stripe subscription amount and currency for the conversion
value
- mark the account attribution after enqueueing so later plan updates do
not send duplicate activation conversions

## Notes
- Marketing tracker: https://linear.app/chatwoot/issue/MAR-113
- Cloud implementation: https://linear.app/chatwoot/issue/LEA-34
- Stripe billing stays responsible for subscription state and value
calculation.
- Cloud plan activation conversion tracking is handled by a small
dedicated service that owns the activation rule, duplicate marker, and
conversion enqueue.
- Website attribution cookie capture remains separate in the marketing
attribution service.
- There is no frontend change and no new user-facing configuration.
- Conversion upload still no-ops outside Chatwoot Cloud and when
attribution has no supported click identifier.
2026-06-25 18:41:43 -07:00
Sony MathewandGitHub cf134deb37 fix: Preserve Captain LLM defaults (#14858)
# Pull Request Template

## Description

Adjusts the Captain LLM feature defaults after feature routing so the
defaults stay intentional and avoid unintended high-cost model upgrades.
Assistant, copilot, and onboarding content generation now default to
`gpt-4.1`; audio transcription keeps `gpt-4o-mini-transcribe` as the
default while exposing `whisper-1` as an available account override
option.

Related: https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

- `ruby -ryaml -e 'yaml = YAML.load_file("config/llm.yml"); features =
yaml.fetch("features"); models = yaml.fetch("models"); features.each {
|name, cfg| missing = Array(cfg["models"]) - models.keys; raise
"#{name}: missing #{missing.join(",")}" if missing.any?; raise "#{name}:
default not in models" unless
Array(cfg["models"]).include?(cfg["default"]) }'`
- `node -e
"JSON.parse(require('fs').readFileSync('app/javascript/dashboard/i18n/locale/en/settings.json',
'utf8'))"`
- `pnpm exec prettier --check
app/javascript/dashboard/i18n/locale/en/settings.json`
- `bundle exec rspec spec/lib/llm/feature_router_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/models/concerns/agentable_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/models/account_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/controllers/super_admin/accounts_controller_spec.rb`
- `bundle exec rspec
spec/enterprise/services/captain/llm/article_translation_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb`
- `RUBOCOP_CACHE_ROOT=/private/tmp/rubocop_cache bundle exec rubocop
enterprise/app/services/captain/llm/article_translation_service.rb
enterprise/app/services/messages/audio_transcription_service.rb
spec/enterprise/services/captain/llm/article_translation_service_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 21:35:09 +05:30
Sony MathewandGitHub b8b62ad0f1 feat: Harden model override preferences (5/6) (#14846)
## Description

Hardens the Captain model override preferences API so account-level
overrides follow the same feature-router contract used by runtime LLM
calls. The API now permits model and feature keys from `llm.yml`,
removes blank model overrides, rejects invalid saved model combinations,
and returns each feature's effective model, provider, and source for UI
clients.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

Verified the account preferences API and account model validation
behavior for valid overrides, invalid model values, unknown feature
keys, blank override removal, and effective model/provider/source
payload metadata.

- `eval "$(rbenv init -)" && bundle exec rspec
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/models/account_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/lib/llm/feature_router_spec.rb`
- `eval "$(rbenv init -)" && bundle exec rubocop
app/controllers/api/v1/accounts/captain/preferences_controller.rb
app/models/concerns/account_settings_schema.rb
app/models/concerns/captain_featurable.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/models/account_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/lib/llm/feature_router_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] 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-25 17:38:11 +05:30
Sony MathewandGitHub 4e26c5b4bb feat: Route system LLM jobs (4/6) (#14843)
## Description

Routes the remaining system-only and legacy-sensitive LLM jobs through
feature-level model configuration, while preserving system credential
usage and usage-accounting behavior. This adds dedicated defaults for
help center article generation, onboarding content generation, query
translation, transcription, and search embeddings so these flows can be
configured per account without falling back to installation-wide model
settings.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

Verified the feature routing defaults and account overrides for the
touched Captain/system LLM paths, including the legacy OpenAI
transcription and paginated FAQ services.

- `eval "$(rbenv init -)" && bundle exec rspec
spec/lib/captain/base_task_service_spec.rb spec/lib/llm/models_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/enterprise/services/captain/llm/paginated_faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/pdf_processing_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/enterprise/services/onboarding/help_center_article_builder_spec.rb
spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb`
- `eval "$(rbenv init -)" && bundle exec rubocop
app/controllers/api/v1/accounts/captain/preferences_controller.rb
app/models/concerns/account_settings_schema.rb
lib/captain/base_task_service.rb
enterprise/app/services/captain/llm/article_translation_service.rb
enterprise/app/services/captain/llm/article_writer_service.rb
enterprise/app/services/captain/llm/embedding_service.rb
enterprise/app/services/captain/llm/help_center_curation_service.rb
enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
enterprise/app/services/captain/llm/translate_query_service.rb
enterprise/app/services/captain/llm/widget_tagline_service.rb
enterprise/app/services/captain/onboarding/website_analyzer_service.rb
enterprise/app/services/messages/audio_transcription_service.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/lib/captain/base_task_service_spec.rb`
- `ruby -e "require 'yaml'; config = YAML.load_file('config/llm.yml');
%w[document_faq_generation help_center_article_generation
onboarding_content_generation help_center_query_translation
audio_transcription help_center_search].each { |feature| abort(%(missing
#{feature})) unless config.dig('features', feature) }; abort('wrong
article default') unless config.dig('features',
'help_center_article_generation', 'default') == 'gpt-5.2'; puts 'llm.yml
ok'"`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:37:45 +05:30
Sony MathewandGitHub 43e0f8a141 feat: Route Enterprise LLM services (3/6) (#14841)
# Pull Request Template

## Description

Routes Enterprise assistant, copilot, FAQ, contact memory,
action-classifier, and false-promise detector LLM paths through
feature-specific model resolution. `Llm::BaseAiService` now accepts
feature/account context and uses `Llm::FeatureRouter` when that context
is present, while retaining the installation-model fallback for
unmigrated callers. This also adds a `document_faq_generation` feature
default for generative FAQ/document content.

Linear: https://linear.app/chatwoot/issue/CW-7425/test-new-models
Depends on #14840

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/lib/llm/models_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/captain/copilot/chat_service_spec.rb
spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb
spec/enterprise/services/captain/llm/assistant_false_promise_service_spec.rb
spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb`
passed with 112 examples, 0 failures.
- `bundle exec rspec spec/models/concerns/captain_featurable_spec.rb
spec/models/account_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/lib/llm/feature_router_spec.rb` passed with 87 examples, 0
failures.
- `bundle exec rubocop enterprise/app/services/llm/base_ai_service.rb
enterprise/app/services/captain/copilot/chat_service.rb
enterprise/app/services/captain/llm/assistant_chat_service.rb
enterprise/app/services/captain/llm/faq_generator_service.rb
enterprise/app/services/captain/llm/conversation_faq_service.rb
enterprise/app/services/captain/llm/contact_notes_service.rb
enterprise/app/services/captain/llm/contact_attributes_service.rb
enterprise/app/services/captain/llm/assistant_action_classifier_service.rb
enterprise/app/services/captain/llm/assistant_false_promise_service.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/captain/copilot/chat_service_spec.rb
spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb
spec/enterprise/services/captain/llm/assistant_false_promise_service_spec.rb
spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb`
passed with no offenses.
- `bundle exec ruby -e "require 'yaml'; config =
YAML.load_file('config/llm.yml'); abort('missing
document_faq_generation') unless config.dig('features',
'document_faq_generation'); abort('missing default') unless
config.dig('features', 'document_faq_generation', 'default'); puts
'llm.yml ok'"` passed.
- `git diff --check` passed.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:03:07 +05:30
Sony MathewandGitHub 91d8a4e2a3 feat: Route reply box LLM tasks (2/6) (#14840)
# Pull Request Template

## Description

Routes OSS reply-box and small Captain tasks through feature-specific
LLM model resolution. Rewrite, reply suggestion, summary, follow-up, and
CSAT utility analysis now resolve through the `editor` feature; label
suggestion resolves through `label_suggestion`. Existing credentials,
account OpenAI hook behavior, and instrumentation event names remain
unchanged.

Linear: https://linear.app/chatwoot/issue/CW-7425/test-new-models
Depends on #14839

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/lib/captain/base_task_service_spec.rb
spec/lib/captain/rewrite_service_spec.rb
spec/lib/captain/reply_suggestion_service_spec.rb
spec/lib/captain/summary_service_spec.rb
spec/lib/captain/label_suggestion_service_spec.rb
spec/lib/captain/csat_utility_analysis_service_spec.rb
spec/lib/captain/follow_up_service_spec.rb` passed with 81 examples, 0
failures.
- `bundle exec rubocop lib/captain/base_task_service.rb
lib/captain/rewrite_service.rb lib/captain/reply_suggestion_service.rb
lib/captain/summary_service.rb lib/captain/label_suggestion_service.rb
lib/captain/csat_utility_analysis_service.rb
lib/captain/follow_up_service.rb
enterprise/lib/enterprise/captain/reply_suggestion_service.rb
spec/lib/captain/base_task_service_spec.rb
spec/lib/captain/rewrite_service_spec.rb
spec/lib/captain/reply_suggestion_service_spec.rb
spec/lib/captain/summary_service_spec.rb
spec/lib/captain/label_suggestion_service_spec.rb
spec/lib/captain/csat_utility_analysis_service_spec.rb
spec/lib/captain/follow_up_service_spec.rb` passed with no offenses.
- `git diff --check` passed.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:01:05 +05:30
Sony MathewandGitHub 8b977b35a8 feat: Add LLM feature router (1/6) (#14839)
## Description

Adds the foundation for feature-specific LLM model routing so Captain AI
features can resolve their effective provider/model from code defaults
and account-level overrides. This fixes the provider metadata key in
`config/llm.yml`, adds `Llm::FeatureRouter`, and routes existing
`CaptainFeaturable` model defaults through the shared resolver.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/lib/llm/models_spec.rb
spec/lib/llm/feature_router_spec.rb
spec/models/concerns/captain_featurable_spec.rb` - 23 examples, 0
failures
- `bundle exec rubocop lib/llm/models.rb lib/llm/feature_router.rb
app/models/concerns/captain_featurable.rb spec/lib/llm/models_spec.rb
spec/lib/llm/feature_router_spec.rb
spec/models/concerns/captain_featurable_spec.rb` - no offenses
- `bundle exec ruby -e "require 'yaml'; config =
YAML.load_file('config/llm.yml'); abort('missing providers') unless
config['providers']; abort('missing models') unless config['models'];
abort('missing features') unless config['features']; puts 'llm.yml ok'"`
- `git diff --check`

## 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-25 17:00:07 +05:30
62cbeae95f feat: onboarding inboxes UI (#14565)
After entering their account details, new admins land on an **Inbox
setup** screen that shows what we've already set up for them and lets
them connect their conversation channels without leaving onboarding. It
surfaces the auto-created live chat widget (and Help Center on
Enterprise), highlights channels detected from their website, and offers
a **View all** dialog to connect any supported channel inline.

### Channel status

| Channel | How it connects | Status | PR |
|---|---|---|---|
| Live chat (Website) | Auto-created during setup |  Done |
https://github.com/chatwoot/chatwoot/pull/14314 |
| WhatsApp | Meta embedded signup |  Done |
https://github.com/chatwoot/chatwoot/pull/14619 |
| Facebook | Login + page picker |  Done |
https://github.com/chatwoot/chatwoot/pull/14619 |
| Instagram | OAuth redirect |  Done |
https://github.com/chatwoot/chatwoot/pull/14568 |
| TikTok | OAuth redirect |  Done |
https://github.com/chatwoot/chatwoot/pull/14569 |
| LINE | Inline credential form |  Done | — |
| Telegram | Inline credential form |  Done | — |
| Gmail / Outlook | OAuth (email) | ⚠️ Disabled — coming in a follow-up
| https://github.com/chatwoot/chatwoot/pull/14567 |
| SMS / API / Voice / Other email | — |  Unavailable | — |

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-06-25 14:54:07 +05:30
7c7459b734 fix(whatsapp): override webhook at phone number level (#13817)
## Description

Move WhatsApp webhook callback override from WABA level to phone number
level, allowing multiple phone numbers on the same WABA to have
independent callback URLs.

## Type of change

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

## How Has This Been Tested?

- Connect a WhatsApp Cloud inbox via embedded signup
- Verify webhook setup succeeds and messages are received
- Connect a second phone number on the same WABA — both should receive
messages independently
- Delete an inbox and verify only that phone number's override is
cleared

## Checklist:

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

---------

Co-authored-by: tds-1 <tds-1@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-25 14:49:09 +05:30
Sojan JoseandGitHub 0849d2e070 chore: Track cloud signup conversions (#14852)
## Summary
- enqueue `cloud_signup` conversion tracking after website attribution
is stored on a new cloud account
- keep authenticated add-workspace requests out of
attribution/conversion tracking
- reuse the existing marketing conversion tracking service and config
already available on `develop`

## Notes
- This PR only wires the signup path.
- Billing/plan activation conversion tracking is intentionally left out
for a separate rollout.
- The conversion service still no-ops outside Chatwoot Cloud and when
stored attribution has no supported click identifier.
2026-06-24 20:04:42 -07:00
Sojan JoseandGitHub cd1a214d98 chore: Add marketing conversion tracking foundation (#14851)
## Summary

Adds the minimal foundation for Cloud marketing conversion tracking:

- locked internal installation config for conversion tracking
credentials and event mappings
- generic background job and service for uploading conversion events
from stored attribution
- focused coverage for Cloud gating, click-id selection, payload shape,
and optional conversion value

This PR intentionally does not wire signup or plan activation yet. The
next step is to validate the service from Rails console against existing
attributed accounts, then add the event hooks in a follow-up PR.

## Notes

The config remains locked and is surfaced under the Internal settings
group next to the existing Cloud plan configuration. The service assumes
the locked config is present and valid; config mistakes should surface
instead of being silently ignored.
2026-06-24 18:27:02 -07:00
f9cc702030 fix(help-center): documentation layout on custom domain root, locale (#14850)
Help centers using the documentation layout now render correctly in two
cases that previously fell back to the wrong output. Opening a portal at
its custom-domain root (e.g. docs.example.com/ ) now shows the full
documentation home
in place instead of the classic layout, and portals whose locale is a
region variant that Chatwoot doesn't ship a
translation for (e.g. th_TH , fr_ML ) now show their categories in the
sidebar on article pages.

Closes
https://linear.app/chatwoot/issue/CW-7437/portal-layout-is-not-properly-working-in-custom-domain

## How to test

1. Create a portal with the documentation layout and a custom domain
(e.g. example.chat.test ), with at least one
category containing a published article.
2. Visit the custom-domain root ( http://example.chat.test:3000/ ) → it
should show the full documentation home
(sidebar, topbar), not the classic layout, with no redirect.
3. Set the portal's locale to a region variant Chatwoot doesn't
translate, e.g. th_TH , with categories/articles
under that locale.
4. Open an article → the sidebar should list the article's category and
sibling articles. Before the fix the sidebar
was empty for these locales.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:12:55 -07:00
74db16158d feat: migrate dyte integration to cloudflare realtimekit (#14752)
Dyte is sunsetting its existing infrastructure after the Cloudflare
acquisition, so this migrates Chatwoot’s video call integration to
Cloudflare RealtimeKit.

The integration now uses Cloudflare Account ID, RealtimeKit App ID, and
a Cloudflare API token with Realtime Admin permissions. Meeting creation
and participant token generation now call Cloudflare’s RealtimeKit APIs,
while the existing Chatwoot call experience remains unchanged for agents
and customers.

This also adds setup-time credential validation, so admins get clearer
errors when the API token is invalid, the Cloudflare account or
permissions are incorrect, or the RealtimeKit App ID does not belong to
the selected account.

Fixes
https://linear.app/chatwoot/issue/PLA-176/migrate-dyte-integration-to-cloudflare-realtimekit

**How to test**

1. Go to Settings → Integrations → Cloudflare RealtimeKit.
2. Add a Cloudflare Account ID, RealtimeKit App ID, and API token with
Realtime Admin permissions.
3. Confirm the integration saves successfully with valid credentials.
4. Try invalid credentials and confirm the error identifies whether the
token, account/permissions, or app ID is wrong.
5. Start a video call from a conversation and confirm the RealtimeKit
meeting opens.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-24 11:04:08 +04:00
Sivin VargheseandGitHub e8edc9ebf5 feat: allow users to set an icon or emoji for teams (#14767) 2026-06-23 20:53:31 +05:30
Vishnu NarayananandGitHub e785620921 feat: read structured X-Chatwoot-* headers for mobile session metadata (#14762)
Phase 2 of [INF-75](https://linear.app/chatwoot/issue/INF-75). Follows
up on [#14753](https://github.com/chatwoot/chatwoot/pull/14753) (Phase 1
UA pattern fallback, already merged).

When the request carries an \`X-Chatwoot-Client-Name\` header,
\`UserSessionTrackingService\` now prefers the five structured
\`X-Chatwoot-*\` headers over the User-Agent for populating the
\`user_sessions\` row:

| Header | Mapped column |
|---|---|
| \`X-Chatwoot-Client-Name\` | \`browser_name\` |
| \`X-Chatwoot-Client-Version\` | \`browser_version\` |
| \`X-Chatwoot-Device-Model\` | \`platform_name\` |
| \`X-Chatwoot-Platform-Version\` | \`platform_version\` |
| \`X-Chatwoot-Platform\` (+ model) | \`device_name\` (\`iPhone\` /
\`iPad\` / \`Android\`) |

When the header is absent or blank, the existing \`Browser.new\` path
runs, with the Phase 1 legacy UA fallback still acting as the floor.
Real browsers are untouched.

A follow-up PR in
[chatwoot-mobile-app](https://github.com/chatwoot/chatwoot-mobile-app)
will start sending these headers from the React Native build. Until that
ships, this PR is a no-op for production traffic, so it can land
independently.

Fixes INF-75.
2026-06-23 20:08:27 +05:30
Aakash BakhleandGitHub 0af9900be2 fix: model for false promise harness (#14824)
fixes model for false promise harness
2026-06-23 18:42:34 +05:30
Aakash BakhleandGitHub 84fda8a323 fix(captain): move false promise harness setting to account (#14823)
# Pull Request Template

## Description

moves the harness to account settings rather than assistant settings

## Type of change
refactor

## How Has This Been Tested?

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

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-23 16:55:42 +05:30
Aakash BakhleandGitHub 1c7e60880d fix: add harness on false promises (#14672)
# Pull Request Template

## Description


https://linear.app/chatwoot/issue/AI-179/false-promise-soft-handoff-guard

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?

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

Against negative cases identified by evals

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-23 16:30:14 +05:30
Aakash BakhleandGitHub 8e27ca4282 fix(firecrawl): get url in case sourceUrl is not available (#14820)
# Pull Request Template

## Description

Fixes inconsistency by adding a fallback to use `url` instead of
`sourceUrl` when Firecrawl returns empty `sourceUrl` in production.

We switched to v2 endpoints of Firecrawl in
https://github.com/chatwoot/chatwoot/pull/14624 and it worked locally,
but seems to fail for some cases in production.

## Type of change

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

## How Has This Been Tested?

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


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-23 13:40:31 +05:30
ee5e20551d fix(facebook): handle messenger post attachment type (#14813)
Facebook Messenger messages that share a post no longer break message
ingestion. Following the recent sticker change, Meta now sends a shared
post as a new `post` attachment type. Chatwoot didn't recognise `post`
as a valid attachment file type, so the webhook job crashed and the
message — and any others in the same batch — failed to sync. Shared
posts now appear in the conversation as a fallback link (title + URL),
the same way `share` attachments already do.

This is the same class of bug as #14793 (sticker), just a different
attachment type.

#### Root cause

`post` was neither skipped as unsupported nor mapped to a valid
`file_type`, so `attachment_params` produced `file_type: :post`. The
`Attachment#file_type` enum has no `post` value, so it raised
`ArgumentError: 'post' is not a valid file_type`, crashing
`Webhooks::FacebookEventsJob`.

#### Fix

The Facebook builder already maps `share -> :fallback` because shared
posts point to facebook.com page URLs rather than downloadable media. A
`post` is the same kind of attachment, so map both `share` and `post` to
`fallback` (stores the title/link, no download attempt). Also read the
fallback title from `payload.title`, since the post payload nests it
there rather than at the top level.

## How to reproduce

1. Connect a Facebook Page inbox.
2. Share a Facebook post into Messenger to that page.
3. Before this change: `Webhooks::FacebookEventsJob` raises
`ArgumentError: 'post' is not a valid file_type` and the message is
dropped.
4. After this change: the shared post shows up in the conversation as a
fallback link.

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-23 11:03:04 +04:00
df79c0bbde feat: exclude stale conversations via assignment policy age threshold (#14766)
## Linear ticket
-
https://linear.app/chatwoot/issue/CW-7137/assignment-v2-backlog-flush-overloads-agents

## Description

Assignment policies now skip stale unassigned conversations
automatically. Each policy carries an age threshold (defaults to 7
days), so auto-assignment only picks up recent backlog instead of
draining very old, forgotten conversations. The threshold is
configurable per policy and can be cleared to assign conversations
regardless of age. Previously this control existed only on Enterprise
capacity policies (`exclude_older_than_hours`); it now lives on the
assignment policy itself, so every V2 inbox benefits without needing a
capacity policy.

## Type of change

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

## How Has This Been Tested?

- Local UI flows

## Screenshots? 

<img width="645" height="822" alt="image"
src="https://github.com/user-attachments/assets/ec58db86-c1fa-4f9e-be87-2e24ff02e077"
/>


## Checklist:

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-23 12:22:45 +05:30
647cfc2d83 feat: distinguish agent-declined calls with a rejected status (#14783)
## Linear ticket
-
https://linear.app/chatwoot/issue/CW-7374/agent-declined-voice-calls-miscounted-as-failed

## Description

Agent rejections were stored with status: failed, so call reports lumped
deliberate declines together with real technical failure.

Fix: give declines their own terminal rejected status (Twilio + WhatsApp
paths), keeping end_reason: agent_rejected. Genuine provider/network
failures stay failed. Frontend renders declines exactly as before;
existing rows backfilled via migration.

## Type of change

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

## How Has This Been Tested?

- Tested on UI

## Checklist:

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-23 12:22:29 +05:30
Sivin VargheseandGitHub 961ec86ba9 chore: support custom variables in auto-resolution message (#14782)
# Pull Request Template

## Description

This PR adds support for custom variables in auto-resolution messages
within Conversation Workflows.

Agents can insert variables (e.g. `{{contact.name}}`) into the custom
auto-resolution message using the variable picker by typing `{{`. These
variables are resolved with actual conversation data when the
auto-resolution message is sent.

The field uses the existing editor with the formatting toolbar hidden,
since auto-resolution messages are delivered across all channels,
including SMS, where formatting isn't supported. Existing message
content is preserved as-is, and typing `{{` opens the variable picker
for quick insertion.

Fixes
https://linear.app/chatwoot/issue/CW-7369/support-custom-variables-in-conversations-workflow-text-fields

## Type of change

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

## How Has This Been Tested?

### Screenshots
<img width="1262" height="764" alt="image"
src="https://github.com/user-attachments/assets/e43be22c-f86e-436a-9fdd-5bcb73c61ca3"
/>



## 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
- [ ] 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-23 11:35:52 +05:30
224 changed files with 6537 additions and 913 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 20
parallelism: 18
steps:
- checkout
- node/install:
+8 -3
View File
@@ -43,13 +43,18 @@
## General Guidelines
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- Prefer the smallest production-ready change that solves the current problem.
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
- Remove dead/unreachable/unused code
- Dont write multiple versions or backups for the same logic — pick the best approach and implement it
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
@@ -91,15 +91,17 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'],
fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
external_url: attachment['url'] || attachment.dig('payload', 'url')
}
end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Both `share` and `post` attachment types carry a page URL rather than a media file,
# so map them to `fallback` (which keeps the title/link without attempting a download).
# 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
return :fallback if [:share, :post].include?(type.to_sym)
super
end
@@ -30,7 +30,8 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled
:fair_distribution_limit, :fair_distribution_window, :enabled,
:exclude_older_than_hours
)
end
end
@@ -8,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models)
@current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features)
@current_account.save!
render json: preferences_payload
@@ -38,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models)
existing_models.merge(permitted_captain_models).compact_blank.presence
end
def merged_captain_features
@@ -47,29 +47,30 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
end
def permitted_captain_models
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys
end
def captain_feature_keys
Llm::Models.feature_keys.map(&:to_sym)
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
enabled: account_features[feature_key] == true,
selected: account_models[feature_key] || config[:default]
model: route[:model],
selected: route[:model],
provider: route[:provider],
source: route[:source]
)
end
end
@@ -1,5 +1,6 @@
class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController
before_action :ensure_api_inbox, only: :update
before_action :ensure_agent_bot_takeover, only: :create
def index
@messages = message_finder.perform
@@ -72,6 +73,21 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
message.translations.present? && message.translations[permitted_params[:target_language]].present?
end
def ensure_agent_bot_takeover
return unless Current.user.is_a?(User) && @conversation.assignee_agent_bot_id.present?
return if private_message? || incoming_message?
render json: { error: 'Conversation is assigned to an Agent Bot. Take over the conversation before replying.' }, status: :unprocessable_entity
end
def private_message?
ActiveModel::Type::Boolean.new.cast(params[:private])
end
def incoming_message?
params[:message_type] == 'incoming'
end
# API inbox check
def ensure_api_inbox
# Only API inboxes can update messages
@@ -21,11 +21,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@assignable_agents = @inbox.assignable_agents
end
def assignable_owners
@assignable_agents = @inbox.assignable_agents.select(&:confirmed?)
@agent_bots = AgentBot.accessible_to(Current.account)
end
def campaigns
@campaigns = @inbox.campaigns
end
@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
end
render_response(
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message)
)
end
@@ -1,17 +1,19 @@
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
ONBOARDING_STEP_KEY = 'onboarding_step'.freeze
STEP_ACCOUNT_DETAILS = 'account_details'.freeze
STEP_INBOX_SETUP = 'inbox_setup'.freeze
ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze
def update
return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step])
@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?
# The client declares the step it is completing; `account_details` runs
# `complete_account_details`, and so on. The known-step guard above keeps the
# client value from `send`-ing an arbitrary method.
send("complete_#{params[:onboarding_step]}")
render 'api/v1/accounts/update', format: :json
end
@@ -22,12 +24,48 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
private
def finalizing_account_details?
@account.custom_attributes['onboarding_step'] == 'account_details'
def complete_account_details
# Only act while the cursor still points here, so a stale replay after
# onboarding finished can't re-enter it.
return unless current_step == STEP_ACCOUNT_DETAILS
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
# inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded
# environment check); self-hosted finishes onboarding here.
if ChatwootApp.chatwoot_cloud?
move_to_step(STEP_INBOX_SETUP)
create_onboarding_inboxes
else
finish_onboarding
end
end
def website
custom_attributes_params[:website]
def complete_inbox_setup
# Only finalize while the cursor still points here, so a stale or out-of-order
# request can't end onboarding early. Replays are no-ops.
return unless current_step == STEP_INBOX_SETUP
finish_onboarding
end
def current_step
@account.custom_attributes[ONBOARDING_STEP_KEY]
end
def move_to_step(step)
@account.custom_attributes[ONBOARDING_STEP_KEY] = step
@account.save!
end
def finish_onboarding
@account.custom_attributes.delete(ONBOARDING_STEP_KEY)
@account.save!
end
def create_onboarding_inboxes
Onboarding::WebWidgetCreationService.new(@account, Current.user).perform
end
def account_params
@@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end
def team_params
params.require(:team).permit(:name, :description, :allow_auto_assign)
params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color)
end
end
@@ -10,7 +10,8 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr
response = dyte_processor_service.add_participant_to_meeting(
@message.content_attributes['data']['meeting_id'],
@conversation.contact
@conversation.contact,
@message
)
render_response(response)
end
@@ -0,0 +1,29 @@
module PortalHomeData
extend ActiveSupport::Concern
private
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
+5
View File
@@ -1,5 +1,6 @@
class DashboardController < ActionController::Base
include SwitchLocale
include PortalHomeData
GLOBAL_CONFIG_KEYS = %w[
LOGO
@@ -63,6 +64,10 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
if @portal.layout == 'documentation'
request.variant = :documentation
load_home_data
end
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
@@ -39,9 +39,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def switch_locale_with_portal(&)
@locale = validate_and_get_locale(params[:locale])
# Keep @locale as the portal's own locale code (e.g. th_TH) for content queries,
# while UI translations fall back to an available I18n locale (e.g. th).
@locale = params[:locale]
I18n.with_locale(@locale, &)
I18n.with_locale(validate_and_get_locale(@locale), &)
end
def switch_locale_with_article(&)
@@ -49,13 +51,12 @@ class Public::Api::V1::Portals::BaseController < PublicController
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank?
article_locale = if article.category.present?
article.category.locale
else
article.locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
@locale = if article.category.present?
article.category.locale
else
article.locale
end
I18n.with_locale(validate_and_get_locale(@locale), &)
end
def allow_iframe_requests
@@ -1,4 +1,6 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
include PortalHomeData
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
@@ -31,28 +33,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
@@ -35,7 +35,8 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
#
def resource_params
permitted_params = super
permitted_params[:limits] = permitted_params[:limits].to_h.compact
permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits)
permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models)
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
permitted_params
end
+4 -1
View File
@@ -18,6 +18,7 @@ class AccountDashboard < Administrate::BaseDashboard
# Add all_features last so it appears after manually_managed_features
attributes[:all_features] = AccountFeaturesField
attributes[:captain_models] = CaptainModelOverridesField
attributes
else
@@ -57,6 +58,7 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[custom_attributes limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs << :captain_models
attrs
else
[]
@@ -79,6 +81,7 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs << :captain_models
attrs
else
[]
@@ -117,7 +120,7 @@ class AccountDashboard < Administrate::BaseDashboard
# to prevent an error from being raised (wrong number of arguments)
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
def permitted_attributes(action)
attrs = super + [limits: {}]
attrs = super + [limits: {}, captain_models: {}]
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?
@@ -62,10 +62,9 @@ class ConversationApi extends ApiClient {
});
}
assignAgent({ conversationId, agentId, assigneeType }) {
assignAgent({ conversationId, agentId }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
assignee_type: assigneeType,
});
}
-4
View File
@@ -15,10 +15,6 @@ class Inboxes extends CacheEnabledApiClient {
return axios.get(`${this.url}/${inboxId}/campaigns`);
}
getAssignableOwners(inboxId) {
return axios.get(`${this.url}/${inboxId}/assignable_owners`);
}
deleteInboxAvatar(inboxId) {
return axios.delete(`${this.url}/${inboxId}/avatar`);
}
@@ -90,16 +90,11 @@ describe('#ConversationAPI', () => {
});
it('#assignAgent', () => {
conversationAPI.assignAgent({
conversationId: 12,
agentId: 34,
assigneeType: 'AgentBot',
});
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
assignee_type: 'AgentBot',
}
);
});
@@ -9,7 +9,6 @@ describe('#InboxesAPI', () => {
expect(inboxesAPI).toHaveProperty('create');
expect(inboxesAPI).toHaveProperty('update');
expect(inboxesAPI).toHaveProperty('delete');
expect(inboxesAPI).toHaveProperty('getAssignableOwners');
expect(inboxesAPI).toHaveProperty('getCampaigns');
expect(inboxesAPI).toHaveProperty('getAgentBot');
expect(inboxesAPI).toHaveProperty('setAgentBot');
@@ -38,13 +37,6 @@ describe('#InboxesAPI', () => {
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/inboxes/2/campaigns');
});
it('#getAssignableOwners', () => {
inboxesAPI.getAssignableOwners(2);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/inboxes/2/assignable_owners'
);
});
it('#deleteInboxAvatar', () => {
inboxesAPI.deleteInboxAvatar(2);
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
@@ -20,6 +20,7 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
@@ -28,13 +29,18 @@ const COLOR_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(props.status)
);
const labelKey = computed(() => {
@@ -168,6 +168,7 @@ const selectEmoji = emoji => {
<Button
v-if="showRemoveButton && value"
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
type="button"
variant="ghost"
color="slate"
size="sm"
@@ -34,6 +34,7 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x-bold',
};
const { t } = useI18n();
@@ -81,7 +82,11 @@ const isWhatsapp = computed(
() => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(status.value)
);
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
const endReason = computed(() => call.value?.endReason);
@@ -90,6 +90,7 @@ export const VOICE_CALL_STATUS = {
COMPLETED: 'completed',
NO_ANSWER: 'no-answer',
FAILED: 'failed',
REJECTED: 'rejected',
};
export const VOICE_CALL_DIRECTION = {
@@ -31,12 +31,22 @@ export default {
}
},
methods: {
createErrorMessage(error) {
const responseError = error?.response?.data?.error;
if (typeof responseError === 'string') return responseError;
return (
responseError?.error?.message ||
responseError?.message ||
this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR')
);
},
async onClick() {
this.isLoading = true;
try {
await DyteAPI.createAMeeting(this.conversationId);
} catch (error) {
useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR'));
useAlert(this.createErrorMessage(error));
} finally {
this.isLoading = false;
}
@@ -171,6 +171,9 @@ export default {
return this.isATwilioWhatsAppChannel && !this.isPrivate;
},
isPrivate() {
if (this.isAgentBotOwned) {
return true;
}
if (
this.currentChat.can_reply ||
this.isAWhatsAppChannel ||
@@ -197,10 +200,14 @@ export default {
},
isReplyRestricted() {
return (
!this.currentChat?.can_reply &&
!(this.isAWhatsAppChannel || this.isAPIInbox)
this.isAgentBotOwned ||
(!this.currentChat?.can_reply &&
!(this.isAWhatsAppChannel || this.isAPIInbox))
);
},
isAgentBotOwned() {
return this.currentChat?.meta?.assignee_type === 'AgentBot';
},
inboxId() {
return this.currentChat.inbox_id;
},
@@ -462,6 +469,11 @@ export default {
return;
}
if (this.isAgentBotOwned) {
this.replyType = REPLY_EDITOR_MODES.NOTE;
return;
}
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
this.replyType = REPLY_EDITOR_MODES.REPLY;
} else {
@@ -501,6 +513,9 @@ export default {
mounted() {
this.getFromDraft();
if (this.isAgentBotOwned) {
this.replyType = REPLY_EDITOR_MODES.NOTE;
}
// Don't use the keyboard listener mixin here as the events here are supposed to be
// working even if the editor is focussed.
document.addEventListener('paste', this.onPaste);
@@ -921,6 +936,9 @@ export default {
this.hideContentTemplatesModal();
},
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
if (this.isAgentBotOwned && mode === REPLY_EDITOR_MODES.REPLY) {
return;
}
// Clear attachments when switching between private note and reply modes
// This is to prevent from breaking the upload rules
if (this.attachedFiles.length > 0) this.attachedFiles = [];
@@ -4,7 +4,6 @@ import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import wootConstants from 'dashboard/constants/globals';
import Banner from 'dashboard/components/ui/Banner.vue';
@@ -34,6 +33,7 @@ const assignedAgent = computed({
store.dispatch('setCurrentChatAssignee', {
conversationId: currentChat.value?.id,
assignee: agent,
assigneeType: agent ? 'User' : null,
});
store.dispatch('assignAgent', {
conversationId: currentChat.value?.id,
@@ -42,9 +42,8 @@ const assignedAgent = computed({
},
});
const isUserTyping = computed(
() => props.message !== '' && !props.isOnPrivateNote
);
const hasMessage = computed(() => props.message !== '');
const isUserTyping = computed(() => hasMessage.value && !props.isOnPrivateNote);
const isUnassigned = computed(() => !assignedAgent.value);
const isAssignedToOtherAgent = computed(
() => assignedAgent.value?.id !== currentUser.value?.id
@@ -56,11 +55,11 @@ const showSelfAssignBanner = computed(() => {
);
});
const showBotHandoffBanner = computed(
() =>
isUserTyping.value &&
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
);
const showBotHandoffBanner = computed(() => {
return (
hasMessage.value && currentChat.value?.meta?.assignee_type === 'AgentBot'
);
});
const botHandoffActionLabel = computed(() => {
return assignedAgent.value?.id === currentUser.value?.id
@@ -89,7 +88,7 @@ const onClickSelfAssign = async () => {
const reopenConversation = async () => {
await store.dispatch('toggleStatus', {
conversationId: currentChat.value?.id,
status: wootConstants.STATUS_TYPE.OPEN,
status: 'open',
});
};
@@ -21,6 +21,7 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
@@ -29,13 +30,18 @@ const COLOR_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(props.status)
);
const labelKey = computed(() => {
@@ -31,7 +31,6 @@ const mockUseMapGetter = (overrides = {}) => {
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
'inboxAssignableAgents/getAssignableOwners': ref(() => []),
};
const mergedGetters = { ...defaultGetters, ...overrides };
@@ -76,32 +75,6 @@ describe('useAgentsList', () => {
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
});
it('includes agent bots when includeAgentBots is true', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableOwners': ref(() => [
{ id: 2, name: 'Agent', assignee_type: 'User' },
{
id: 1,
name: 'Bot',
thumbnail: '',
assignee_type: 'AgentBot',
icon: 'i-lucide-bot',
},
]),
});
const { agentsList } = useAgentsList(true, true);
expect(agentsList.value).toContainEqual(
expect.objectContaining({
id: 1,
name: 'Bot',
assignee_type: 'AgentBot',
icon: 'i-lucide-bot',
})
);
});
it('handles empty assignable agents', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
@@ -10,21 +10,14 @@ import {
* A composable function that provides a list of agents for assignment.
*
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
* @param {boolean} [includeAgentBots=false] - Whether to include agent bots as assignment options.
* @returns {Object} An object containing the agents list and assignable agents.
*/
export function useAgentsList(
includeNoneAgent = true,
includeAgentBots = false
) {
export function useAgentsList(includeNoneAgent = true) {
const { t } = useI18n();
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
const currentAccountId = useMapGetter('getCurrentAccountId');
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
const assignableOwners = useMapGetter(
'inboxAssignableAgents/getAssignableOwners'
);
const inboxId = computed(() => currentChat.value?.inbox_id);
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
@@ -49,22 +42,11 @@ export function useAgentsList(
return inboxId.value ? assignable.value(inboxId.value) : [];
});
const owners = computed(() => {
return includeAgentBots && inboxId.value
? assignableOwners.value(inboxId.value)
: [];
});
/**
* @type {import('vue').ComputedRef<Array>}
*/
const agentsList = computed(() => {
const agents = includeAgentBots
? owners.value.filter(owner => owner.assignee_type === 'User')
: assignableAgents.value || [];
const bots = owners.value.filter(
owner => owner.assignee_type === 'AgentBot'
);
const agents = assignableAgents.value || [];
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
currentUser.value,
@@ -78,7 +60,6 @@ export function useAgentsList(
return [
...(includeNoneAgent && isAgentSelected.value ? [createNoneAgent()] : []),
...filteredAgentsByAvailability,
...bots,
];
});
@@ -163,6 +163,11 @@ export const FORMATTING = {
nodes: [],
menu: [],
},
'Context::NoToolbar': {
marks: ['strong', 'em', 'link'],
nodes: ['bulletList', 'orderedList'],
menu: [],
},
};
// Editor menu options for Full Editor
@@ -162,4 +162,7 @@ export const SESSION_EVENTS = Object.freeze({
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
INBOX_SETUP_VISITED: 'Onboarding: Inbox setup visited',
INBOX_SETUP_COMPLETED: 'Onboarding: Inbox setup completed',
INBOX_SETUP_SKIPPED: 'Onboarding: Inbox setup skipped',
});
+18
View File
@@ -13,6 +13,24 @@ export const INBOX_TYPES = {
TIKTOK: 'Channel::Tiktok',
};
// Short channel-type slugs used to identify a channel without leaning on its
// Channel:: class name — e.g. onboarding channel cards and OAuth provider maps.
export const CHANNEL_TYPES = {
WEBSITE: 'website',
WHATSAPP: 'whatsapp',
FACEBOOK: 'facebook',
INSTAGRAM: 'instagram',
TIKTOK: 'tiktok',
TELEGRAM: 'telegram',
LINE: 'line',
GMAIL: 'gmail',
OUTLOOK: 'outlook',
SMS: 'sms',
API: 'api',
VOICE: 'voice',
EMAIL: 'email',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
+1
View File
@@ -7,6 +7,7 @@ export const TERMINAL_STATUSES = [
'completed',
'busy',
'failed',
'rejected',
'no-answer',
'canceled',
'missed',
@@ -30,5 +30,59 @@
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
},
"ONBOARDING_INBOX_SETUP": {
"GREETING": "Let's set up a few things",
"SUBTITLE": "This will give you head-start in your workspace",
"CONTINUE": "Continue",
"SKIP": "Skip",
"ERROR": "Something went wrong. Please try again.",
"WHATSAPP_CONNECTED": "WhatsApp connected successfully",
"FACEBOOK_CONNECTED": "Facebook connected successfully",
"CREATED_FOR_YOU": {
"TITLE": "We've created the following for you",
"LIVE_CHAT": "Live Chat widget",
"LIVE_CHAT_DESCRIPTION": "Instant messenger for your website",
"LIVE_CHAT_STATUS": "Almost done…",
"LIVE_CHAT_READY": "Ready",
"HELP_CENTER": "Help Center",
"HELP_CENTER_DESCRIPTION": "Your digital encyclopedia",
"HELP_CENTER_GENERATING": "Creating your help center…",
"HELP_CENTER_ANALYZING_WEBSITE": "Analyzing your website…",
"HELP_CENTER_SETTING_UP_CATEGORIES": "Setting up categories…",
"HELP_CENTER_CURATING_ARTICLES": "Curating articles…",
"HELP_CENTER_ARTICLES": "Created {count} article | Created {count} articles",
"HELP_CENTER_CATEGORIES": "{count} category | {count} categories",
"HELP_CENTER_SUMMARY": "Created {count} article across {categories} | Created {count} articles across {categories}"
},
"CHANNELS": {
"TITLE": "Connect all your conversation channels",
"HEADER": "We found a few channels you can connect",
"CONNECT": "Connect",
"CONNECTED": "Connected",
"MORE_CHANNELS_NOTE": "Set up {email} and {voice} channels later inside the app",
"MORE_CHANNELS_EMAIL": "Email",
"MORE_CHANNELS_VOICE": "Voice",
"VIEW_ALL": "View all",
"GMAIL": "Gmail",
"OUTLOOK": "Outlook",
"OTHER_EMAIL": "Other Email Providers"
},
"CHANNELS_DIALOG": {
"TITLE": "Connect all your channels instantly",
"SUBTITLE": "Manage all of them from one dashboard. You can also set up and edit inboxes later inside the app.",
"NOTE": "SMS, API, Voice, and other email providers can be set up later from your dashboard.",
"CONNECT_TITLE": "Connect your {name} account",
"CONNECT_SUBTITLE": "Fill out these quick details",
"CONNECT": "Connect",
"BACK": "Back",
"SETUP_LATER": "Setup later in app",
"FACEBOOK_SUBTITLE": "Authorize access and choose a Page to connect.",
"FACEBOOK_LAUNCH": "Continue with Facebook",
"FACEBOOK_SELECT_PAGE": "Select a Page to connect",
"FACEBOOK_LOADING": "Loading your Facebook Pages…",
"FACEBOOK_NO_PAGES": "No connectable Pages found. All your Pages are already connected.",
"FACEBOOK_ERROR": "Couldn't connect to Facebook. Please try again."
}
}
}
@@ -440,7 +440,9 @@
"DESCRIPTION": "Enable or disable AI-powered features.",
"AUDIO_TRANSCRIPTION": {
"TITLE": "Audio Transcription",
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts.",
"MODEL_TITLE": "Audio Transcription Model",
"MODEL_DESCRIPTION": "Select the AI model to use for converting audio messages into text transcripts"
},
"HELP_CENTER_SEARCH": {
"TITLE": "Help Center Search Indexing",
@@ -798,10 +800,14 @@
},
"FAIR_DISTRIBUTION": {
"LABEL": "Fair distribution policy",
"DESCRIPTION": "Set the maximum number of conversations that can be assigned per agent within a time window to avoid overloading any one agent. This required field defaults to 100 conversations per hour.",
"DESCRIPTION": "Cap conversations per agent within a time window to avoid overload. Defaults to 100 per hour.",
"INPUT_MAX": "Assign max",
"DURATION": "Conversations per agent in every"
},
"EXCLUDE_OLDER_THAN": {
"LABEL": "Skip stale conversations",
"DESCRIPTION": "Skip unassigned conversations older than this. Defaults to 7 days; clear to disable."
},
"INBOXES": {
"LABEL": "Added inboxes",
"DESCRIPTION": "Add inboxes for which this policy will be applicable.",
@@ -25,7 +25,7 @@ export default {
},
},
setup() {
const { agentsList } = useAgentsList(true, true);
const { agentsList } = useAgentsList();
return {
agentsList,
};
@@ -81,27 +81,18 @@ export default {
},
assignedAgent: {
get() {
const assignee = this.currentChat.meta.assignee;
if (!assignee) return assignee;
return {
...assignee,
assignee_type: this.currentChat.meta.assignee_type || 'User',
};
return this.currentChat.meta.assignee;
},
set(agent) {
const agentId = agent ? agent.id : null;
const assigneeType = agent ? agent.assignee_type || 'User' : null;
this.$store.dispatch('setCurrentChatAssignee', {
conversationId: this.currentChat.id,
assignee: agent,
assigneeType,
});
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
assigneeType,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
@@ -167,12 +158,6 @@ export default {
return false;
},
},
mounted() {
this.$store.dispatch(
'inboxAssignableAgents/fetchAssignableOwners',
this.currentChat.inbox_id
);
},
methods: {
onSelfAssign() {
const {
@@ -189,7 +174,6 @@ export default {
account_id,
availability_status,
available_name,
assignee_type: 'User',
email,
id,
name,
@@ -199,14 +183,7 @@ export default {
this.assignedAgent = selfAssign;
},
onClickAssignAgent(selectedItem) {
const currentAssigneeType = this.assignedAgent?.assignee_type || 'User';
const selectedAssigneeType = selectedItem.assignee_type || 'User';
if (
this.assignedAgent &&
this.assignedAgent.id === selectedItem.id &&
currentAssigneeType === selectedAssigneeType
) {
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
this.assignedAgent = null;
} else {
this.assignedAgent = selectedItem;
@@ -275,6 +252,7 @@ export default {
<MultiselectDropdown
:options="teamsList"
:selected-item="assignedTeam"
show-emoji-icon
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.TEAM')"
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
:no-search-result="
@@ -13,6 +13,7 @@ import AppContainer from './Dashboard.vue';
import Suspended from './suspended/Index.vue';
import NoAccounts from './noAccounts/Index.vue';
import OnboardingAccountDetails from './onboarding/Index.vue';
import OnboardingInboxSetup from './onboarding/InboxSetup.vue';
export default {
routes: [
@@ -40,6 +41,14 @@ export default {
},
component: OnboardingAccountDetails,
},
{
path: frontendURL('accounts/:accountId/onboarding/inbox-setup'),
name: 'onboarding_inbox_setup',
meta: {
permissions: ['administrator', 'agent', 'custom_role'],
},
component: OnboardingInboxSetup,
},
{
path: frontendURL('accounts/:accountId/suspended'),
name: 'account_suspended',
@@ -0,0 +1,158 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import OnboardingLayout from './shared/OnboardingLayout.vue';
import OnboardingSection from './shared/OnboardingSection.vue';
import InboxChannelsDialog from './inbox-setup/InboxChannelsDialog.vue';
import InboxChannelsFooter from './inbox-setup/InboxChannelsFooter.vue';
import ChannelRow from './inbox-setup/ChannelRow.vue';
import WebWidgetCreationStatus from './inbox-setup/WebWidgetCreationStatus.vue';
import HelpCenterCreationStatus from './inbox-setup/HelpCenterCreationStatus.vue';
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
import { useChannelConnect } from './inbox-setup/useChannelConnect';
import { useDetectedChannels } from './inbox-setup/useDetectedChannels';
import { DIALOG_CHANNELS } from './inbox-setup/constants';
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const { accountId, currentAccount, finishOnboarding } = useAccount();
const { isEnterprise } = useConfig();
const { connectViaOAuth, connectWhatsapp } = useChannelConnect();
const helpCenterGenerationId = computed(
() => currentAccount.value?.custom_attributes?.help_center_generation_id
);
const isSubmitting = ref(false);
const inboxes = useMapGetter('inboxes/getInboxes');
const {
displayedChannels,
remainingChannels,
connectedInbox,
hasDetectedChannels,
} = useDetectedChannels();
const channelsDialogRef = ref(null);
// The initial inboxes fetch happens in WebWidgetCreationStatus, which polls
// `inboxes/get` from its own mount — no need to dispatch it here too.
onMounted(() => {
useTrack(ONBOARDING_EVENTS.INBOX_SETUP_VISITED);
});
const completeOnboarding = async event => {
if (isSubmitting.value) return;
isSubmitting.value = true;
try {
// Declare the step we're completing so the controller only clears it when
// the stored step still matches (idempotent). setUser then refreshes the
// auth store so the router guard sees the cleared step and lets us in.
await finishOnboarding({ onboarding_step: 'inbox_setup' });
useTrack(event);
await store.dispatch('setUser');
router.push({ name: 'home', params: { accountId: accountId.value } });
} catch {
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
} finally {
isSubmitting.value = false;
}
};
const handleContinue = () =>
completeOnboarding(ONBOARDING_EVENTS.INBOX_SETUP_COMPLETED);
const handleSkip = () =>
completeOnboarding(ONBOARDING_EVENTS.INBOX_SETUP_SKIPPED);
const openChannelsDialog = () => channelsDialogRef.value?.open();
const refetchInboxes = () => store.dispatch('inboxes/get');
// WhatsApp connects via Meta's embedded-signup popup; the DIALOG_CHANNELS open
// the channels dialog preselected to their in-dialog step; the rest go through
// the redirect OAuth flow (Gmail/Outlook keyed by email provider, Instagram by
// channel type).
const connectChannel = channel => {
if (channel.type === CHANNEL_TYPES.WHATSAPP) {
connectWhatsapp();
return;
}
if (DIALOG_CHANNELS.includes(channel.type)) {
channelsDialogRef.value?.open(channel.type);
return;
}
connectViaOAuth(channel.inbox?.provider || channel.type);
};
</script>
<template>
<div>
<OnboardingLayout
:greeting="t('ONBOARDING_INBOX_SETUP.GREETING')"
:subtitle="t('ONBOARDING_INBOX_SETUP.SUBTITLE')"
:continue-label="t('ONBOARDING_INBOX_SETUP.CONTINUE')"
:skip-label="t('ONBOARDING_INBOX_SETUP.SKIP')"
:is-loading="isSubmitting"
@continue="handleContinue"
@skip="handleSkip"
>
<template #greeting-icon>
<Icon icon="i-lucide-wrench" class="size-4 text-n-slate-7" />
</template>
<OnboardingSection
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.TITLE')"
icon="i-lucide-sparkles"
>
<div class="divide-y divide-n-weak">
<WebWidgetCreationStatus />
<HelpCenterCreationStatus
v-if="isEnterprise && helpCenterGenerationId"
/>
</div>
</OnboardingSection>
<OnboardingSection
:title="t('ONBOARDING_INBOX_SETUP.CHANNELS.TITLE')"
icon="i-lucide-inbox"
>
<div
v-if="hasDetectedChannels"
class="flex items-center gap-2 p-3 border-b border-dashed border-n-strong"
>
<Icon
icon="i-lucide-lightbulb"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
<span class="flex-1 min-w-0 text-body-main text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.HEADER') }}
</span>
</div>
<ChannelRow
v-for="channel in displayedChannels"
:key="channel.type"
:channel="channel"
:connected-inbox="connectedInbox(channel)"
@connect="connectChannel"
/>
<InboxChannelsFooter
:remaining-channels="remainingChannels"
@view-all="openChannelsDialog"
/>
</OnboardingSection>
</OnboardingLayout>
<InboxChannelsDialog
ref="channelsDialogRef"
:inboxes="inboxes"
@connected="refetchInboxes"
/>
</div>
</template>
@@ -1,21 +1,22 @@
<script setup>
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
import { ref, computed, nextTick, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAlert, useTrack } from 'dashboard/composables';
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import OnboardingLayout from './OnboardingLayout.vue';
import OnboardingSection from './OnboardingSection.vue';
import OnboardingFormRow from './OnboardingFormRow.vue';
import OnboardingFormSelect from './OnboardingFormSelect.vue';
import OnboardingLayout from './shared/OnboardingLayout.vue';
import OnboardingSection from './shared/OnboardingSection.vue';
import OnboardingFormRow from './account-details/OnboardingFormRow.vue';
import OnboardingFormSelect from './account-details/OnboardingFormSelect.vue';
import { useAccountEnrichment } from './account-details/useAccountEnrichment';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import {
@@ -23,12 +24,16 @@ import {
INDUSTRY_OPTIONS,
REFERRAL_SOURCE_OPTIONS,
USER_ROLE_OPTIONS,
} from './constants';
} from './shared/constants';
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { accountId, currentAccount, finishOnboarding } = useAccount();
// Where each onboarding cursor routes. The backend owns which steps run where;
// the frontend just follows the cursor it advanced us to (no deployment checks).
const STEP_ROUTES = { inbox_setup: 'onboarding_inbox_setup' };
const { enabledLanguages } = useConfig();
const currentUser = useMapGetter('getCurrentUser');
@@ -46,7 +51,10 @@ const showErrorOnFields = ref(false);
const validationRules = {
userRole: {},
website: {},
// Website is required: the onboarding web-widget inbox can't be created
// without a URL (Channel::WebWidget validates presence), so a blank value
// would leave the "Live Chat widget" status polling forever.
website: { required },
locale: {},
timezone: {},
companySize: {},
@@ -67,12 +75,15 @@ const v$ = useVuelidate(validationRules, {
const userName = computed(() => currentUser.value?.name || '');
const userEmail = computed(() => currentUser.value?.email || '');
const accountName = computed(() => currentAccount.value?.name || '');
const enrichmentTimedOut = ref(false);
const isEnriching = computed(
() =>
!enrichmentTimedOut.value &&
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
);
const { isEnriching, getChangedFields } = useAccountEnrichment({
locale,
website,
timezone,
companySize,
industry,
referralSource,
});
const companyLogo = computed(() => {
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
if (!logos?.length) return '';
@@ -98,94 +109,10 @@ const timezoneOptions = computed(() => {
}
});
// Best-effort match browser language to enabled Chatwoot locales.
// Tries exact match first (e.g. 'pt_BR'), then base language (e.g. 'pt'),
// falls back to account locale or 'en'.
const detectBestLocale = () => {
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
const browserLang = navigator.language?.replace('-', '_');
const accountLocale = currentAccount.value?.locale || 'en';
if (!browserLang) return accountLocale;
if (codes.includes(browserLang)) return browserLang;
const base = browserLang.split('_')[0];
if (codes.includes(base)) return base;
return accountLocale;
};
// Snapshot of auto-populated values to detect user edits at submit time
const initialValues = ref({});
const snapshotInitialValues = () => {
initialValues.value = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
};
// Idempotent: only fills empty fields, so late-arriving enrichment data
// populates untouched fields without clobbering user edits.
const populateFormFields = () => {
const account = currentAccount.value;
const attrs = account?.custom_attributes || {};
const brandInfo = attrs.brand_info;
if (!locale.value) locale.value = detectBestLocale();
if (!website.value) {
website.value = attrs.website || brandInfo?.domain || '';
}
if (!timezone.value) {
timezone.value =
attrs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '';
}
if (!companySize.value) companySize.value = attrs.company_size || '';
if (!industry.value) {
industry.value =
attrs.industry || brandInfo?.industries?.[0]?.industry || '';
}
if (!referralSource.value) referralSource.value = attrs.referral_source || '';
snapshotInitialValues();
};
let enrichmentTimer = null;
const startEnrichmentTimer = () => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
enrichmentTimer = setTimeout(() => {
enrichmentTimedOut.value = true;
populateFormFields();
}, 30000);
};
onMounted(() => {
populateFormFields();
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
if (isEnriching.value) startEnrichmentTimer();
});
onUnmounted(() => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
});
watch(isEnriching, newVal => {
if (newVal) {
startEnrichmentTimer();
} else {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
populateFormFields();
}
});
// Re-populate when account data arrives after mount, or when brand_info
// appears after enrichment. populateFormFields is idempotent so this is safe.
watch(
() => currentAccount.value?.custom_attributes,
() => populateFormFields()
);
const enableWebsiteEditing = () => {
isEditingWebsite.value = true;
nextTick(() => websiteInput.value?.focus());
@@ -204,8 +131,10 @@ const normalizeWebsiteUrl = raw => {
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
// onboarding_step and persist incomplete data.
if (isEnriching.value) return;
// onboarding_step and persist incomplete data. Also guard against
// re-entry while a submit is in flight (double-click/Enter), which would
// fire parallel requests that can duplicate the auto-created inbox/portal.
if (isEnriching.value || isSubmitting.value) return;
v$.value.$touch();
if (v$.value.$invalid) {
@@ -214,22 +143,15 @@ const handleSubmit = async () => {
setTimeout(() => {
showErrorOnFields.value = false;
}, 600);
// The website field is read-only until edited; open it so a required-but-
// empty value is immediately fixable rather than just shaking a locked field.
if (v$.value.website.$error) enableWebsiteEditing();
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);
// Capture which enrichable fields the user edited *before* normalizing the
// website, so an untouched auto-filled domain isn't falsely flagged.
const fieldsChanged = getChangedFields();
// Persist with a scheme so downstream consumers (Firecrawl, portal
// homepage_link) get a fully-qualified URL regardless of what the user typed.
@@ -246,6 +168,7 @@ const handleSubmit = async () => {
timezone: timezone.value,
referral_source: referralSource.value,
user_role: userRole.value,
onboarding_step: 'account_details',
});
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
@@ -260,8 +183,19 @@ const handleSubmit = async () => {
});
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
store.commit('RESET_ONBOARDING', accountId.value);
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
// Follow the cursor the backend advanced us to. A next step routes there; no
// next step means onboarding is complete, so refresh the user (so the router
// guard sees the cleared cursor) and head to the dashboard.
const nextStep = currentAccount.value?.custom_attributes?.onboarding_step;
if (STEP_ROUTES[nextStep]) {
router.push({
name: STEP_ROUTES[nextStep],
params: { accountId: accountId.value },
});
} else {
await store.dispatch('setUser');
router.push({ name: 'home', params: { accountId: accountId.value } });
}
} catch {
useAlert(t('ONBOARDING_NEXT.ERROR'));
} finally {
@@ -0,0 +1,136 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
const ENRICHMENT_TIMEOUT = 30000;
// Manages the post-signup enrichment lifecycle for the account-details form.
// After signup the account is enriched asynchronously (onboarding_step ===
// 'enrichment'); this fills empty form fields from the enriched
// custom_attributes/brand_info as it arrives — idempotently, so it never
// clobbers a value the user has already typed — waits the step out (with a
// timeout fallback), and tracks which enrichable fields the user edited.
//
// `fields` is the set of form refs to populate, owned by the component so it can
// still wire them to validation and the template.
export function useAccountEnrichment(fields) {
const { currentAccount } = useAccount();
const { enabledLanguages } = useConfig();
const enrichmentTimedOut = ref(false);
const isEnriching = computed(
() =>
!enrichmentTimedOut.value &&
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
);
// Best-effort match browser language to enabled Chatwoot locales: exact match
// first (e.g. 'pt_BR'), then base language (e.g. 'pt'), else the account
// locale or 'en'.
const detectBestLocale = () => {
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
const browserLang = navigator.language?.replace('-', '_');
const accountLocale = currentAccount.value?.locale || 'en';
if (!browserLang) return accountLocale;
if (codes.includes(browserLang)) return browserLang;
const base = browserLang.split('_')[0];
if (codes.includes(base)) return base;
return accountLocale;
};
// Snapshot of the auto-populated values, used to detect user edits at submit.
const initialValues = ref({});
const snapshotInitialValues = () => {
initialValues.value = {
website: fields.website.value,
company_size: fields.companySize.value,
industry: fields.industry.value,
};
};
// Idempotent: only fills empty fields, so late-arriving enrichment data
// populates untouched fields without clobbering user edits.
const populateFormFields = () => {
const {
website,
timezone,
company_size: companySize,
industry,
referral_source: referralSource,
brand_info: brandInfo,
} = currentAccount.value?.custom_attributes || {};
const fillIfEmpty = (field, value) => {
if (!field.value) field.value = value || '';
};
fillIfEmpty(fields.locale, detectBestLocale());
fillIfEmpty(fields.website, website || brandInfo?.domain);
fillIfEmpty(
fields.timezone,
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone
);
fillIfEmpty(fields.companySize, companySize);
fillIfEmpty(
fields.industry,
industry || brandInfo?.industries?.[0]?.industry
);
fillIfEmpty(fields.referralSource, referralSource);
snapshotInitialValues();
};
let enrichmentTimer = null;
const startEnrichmentTimer = () => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
enrichmentTimer = setTimeout(() => {
enrichmentTimedOut.value = true;
populateFormFields();
}, ENRICHMENT_TIMEOUT);
};
onMounted(() => {
populateFormFields();
if (isEnriching.value) startEnrichmentTimer();
});
onUnmounted(() => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
});
watch(isEnriching, enriching => {
if (enriching) {
startEnrichmentTimer();
} else {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
populateFormFields();
}
});
// Re-populate when account data arrives after mount, or when brand_info
// appears after enrichment. populateFormFields is idempotent so this is safe.
watch(
() => currentAccount.value?.custom_attributes,
() => populateFormFields()
);
// Enrichable fields the user actually edited since they were auto-filled.
// Compare against the snapshot *before* the caller normalizes any values —
// otherwise an untouched auto-filled domain (acme.com -> https://acme.com)
// compares unequal and gets falsely reported as changed.
const getChangedFields = () => {
const init = initialValues.value;
const current = {
website: fields.website.value,
company_size: fields.companySize.value,
industry: fields.industry.value,
};
return Object.entries(current)
.filter(([key, value]) => value !== init[key])
.map(([key]) => key);
};
return { isEnriching, getChangedFields };
}
@@ -0,0 +1,60 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
const props = defineProps({
channel: { type: Object, required: true },
connectedInbox: { type: Object, default: null },
});
defineEmits(['connect']);
const { t } = useI18n();
const connected = computed(() => Boolean(props.connectedInbox));
// Prefer the real connected account's name over the detected handle — the user
// may have connected a different account than the one we detected.
const connectedName = computed(
() =>
props.connectedInbox?.name ||
props.channel.handle ||
t(props.channel.labelKey)
);
</script>
<template>
<div class="flex items-center gap-2 p-3 border-b border-n-weak">
<div class="size-4 rounded overflow-hidden flex-shrink-0">
<ChannelIcon
:inbox="channel.inbox"
use-brand-icon
class="size-4"
:class="{ grayscale: !connected }"
/>
</div>
<span class="flex-1 min-w-0 truncate text-body-main text-n-slate-12">
{{ t(channel.labelKey) }}
</span>
<div
v-if="connected"
class="flex items-center gap-2 flex-shrink-0 text-body-main text-n-slate-11"
>
<span class="whitespace-nowrap">{{ connectedName }}</span>
<span class="w-px h-3 bg-n-weak" />
<span class="whitespace-nowrap">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECTED') }}
</span>
</div>
<button
v-else
type="button"
class="flex items-center flex-shrink-0 h-7 px-2 rounded-lg outline outline-1 outline-n-container bg-n-button-color text-button text-n-blue-11"
@click="$emit('connect', channel)"
>
<span class="truncate">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECT') }}
</span>
</button>
</div>
</template>
@@ -0,0 +1,33 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
// Presentational shell for the "Created for you" rows. The owning component
// drives `ready` from its own lifecycle (widget polling, help-center events);
// row separation is handled by the parent's `divide-y`.
defineProps({
ready: { type: Boolean, default: false },
title: { type: String, required: true },
description: { type: String, default: '' },
status: { type: String, default: '' },
});
</script>
<template>
<div class="flex items-center justify-between gap-3 px-3 py-3">
<div class="flex items-center gap-2 min-w-0">
<Icon
v-if="ready"
icon="i-lucide-check"
class="size-4 text-n-teal-11 flex-shrink-0"
/>
<Spinner v-else :size="16" class="text-n-slate-9 flex-shrink-0" />
<span class="text-sm font-medium text-n-slate-12 flex-shrink-0">
{{ title }}
</span>
<span class="w-px h-4 bg-n-weak flex-shrink-0" />
<span class="text-sm text-n-slate-11 truncate">{{ description }}</span>
</div>
<span class="text-sm text-n-slate-11 flex-shrink-0">{{ status }}</span>
</div>
</template>
@@ -0,0 +1,116 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useTimeoutPoll } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import OnboardingAPI from 'dashboard/api/onboarding';
import CreationStatusRow from './CreationStatusRow.vue';
const { t } = useI18n();
const POLL_INTERVAL = 5000;
const generation = ref({
generation_id: null,
state: null,
articles_count: 0,
categories_count: 0,
});
// Before the first article arrives, advance through phases so the spinner
// label doesn't sit on a single line through the whole curation step.
const generatingPhases = computed(() => [
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_GENERATING'),
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_ANALYZING_WEBSITE'),
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_SETTING_UP_CATEGORIES'),
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_CURATING_ARTICLES'),
]);
const PHASE_DELAY_BASE = 4000;
const PHASE_DELAY_JITTER = 2000;
const phaseIndex = ref(0);
let phaseTimer = null;
// Advance one phase after a jittered delay; stop once we reach the last line
// (it then holds until the first article arrives).
const scheduleNextPhase = () => {
if (phaseIndex.value >= generatingPhases.value.length - 1) return;
const delay = PHASE_DELAY_BASE + Math.random() * PHASE_DELAY_JITTER;
phaseTimer = setTimeout(() => {
phaseIndex.value += 1;
scheduleNextPhase();
}, delay);
};
const status = computed(
() =>
generation.value.state?.status ||
(generation.value.generation_id ? 'generating' : 'not_started')
);
const isCompleted = computed(() => status.value === 'completed');
const isSkipped = computed(() => status.value === 'skipped');
const isNotStarted = computed(() => status.value === 'not_started');
const isTerminal = computed(
() => isCompleted.value || isSkipped.value || isNotStarted.value
);
const articlesCount = computed(() => generation.value.articles_count || 0);
const categoriesCount = computed(() => generation.value.categories_count || 0);
// Poll the generation status until it reaches a terminal state. useTimeoutPoll
// waits for each request to settle before scheduling the next (so requests never
// overlap), fires immediately on mount, and stops on unmount.
const { pause: stopPolling } = useTimeoutPoll(
async () => {
try {
const { data } = await OnboardingAPI.getHelpCenterGeneration();
generation.value = data;
if (isTerminal.value) stopPolling();
} catch {
// Keep polling; transient network failures should not strand onboarding.
}
},
POLL_INTERVAL,
{ immediate: true }
);
onMounted(scheduleNextPhase);
onBeforeUnmount(() => clearTimeout(phaseTimer));
const articlesText = computed(() =>
t(
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_ARTICLES',
{ count: articlesCount.value },
articlesCount.value
)
);
const statusText = computed(() => {
if (isCompleted.value) {
const categories = t(
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_CATEGORIES',
{ count: categoriesCount.value },
categoriesCount.value
);
return t(
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_SUMMARY',
{ count: articlesCount.value, categories },
articlesCount.value
);
}
if (articlesCount.value > 0) return articlesText.value;
return generatingPhases.value[phaseIndex.value];
});
const isVisible = computed(() => !isSkipped.value && !isNotStarted.value);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<CreationStatusRow
v-if="isVisible"
:ready="isCompleted"
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER')"
:description="
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_DESCRIPTION')
"
:status="statusText"
/>
</template>
@@ -0,0 +1,139 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
channel: { type: Object, required: true },
});
const emit = defineEmits(['back', 'created']);
const { t } = useI18n();
const store = useStore();
// Per-channel field list + payload shape, mirroring the standalone settings
// forms (channels/Line.vue, channels/Telegram.vue). Labels/placeholders reuse
// the existing INBOX_MGMT translations so there's nothing new to localize.
const FORMS = {
line: {
fields: [
{
key: 'lineChannelId',
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.LABEL'),
placeholder: t(
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.PLACEHOLDER'
),
},
{
key: 'lineChannelSecret',
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.LABEL'),
placeholder: t(
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.PLACEHOLDER'
),
type: 'password',
},
{
key: 'lineChannelToken',
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.LABEL'),
placeholder: t(
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.PLACEHOLDER'
),
type: 'password',
},
],
errorMessage: t('INBOX_MGMT.ADD.LINE_CHANNEL.API.ERROR_MESSAGE'),
// Inbox name is mandatory for Line; prefill it from the channel label
// rather than asking, to keep the form to just the credentials.
buildPayload: values => ({
name: t(props.channel.labelKey),
channel: {
type: 'line',
line_channel_id: values.lineChannelId,
line_channel_secret: values.lineChannelSecret,
line_channel_token: values.lineChannelToken,
},
}),
},
telegram: {
fields: [
{
key: 'botToken',
label: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.BOT_TOKEN.LABEL'),
placeholder: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.BOT_TOKEN.PLACEHOLDER'),
type: 'password',
},
],
errorMessage: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.API.ERROR_MESSAGE'),
buildPayload: values => ({
channel: { type: 'telegram', bot_token: values.botToken },
}),
},
};
const config = FORMS[props.channel.type];
const values = reactive(
Object.fromEntries(config.fields.map(field => [field.key, '']))
);
const isCreating = ref(false);
const isValid = computed(() =>
config.fields.every(field => values[field.key].trim())
);
const submit = async () => {
if (!isValid.value || isCreating.value) return;
isCreating.value = true;
try {
await store.dispatch('inboxes/createChannel', config.buildPayload(values));
emit('created');
} catch (error) {
useAlert(error?.message || config.errorMessage);
} finally {
isCreating.value = false;
}
};
</script>
<!-- This sits inside the dialog's <form>; intercept Enter so it submits the
channel here instead of bubbling up and completing onboarding. -->
<template>
<div class="flex flex-col gap-4" @keydown.enter.prevent="submit">
<div class="grid grid-cols-[auto_1fr] items-center gap-4">
<template v-for="field in config.fields" :key="field.key">
<label :for="field.key" class="text-sm font-medium text-n-slate-12">
{{ field.label }}
</label>
<Input
:id="field.key"
v-model="values[field.key]"
:type="field.type || 'text'"
:placeholder="field.placeholder"
/>
</template>
</div>
<div class="flex items-center gap-3">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
<NextButton
type="button"
blue
class="flex-1 justify-center"
:is-loading="isCreating"
:disabled="!isValid"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT')"
@click="submit"
/>
</div>
</div>
</template>
@@ -0,0 +1,210 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
import { useChannelConnect } from './useChannelConnect';
import { useChannelConfig } from './useChannelConfig';
import { CHANNEL_LIST } from './constants';
import { isChannelConnected } from './channelMatchers';
import InboxChannelForm from './InboxChannelForm.vue';
import InboxFacebookForm from './InboxFacebookForm.vue';
const props = defineProps({
inboxes: { type: Array, default: () => [] },
});
const emit = defineEmits(['connected']);
const { t } = useI18n();
const { connectViaOAuth, connectWhatsapp } = useChannelConnect();
const { isConfigured } = useChannelConfig();
// Maps the dialog's display types to the OAuth client key the flow expects.
// Types without an entry (manual-setup channels) are no-ops for now.
const OAUTH_PROVIDERS = {
[CHANNEL_TYPES.GMAIL]: 'google',
[CHANNEL_TYPES.OUTLOOK]: 'microsoft',
[CHANNEL_TYPES.INSTAGRAM]: 'instagram',
[CHANNEL_TYPES.TIKTOK]: 'tiktok',
};
// A card's availability — what the user can do with it right now:
// available — usable now (configured, not deferred)
// setupLater — deferred to in-app setup (SMS/API/Voice/Email cards)
// Channels whose installation OAuth credentials are missing are hidden entirely
// (see channelCards), so they never reach this state.
// `connected` (a real inbox already backs it) is orthogonal and tracked
// separately, since a connected channel can still be in any of these states.
const channelAvailability = channel =>
channel.setupLater ? 'setupLater' : 'available';
const CARD_CLASS = {
available: 'bg-n-solid-1 hover:outline-n-slate-6 cursor-pointer',
setupLater: 'bg-n-slate-2 cursor-not-allowed',
};
// Decorate the catalog with per-render state so the template reads plain fields
// rather than calling predicates for each card. Channels needing an absent
// installation credential are dropped so they don't show at all; deferred
// (setupLater) channels stay since they aren't a configuration problem.
const channelCards = computed(() =>
CHANNEL_LIST.filter(
channel => channel.setupLater || isConfigured(channel.type)
).map(channel => {
const connected = isChannelConnected(props.inboxes, channel.inbox);
// Website inboxes are only auto-created during onboarding — there is no
// manual creation path, so an unconnected Website card defers rather than
// offering a click that can't do anything.
const availability =
channel.type === CHANNEL_TYPES.WEBSITE && !connected
? 'setupLater'
: channelAvailability(channel);
return { ...channel, availability, connected };
})
);
const dialogRef = ref(null);
// Credential-form channels (Line, Telegram) and Facebook swap the grid for an
// inline view; OAuth channels redirect; the rest are no-ops for now.
const selectedChannel = ref(null);
// An inbox was created by an in-dialog form (Line/Telegram credentials or the
// Facebook page picker); close the dialog (its @close resets the form view) and
// let the parent refetch so the connected state and real channel icons update.
const onCreated = () => {
dialogRef.value?.close();
emit('connected');
};
const onCardClick = channel => {
if (channel.availability !== 'available') return;
if (channel.form) {
selectedChannel.value = channel;
return;
}
// WhatsApp uses Meta's embedded-signup popup, not the redirect OAuth flow.
if (channel.type === CHANNEL_TYPES.WHATSAPP) {
connectWhatsapp();
return;
}
// Facebook swaps to an in-dialog page picker (FB.login → choose a Page).
if (channel.type === CHANNEL_TYPES.FACEBOOK) {
selectedChannel.value = channel;
return;
}
connectViaOAuth(OAUTH_PROVIDERS[channel.type]);
};
const dialogTitle = computed(() =>
selectedChannel.value
? t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT_TITLE', {
name: t(selectedChannel.value.labelKey),
})
: t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.TITLE')
);
const dialogDescription = computed(() => {
if (!selectedChannel.value) {
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.SUBTITLE');
}
if (selectedChannel.value.type === CHANNEL_TYPES.FACEBOOK) {
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SUBTITLE');
}
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT_SUBTITLE');
});
const open = preselectType => {
const entry = preselectType
? channelCards.value.find(channel => channel.type === preselectType)
: null;
// Only jump straight into a channel's view when it's actually usable;
// otherwise show the grid (with its muted card) rather than launching SDK
// auth with a missing credential.
selectedChannel.value = entry?.availability === 'available' ? entry : null;
dialogRef.value?.open();
};
const close = () => dialogRef.value?.close();
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="dialogTitle"
:description="dialogDescription"
width="lg"
:show-confirm-button="false"
:show-cancel-button="false"
@close="selectedChannel = null"
>
<InboxFacebookForm
v-if="selectedChannel?.type === CHANNEL_TYPES.FACEBOOK"
@back="selectedChannel = null"
@created="onCreated"
/>
<InboxChannelForm
v-else-if="selectedChannel"
:channel="selectedChannel"
@back="selectedChannel = null"
@created="onCreated"
/>
<template v-else>
<div class="grid grid-cols-2 gap-3">
<button
v-for="channel in channelCards"
:key="channel.type"
type="button"
:disabled="channel.availability !== 'available'"
class="flex items-center gap-3 p-3 rounded-xl outline outline-1 outline-n-weak shadow-[0px_1px_2px_0px_rgba(27,28,29,0.036)] transition-colors text-start"
:class="CARD_CLASS[channel.availability]"
@click="onCardClick(channel)"
>
<div
class="size-9 rounded-[10px] outline outline-1 outline-n-weak flex items-center justify-center flex-shrink-0"
>
<ChannelIcon
v-if="channel.inbox"
:inbox="channel.inbox"
use-brand-icon
class="size-5 text-n-slate-11"
/>
<Icon
v-else
:icon="channel.fallbackIcon"
class="size-4 text-n-slate-11"
/>
</div>
<div class="flex-1 min-w-0">
<span class="block text-sm font-medium text-n-slate-12">
{{ t(channel.labelKey) }}
</span>
<span
v-if="channel.availability === 'setupLater'"
class="block text-xs text-n-slate-11"
>
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.SETUP_LATER') }}
</span>
</div>
<Icon
v-if="channel.connected"
icon="i-lucide-circle-check"
class="size-5 text-n-teal-11"
/>
<Icon
v-else-if="channel.availability === 'available'"
icon="i-lucide-chevron-right"
class="size-5 text-n-slate-9"
/>
</button>
</div>
<p class="text-sm text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.NOTE') }}
</p>
</template>
</Dialog>
</template>
@@ -0,0 +1,70 @@
<script setup>
import { computed } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
import { FALLBACK_PREVIEW_CHANNELS } from './constants';
import { useChannelConfig } from './useChannelConfig';
const props = defineProps({
remainingChannels: { type: Array, default: () => [] },
});
defineEmits(['viewAll']);
const { t } = useI18n();
const { isConfigured } = useChannelConfig();
// Icons shown next to "View all". Defaults to the unconnected socials, but when
// everything detected is already connected we still want a hint of what's
// behind the dialog — fall back to a representative trio. Either way, drop
// channels whose installation credentials are missing so we never preview an
// icon the dialog itself hides.
const previewChannels = computed(() =>
(props.remainingChannels.length
? props.remainingChannels
: FALLBACK_PREVIEW_CHANNELS
).filter(channel => isConfigured(channel.type))
);
</script>
<template>
<div class="flex items-center gap-2 px-3 pt-3 pb-4">
<Icon icon="i-lucide-info" class="size-4 text-n-slate-9 flex-shrink-0" />
<I18nT
keypath="ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_NOTE"
tag="span"
class="flex-1 min-w-0 text-body-main text-n-slate-11"
>
<template #email>
<span class="text-n-slate-12">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_EMAIL') }}
</span>
</template>
<template #voice>
<span class="text-n-slate-12">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_VOICE') }}
</span>
</template>
</I18nT>
<div class="flex items-center gap-2 flex-shrink-0">
<ChannelIcon
v-for="channel in previewChannels"
:key="channel.type"
:inbox="channel.inbox"
use-brand-icon
class="size-5"
/>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ps-1">
<span class="w-px h-3 bg-n-weak" />
<button
type="button"
class="text-button text-n-slate-12 hover:underline"
@click="$emit('viewAll')"
>
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.VIEW_ALL') }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,157 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const emit = defineEmits(['back', 'created']);
const { t } = useI18n();
const store = useStore();
const { isAuthenticating, preloadSdk, loginAndFetchPages } =
useFacebookPageConnect();
// null until the login + page fetch completes.
const pages = ref(null);
const userAccessToken = ref('');
const selectedPageId = ref('');
const errored = ref(false);
const isCreating = ref(false);
const hasFetched = computed(() => pages.value !== null);
// Pages already connected as inboxes come back with `exists: true`.
const pageOptions = computed(() =>
(pages.value || [])
.filter(page => !page.exists)
.map(page => ({ value: page.id, label: page.name }))
);
// Warm the SDK on open so the login click opens its popup within the gesture's
// activation window (see useFacebookPageConnect).
onMounted(preloadSdk);
const connect = async () => {
errored.value = false;
try {
const result = await loginAndFetchPages();
if (!result) return; // cancelled — stay on the connect prompt
userAccessToken.value = result.userAccessToken;
pages.value = result.pages;
} catch {
errored.value = true;
}
};
const createInbox = async () => {
const page = (pages.value || []).find(p => p.id === selectedPageId.value);
if (!page || isCreating.value) return;
isCreating.value = true;
try {
await store.dispatch('inboxes/createFBChannel', {
user_access_token: userAccessToken.value,
page_access_token: page.access_token,
page_id: page.id,
inbox_name: page.name,
});
useAlert(t('ONBOARDING_INBOX_SETUP.FACEBOOK_CONNECTED'));
emit('created');
} catch (error) {
useAlert(parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR'));
} finally {
isCreating.value = false;
}
};
</script>
<template>
<div class="flex flex-col gap-4">
<div
v-if="isAuthenticating"
class="flex items-center justify-center gap-3 py-8"
>
<Spinner :size="16" class="text-n-blue-10" />
<span class="text-sm text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LOADING') }}
</span>
</div>
<template v-else-if="hasFetched && pageOptions.length">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SELECT_PAGE') }}
</label>
<ComboBox
v-model="selectedPageId"
:options="pageOptions"
:placeholder="
t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SELECT_PAGE')
"
/>
</div>
<div class="flex items-center gap-3">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
<NextButton
type="button"
blue
class="flex-1 justify-center"
:is-loading="isCreating"
:disabled="!selectedPageId"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT')"
@click="createInbox"
/>
</div>
</template>
<template v-else-if="hasFetched">
<p class="text-sm text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES') }}
</p>
<div class="flex">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
</div>
</template>
<template v-else>
<p v-if="errored" class="text-sm text-n-ruby-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR') }}
</p>
<div class="flex items-center gap-3">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
<NextButton
type="button"
blue
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH')"
@click="connect"
/>
</div>
</template>
</div>
</template>
@@ -0,0 +1,44 @@
<script setup>
import { computed } from 'vue';
import { useTimeoutPoll } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import CreationStatusRow from './CreationStatusRow.vue';
const POLL_INTERVAL = 5000;
const { t } = useI18n();
const store = useStore();
// The web widget inbox is created asynchronously during account setup. We poll
// the inboxes endpoint until it shows up, then stop — much simpler than the
// event-driven help center flow.
const websiteInboxes = useMapGetter('inboxes/getWebsiteInboxes');
const isReady = computed(() => websiteInboxes.value.length > 0);
// useTimeoutPoll waits for each fetch to settle before scheduling the next (so
// requests never overlap), fires immediately on mount, and stops on unmount.
const { pause } = useTimeoutPoll(
async () => {
await store.dispatch('inboxes/get');
if (isReady.value) pause();
},
POLL_INTERVAL,
{ immediate: true }
);
</script>
<template>
<CreationStatusRow
:ready="isReady"
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT')"
:description="
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_DESCRIPTION')
"
:status="
isReady
? t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_READY')
: t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_STATUS')
"
/>
</template>
@@ -0,0 +1,18 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
// A detected channel maps to a real inbox when they share a channel_type. Gmail
// and Outlook both use Channel::Email, so for email we also match on provider.
// `stub` is a channel's `{ channel_type, provider }` shape (e.g. channel.inbox).
// Returns the matching inbox (not a boolean) so callers can show the connected
// account's real name rather than the detected handle.
export const findConnectedInbox = (inboxes, stub) =>
inboxes.find(
inbox =>
inbox.channel_type === stub?.channel_type &&
(stub?.channel_type !== INBOX_TYPES.EMAIL ||
inbox.provider === stub?.provider)
);
export const isChannelConnected = (inboxes, stub) =>
Boolean(stub) && Boolean(findConnectedInbox(inboxes, stub));
@@ -0,0 +1,149 @@
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
// Channels whose connect flow opens the channels dialog preselected to their
// in-dialog step — Facebook (page picker) and the credential-form channels
// (Telegram, Line) — rather than redirecting through OAuth.
export const DIALOG_CHANNELS = [
CHANNEL_TYPES.FACEBOOK,
CHANNEL_TYPES.TELEGRAM,
CHANNEL_TYPES.LINE,
];
// Suggested channels (in priority order) to offer as rows when nothing is
// detected, so the step isn't empty. The mainstream OAuth channels show on
// configured installs, while credential-free Telegram/LINE keep the list
// non-empty on a bare self-host.
export const DEFAULT_CHANNEL_TYPES = [
CHANNEL_TYPES.WHATSAPP,
CHANNEL_TYPES.FACEBOOK,
CHANNEL_TYPES.INSTAGRAM,
CHANNEL_TYPES.TELEGRAM,
CHANNEL_TYPES.LINE,
];
// Channels offered in the onboarding "View all" dialog. `inbox` is a stub shaped
// like a real inbox so ChannelIcon can resolve the icon from the shared provider.
// With `use-brand-icon`, ChannelIcon renders the full-color brand logo when one
// exists and falls back to the monochrome glyph otherwise, so no per-channel
// style flag is needed. Entries without a channel type (Voice, Other Email
// Providers) render `fallbackIcon` instead. `form: true` swaps the grid for an
// inline credential form; `setupLater: true` defers the channel to in-app setup
// for this phase. `labelKey` is an i18n key — most reuse the shared channel
// titles from the inbox settings (INBOX_MGMT.ADD.AUTH.CHANNEL.*.TITLE) so the
// names translate without duplicating strings; resolve it with `t()` at display.
export const CHANNEL_LIST = [
{
type: 'website',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE',
inbox: { channel_type: 'Channel::WebWidget' },
},
{
type: 'whatsapp',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
inbox: { channel_type: 'Channel::Whatsapp' },
},
{
type: 'instagram',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
inbox: { channel_type: 'Channel::Instagram' },
},
{
type: 'facebook',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
inbox: { channel_type: 'Channel::FacebookPage' },
},
{
type: 'tiktok',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
inbox: { channel_type: 'Channel::Tiktok' },
},
{
type: 'telegram',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
inbox: { channel_type: 'Channel::Telegram' },
form: true,
},
{
type: 'line',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
inbox: { channel_type: 'Channel::Line' },
form: true,
},
// Email channels (including Gmail/Outlook OAuth) are set up later in-app for
// this phase; they will be enabled in a future PR.
{
type: 'gmail',
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.GMAIL',
inbox: { channel_type: 'Channel::Email', provider: 'google' },
setupLater: true,
},
{
type: 'outlook',
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OUTLOOK',
inbox: { channel_type: 'Channel::Email', provider: 'microsoft' },
setupLater: true,
},
{
type: 'sms',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.SMS.TITLE',
inbox: { channel_type: 'Channel::Sms' },
setupLater: true,
},
{
type: 'api',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.API.TITLE',
inbox: { channel_type: 'Channel::Api' },
setupLater: true,
},
{
type: 'voice',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE',
fallbackIcon: 'i-woot-voice',
setupLater: true,
},
{
type: 'email',
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OTHER_EMAIL',
fallbackIcon: 'i-woot-mail',
setupLater: true,
},
];
const channelByType = type =>
CHANNEL_LIST.find(channel => channel.type === type);
// Icons shown next to "View all" when every detected channel is already
// connected — a representative trio sourced from CHANNEL_LIST so the inbox stubs
// aren't duplicated.
export const FALLBACK_PREVIEW_CHANNELS = ['gmail', 'tiktok', 'whatsapp'].map(
channelByType
);
// Social channels that detected brand_info socials map to, keyed by social type
// in the order they're offered as rows. Derived from CHANNEL_LIST so channel
// identity (label, channel_type) has a single source. Keys mirror
// SocialLinkParser::SOCIAL_DOMAIN_MAP.
const SOCIAL_PLATFORM_TYPES = [
'whatsapp',
'facebook',
'line',
'instagram',
'telegram',
'tiktok',
];
export const SOCIAL_PLATFORMS = Object.fromEntries(
SOCIAL_PLATFORM_TYPES.map(type => {
const { labelKey, inbox } = channelByType(type);
return [type, { labelKey, channelType: inbox.channel_type }];
})
);
// Mailbox providers inferred from the signup domain's MX records, keyed by
// Channel::Email#provider. Derived from CHANNEL_LIST's email entries.
export const EMAIL_PROVIDERS = Object.fromEntries(
CHANNEL_LIST.filter(channel => channel.inbox?.provider).map(channel => [
channel.inbox.provider,
{ labelKey: channel.labelKey },
])
);
@@ -0,0 +1,29 @@
import { useMapGetter } from 'dashboard/composables/store';
// OAuth/SDK channels need installation-level app credentials to be usable. When
// the credential is missing the channel is "not configured" and is hidden from
// onboarding entirely. Channels without an entry (Website, Telegram, Line, …)
// need no installation credential and are always considered configured.
// Mirrors the availability checks in ChannelItem.vue.
export function useChannelConfig() {
const globalConfig = useMapGetter('globalConfig/get');
const installationConfig = window.chatwootConfig || {};
const CHANNEL_CONFIGURED = {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
facebook: () => Boolean(installationConfig.fbAppId),
instagram: () => Boolean(installationConfig.instagramAppId),
tiktok: () => Boolean(installationConfig.tiktokAppId),
gmail: () => Boolean(installationConfig.googleOAuthClientId),
outlook: () => Boolean(globalConfig.value.azureAppId),
};
const isConfigured = type => CHANNEL_CONFIGURED[type]?.() ?? true;
return { isConfigured };
}
@@ -0,0 +1,67 @@
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import googleClient from 'dashboard/api/channel/googleClient';
import microsoftClient from 'dashboard/api/channel/microsoftClient';
import instagramClient from 'dashboard/api/channel/instagramClient';
import tiktokClient from 'dashboard/api/channel/tiktokClient';
// Channels that complete via an OAuth redirect. Email channels are keyed by their
// Channel::Email provider, others by channel type. The request is tagged with a
// return hint so the callback brings the user back to onboarding instead of the
// inbox settings page.
const OAUTH_CLIENTS = {
google: googleClient,
microsoft: microsoftClient,
instagram: instagramClient,
tiktok: tiktokClient,
};
export function useChannelConnect() {
const { t } = useI18n();
const store = useStore();
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const connectViaOAuth = async provider => {
const client = OAUTH_CLIENTS[provider];
if (!client) return;
try {
const {
data: { url },
} = await client.generateAuthorization({ return_to: 'onboarding' });
window.location.href = url;
} catch {
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
}
};
// WhatsApp connects via Meta's embedded-signup popup instead of the redirect
// OAuth flow above. Collect the signup credentials, exchange them for an
// inbox, and surface the result inline — then refetch so the connected state
// reflects the freshly created inbox (and renders its real channel icon).
const connectWhatsapp = async () => {
let credentials;
try {
credentials = await runEmbeddedSignup();
} catch {
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
return;
}
if (!credentials) return; // user dismissed the popup
try {
await store.dispatch('inboxes/createWhatsAppEmbeddedSignup', credentials);
await store.dispatch('inboxes/get');
useAlert(t('ONBOARDING_INBOX_SETUP.WHATSAPP_CONNECTED'));
} catch (error) {
useAlert(
parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR')
);
}
};
return { connectViaOAuth, connectWhatsapp };
}
@@ -0,0 +1,130 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import {
SOCIAL_PLATFORMS,
EMAIL_PROVIDERS,
DEFAULT_CHANNEL_TYPES,
} from './constants';
import { findConnectedInbox } from './channelMatchers';
import { useChannelConfig } from './useChannelConfig';
// How many channel rows to show, whether detected or defaulted. DEFAULT_CHANNEL_TYPES
// is config-gated like everything else, then sliced to this limit.
const DISPLAYED_CHANNEL_LIMIT = 3;
// Pull the handle/username out of a detected social URL, formatted per channel.
const extractHandle = ({ type, url }) => {
try {
const { pathname } = new URL(url);
const path = pathname.replace(/^\/+|\/+$/g, '');
if (type === 'whatsapp') {
const digits = path.replace(/\D/g, '');
return digits ? `+${digits}` : '';
}
if (type === 'line') return path;
return path.startsWith('@') ? path : `@${path}`;
} catch {
return '';
}
};
// Derives the channel rows for the inbox-setup step from the account's detected
// brand_info (socials + mailbox provider) and the real connected inboxes,
// keeping InboxSetup.vue focused on layout, connect routing, and completion.
export function useDetectedChannels() {
const { currentAccount } = useAccount();
const inboxes = useMapGetter('inboxes/getInboxes');
const { isConfigured } = useChannelConfig();
const brandSocials = computed(
() => currentAccount.value?.custom_attributes?.brand_info?.socials || []
);
const connectedChannels = computed(() =>
brandSocials.value
.filter(social => SOCIAL_PLATFORMS[social.type] && social.url)
.map(social => ({
type: social.type,
handle: extractHandle(social),
labelKey: SOCIAL_PLATFORMS[social.type].labelKey,
inbox: { channel_type: SOCIAL_PLATFORMS[social.type].channelType },
}))
);
const detectedEmailChannel = computed(() => {
const brandInfo = currentAccount.value?.custom_attributes?.brand_info;
const provider = brandInfo?.email_provider;
if (!EMAIL_PROVIDERS[provider]) return null;
return {
type: 'email',
handle: brandInfo?.email || '',
labelKey: EMAIL_PROVIDERS[provider].labelKey,
inbox: { channel_type: 'Channel::Email', provider },
};
});
// The real inbox backing a channel, if one exists — returned (not just a
// boolean) so the row can show the connected account's real name.
const connectedInbox = channel =>
findConnectedInbox(inboxes.value, channel.inbox);
// A channel row built from a social type, with no detected handle — used for
// the default suggestions when nothing was detected.
const toChannelRow = type => ({
type,
handle: '',
labelKey: SOCIAL_PLATFORMS[type].labelKey,
inbox: { channel_type: SOCIAL_PLATFORMS[type].channelType },
});
const detectedChannels = computed(() =>
[detectedEmailChannel.value, ...connectedChannels.value]
.filter(Boolean)
// Email channels (including Gmail/Outlook OAuth) are disabled for this
// phase; they will be enabled in a future PR.
.filter(channel => channel.type !== 'email')
// Hide channels whose installation OAuth credentials are missing — their
// connect flow would only error.
.filter(channel => isConfigured(channel.type))
);
const defaultChannels = computed(() =>
DEFAULT_CHANNEL_TYPES.filter(isConfigured)
.slice(0, DISPLAYED_CHANNEL_LIMIT)
.map(toChannelRow)
);
// Show the detected channels, or fall back to the default suggestions so the
// step is never an empty list.
const displayedChannels = computed(() =>
detectedChannels.value.length
? detectedChannels.value
: defaultChannels.value
);
const remainingChannels = computed(() => {
// Exclude whatever is already shown as a row (detected or defaulted) so the
// footer preview doesn't duplicate it.
const shownTypes = new Set(displayedChannels.value.map(c => c.type));
return Object.entries(SOCIAL_PLATFORMS)
.filter(([type]) => !shownTypes.has(type))
.filter(([type]) => isConfigured(type))
.slice(0, 3)
.map(([type, { labelKey, channelType }]) => ({
type,
labelKey,
inbox: { channel_type: channelType },
}));
});
const hasDetectedChannels = computed(() => detectedChannels.value.length > 0);
return {
displayedChannels,
remainingChannels,
connectedInbox,
hasDetectedChannels,
};
}
@@ -5,11 +5,12 @@ defineProps({
greeting: { type: String, required: true },
subtitle: { type: String, default: '' },
continueLabel: { type: String, default: 'Continue' },
skipLabel: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
defineEmits(['continue']);
defineEmits(['continue', 'skip']);
</script>
<template>
@@ -20,7 +21,7 @@ defineEmits(['continue']);
<div
class="absolute inset-0 bg-[size:96px_96px] bg-[image:linear-gradient(to_right,rgb(var(--border-weak))_1px,transparent_1px),linear-gradient(to_bottom,rgb(var(--border-weak))_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_80%_80%_at_100%_0%,black_5%,transparent_50%),radial-gradient(ellipse_80%_80%_at_0%_100%,black_5%,transparent_50%)] [mask-composite:add] [-webkit-mask-composite:source-over]"
/>
<div class="relative w-full max-w-[580px]">
<div class="relative w-full max-w-[40rem]">
<div class="relative ps-12">
<!-- Timeline dotted line -->
<svg
@@ -104,16 +105,30 @@ defineEmits(['continue']);
>
<path d="M6 0L0 3L6 6Z" fill="rgb(var(--blue-9))" />
</svg>
<NextButton
type="submit"
blue
:is-loading="isLoading"
:disabled="disabled"
class="w-full justify-center"
@click="$emit('continue')"
>
{{ continueLabel }}
</NextButton>
<div class="flex items-center gap-3">
<NextButton
type="submit"
blue
:is-loading="isLoading"
:disabled="disabled"
:class="
skipLabel ? 'flex-1 justify-center' : 'w-full justify-center'
"
@click="$emit('continue')"
>
{{ continueLabel }}
</NextButton>
<NextButton
v-if="skipLabel"
type="button"
slate
outline
class="flex-1 justify-center"
@click="$emit('skip')"
>
{{ skipLabel }}
</NextButton>
</div>
</div>
</div>
</div>
@@ -4,6 +4,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
bare: { type: Boolean, default: false },
});
</script>
@@ -42,8 +43,12 @@ defineProps({
</div>
<!-- Card -->
<div class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1">
<div
v-if="!bare"
class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1"
>
<slot />
</div>
<slot v-else />
</div>
</template>
@@ -0,0 +1,186 @@
import { defineComponent, h, ref } from 'vue';
import { createStore } from 'vuex';
import { mount } from '@vue/test-utils';
import { useRoute } from 'vue-router';
import { useAccountEnrichment } from '../../account-details/useAccountEnrichment';
vi.mock('vue-router');
const ENABLED_LANGUAGES = [
{ iso_639_1_code: 'en', name: 'English' },
{ iso_639_1_code: 'fr', name: 'French' },
];
// Mounts the composable against a real store and the real useAccount/useConfig
// (only useRoute and the underlying account getter / window config are faked),
// so a change to how those resolve their data is exercised here too. `presets`
// seeds form fields as if the user had already typed them.
const mountComposable = ({
account = {},
enabledLanguages = ENABLED_LANGUAGES,
presets = {},
} = {}) => {
window.chatwootConfig = { enabledLanguages };
const store = createStore({
modules: {
accounts: {
namespaced: true,
getters: { getAccount: () => () => account },
},
},
});
const fields = {
locale: ref(presets.locale || ''),
website: ref(presets.website || ''),
timezone: ref(presets.timezone || ''),
companySize: ref(presets.companySize || ''),
industry: ref(presets.industry || ''),
referralSource: ref(presets.referralSource || ''),
};
let api;
const Component = defineComponent({
setup() {
api = useAccountEnrichment(fields);
return () => h('div');
},
});
const wrapper = mount(Component, { global: { plugins: [store] } });
return { ...api, fields, wrapper };
};
beforeEach(() => {
useRoute.mockReturnValue({ params: { accountId: '1' } });
});
afterEach(() => {
delete window.chatwootConfig;
});
describe('useAccountEnrichment', () => {
describe('populateFormFields', () => {
it('fills empty fields from the enriched attributes on mount', () => {
const { fields } = mountComposable({
account: {
locale: 'en',
custom_attributes: {
website: 'https://acme.com',
timezone: 'America/New_York',
company_size: '11-50',
industry: 'Technology',
referral_source: 'google',
},
},
});
expect(fields.website.value).toBe('https://acme.com');
expect(fields.timezone.value).toBe('America/New_York');
expect(fields.companySize.value).toBe('11-50');
expect(fields.industry.value).toBe('Technology');
expect(fields.referralSource.value).toBe('google');
});
it('falls back to brand_info for website and industry', () => {
const { fields } = mountComposable({
account: {
custom_attributes: {
brand_info: {
domain: 'acme.com',
industries: [{ industry: 'Retail & E-commerce' }],
},
},
},
});
expect(fields.website.value).toBe('acme.com');
expect(fields.industry.value).toBe('Retail & E-commerce');
});
it('does not clobber fields the user already set', () => {
const { fields } = mountComposable({
presets: { website: 'mysite.com', industry: 'Finance' },
account: {
custom_attributes: {
website: 'https://enriched.com',
industry: 'Technology',
},
},
});
expect(fields.website.value).toBe('mysite.com');
expect(fields.industry.value).toBe('Finance');
});
it('detects the locale from the browser, else the account locale', () => {
// jsdom reports navigator.language as 'en-US' -> base 'en' is enabled.
const { fields } = mountComposable({ account: { locale: 'de' } });
expect(fields.locale.value).toBe('en');
// No enabled language matches the browser -> fall back to account locale.
const { fields: other } = mountComposable({
account: { locale: 'de' },
enabledLanguages: [{ iso_639_1_code: 'es', name: 'Spanish' }],
});
expect(other.locale.value).toBe('de');
});
});
describe('isEnriching', () => {
it('is true while the account is on the enrichment step', () => {
const { isEnriching } = mountComposable({
account: { custom_attributes: { onboarding_step: 'enrichment' } },
});
expect(isEnriching.value).toBe(true);
});
it('is false on any other step', () => {
const { isEnriching } = mountComposable({
account: { custom_attributes: { onboarding_step: 'account_details' } },
});
expect(isEnriching.value).toBe(false);
});
it('times out after 30s, flipping to false and populating', () => {
vi.useFakeTimers();
try {
const { isEnriching, fields } = mountComposable({
account: {
custom_attributes: {
onboarding_step: 'enrichment',
company_size: '51-200',
},
},
});
expect(isEnriching.value).toBe(true);
vi.advanceTimersByTime(30000);
expect(isEnriching.value).toBe(false);
expect(fields.companySize.value).toBe('51-200');
} finally {
vi.useRealTimers();
}
});
});
describe('getChangedFields', () => {
it('lists only enrichable fields edited after auto-fill', () => {
const { fields, getChangedFields } = mountComposable({
account: {
custom_attributes: {
website: 'https://acme.com',
company_size: '11-50',
industry: 'Technology',
},
},
});
expect(getChangedFields()).toEqual([]);
fields.industry.value = 'Finance';
expect(getChangedFields()).toEqual(['industry']);
});
});
});
@@ -0,0 +1,123 @@
import { flushPromises, mount } from '@vue/test-utils';
import HelpCenterCreationStatus from '../../inbox-setup/HelpCenterCreationStatus.vue';
import OnboardingAPI from 'dashboard/api/onboarding';
vi.mock('dashboard/api/onboarding', () => ({
default: {
getHelpCenterGeneration: vi.fn(),
},
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params = {}) => {
if (key.endsWith('HELP_CENTER_CATEGORIES')) {
return `${params.count} categories`;
}
if (key.endsWith('HELP_CENTER_SUMMARY')) {
return `${params.count} articles across ${params.categories}`;
}
if (key.endsWith('HELP_CENTER_ARTICLES')) {
return `${params.count} articles`;
}
return key;
},
}),
}));
const mountStatus = () =>
mount(HelpCenterCreationStatus, {
global: {
stubs: {
CreationStatusRow: {
props: ['ready', 'title', 'description', 'status'],
template:
'<div data-test="row" :data-ready="ready">{{ status }}</div>',
},
},
},
});
describe('HelpCenterCreationStatus', () => {
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('renders completed summary from the status endpoint', async () => {
OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
data: {
generation_id: 'generation-123',
state: { status: 'completed' },
articles_count: 3,
categories_count: 2,
},
});
const wrapper = mountStatus();
await flushPromises();
expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
'true'
);
expect(wrapper.find('[data-test="row"]').text()).toBe(
'3 articles across 2 categories'
);
});
it('hides the row when generation is skipped', async () => {
OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
data: {
generation_id: 'generation-123',
state: { status: 'skipped' },
},
});
const wrapper = mountStatus();
await flushPromises();
expect(wrapper.find('[data-test="row"]').exists()).toBe(false);
});
it('polls while generating and stops after completion', async () => {
vi.useFakeTimers();
OnboardingAPI.getHelpCenterGeneration
.mockResolvedValueOnce({
data: {
generation_id: 'generation-123',
state: { status: 'generating' },
articles_count: 1,
categories_count: 0,
},
})
.mockResolvedValueOnce({
data: {
generation_id: 'generation-123',
state: { status: 'completed' },
articles_count: 2,
categories_count: 1,
},
});
const wrapper = mountStatus();
await flushPromises();
expect(wrapper.find('[data-test="row"]').text()).toBe('1 articles');
vi.advanceTimersByTime(5000);
await flushPromises();
expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
'true'
);
expect(wrapper.find('[data-test="row"]').text()).toBe(
'2 articles across 1 categories'
);
vi.advanceTimersByTime(5000);
await flushPromises();
expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,59 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import InboxChannelsDialog from '../../inbox-setup/InboxChannelsDialog.vue';
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
vi.mock('dashboard/composables/store', () => ({
useMapGetter: () => ({ value: {} }),
}));
vi.mock('../../inbox-setup/useChannelConnect', () => ({
useChannelConnect: () => ({
connectViaOAuth: vi.fn(),
connectWhatsapp: vi.fn(),
}),
}));
const mountDialog = () =>
mount(InboxChannelsDialog, {
props: { inboxes: [] },
global: {
stubs: {
Dialog: {
template: '<div><slot /></div>',
methods: { open() {}, close() {} },
},
InboxFacebookForm: { template: '<div data-test="fb-form" />' },
InboxChannelForm: { template: '<div data-test="channel-form" />' },
ChannelIcon: true,
Icon: true,
},
},
});
describe('InboxChannelsDialog Facebook gating', () => {
afterEach(() => {
delete window.chatwootConfig;
});
it('opens the Facebook page picker when fbAppId is configured', async () => {
window.chatwootConfig = { fbAppId: 'fb-app' };
const wrapper = mountDialog();
wrapper.vm.open('facebook');
await nextTick();
expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(true);
});
it('shows the grid (not the picker) when fbAppId is missing', async () => {
window.chatwootConfig = {};
const wrapper = mountDialog();
wrapper.vm.open('facebook');
await nextTick();
expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(false);
// The channel grid renders its cards instead.
expect(wrapper.find('button').exists()).toBe(true);
});
});
@@ -0,0 +1,158 @@
import { flushPromises, mount } from '@vue/test-utils';
import { ref, nextTick } from 'vue';
import InboxFacebookForm from '../../inbox-setup/InboxFacebookForm.vue';
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
vi.mock('dashboard/store/utils/api', () => ({
parseAPIErrorResponse: vi.fn(),
}));
vi.mock('dashboard/composables/useFacebookPageConnect', () => ({
useFacebookPageConnect: vi.fn(),
}));
const { dispatch } = vi.hoisted(() => ({ dispatch: vi.fn() }));
vi.mock('dashboard/composables/store', () => ({
useStore: () => ({ dispatch }),
}));
const NextButtonStub = {
props: ['label', 'disabled', 'isLoading'],
emits: ['click'],
template: `<button :disabled="disabled" @click="$emit('click')">{{ label }}</button>`,
};
const ComboBoxStub = {
props: ['modelValue', 'options'],
emits: ['update:modelValue'],
template: '<div data-test="combobox" />',
};
const PAGES = [
{ id: 'p1', name: 'Page One', access_token: 'pt1' },
{ id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
];
const LAUNCH = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH';
const CONNECT = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT';
let loginAndFetchPages;
let preloadSdk;
const mountForm = () =>
mount(InboxFacebookForm, {
global: {
stubs: {
NextButton: NextButtonStub,
ComboBox: ComboBoxStub,
Spinner: true,
},
},
});
const clickButton = (wrapper, label) =>
wrapper
.findAll('button')
.find(button => button.text() === label)
.trigger('click');
beforeEach(() => {
vi.clearAllMocks();
preloadSdk = vi.fn();
loginAndFetchPages = vi.fn();
useFacebookPageConnect.mockReturnValue({
isAuthenticating: ref(false),
preloadSdk,
loginAndFetchPages,
});
dispatch.mockResolvedValue({ id: 1 });
});
describe('InboxFacebookForm', () => {
it('preloads the SDK on mount', () => {
mountForm();
expect(preloadSdk).toHaveBeenCalled();
});
it('lists only connectable pages and creates an inbox for the selected one', async () => {
loginAndFetchPages.mockResolvedValue({
userAccessToken: 'tok',
pages: PAGES,
});
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
// p2 is already connected (exists), so only p1 is offered.
const combobox = wrapper.findComponent(ComboBoxStub);
expect(combobox.props('options')).toEqual([
{ value: 'p1', label: 'Page One' },
]);
combobox.vm.$emit('update:modelValue', 'p1');
await nextTick();
await clickButton(wrapper, CONNECT);
await flushPromises();
expect(dispatch).toHaveBeenCalledWith('inboxes/createFBChannel', {
user_access_token: 'tok',
page_access_token: 'pt1',
page_id: 'p1',
inbox_name: 'Page One',
});
expect(wrapper.emitted('created')).toBeTruthy();
});
it('shows the empty state when every page is already connected', async () => {
loginAndFetchPages.mockResolvedValue({
userAccessToken: 'tok',
pages: [
{ id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
],
});
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
expect(wrapper.text()).toContain(
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES'
);
expect(wrapper.find('[data-test="combobox"]').exists()).toBe(false);
});
it('shows an error when the connection fails', async () => {
loginAndFetchPages.mockRejectedValue(new Error('boom'));
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
expect(wrapper.text()).toContain(
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
);
expect(dispatch).not.toHaveBeenCalled();
});
it('stays on the connect prompt without an error when cancelled', async () => {
loginAndFetchPages.mockResolvedValue(null);
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
expect(wrapper.text()).not.toContain(
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
);
// Launch button is still available to retry.
expect(
wrapper.findAll('button').some(button => button.text() === LAUNCH)
).toBe(true);
});
});
@@ -0,0 +1,62 @@
import {
findConnectedInbox,
isChannelConnected,
} from '../../inbox-setup/channelMatchers';
const WHATSAPP = { id: 1, channel_type: 'Channel::Whatsapp' };
const GMAIL = { id: 2, channel_type: 'Channel::Email', provider: 'google' };
const OUTLOOK = {
id: 3,
channel_type: 'Channel::Email',
provider: 'microsoft',
};
describe('channelMatchers', () => {
describe('findConnectedInbox', () => {
it('returns the inbox sharing the channel type', () => {
expect(
findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
).toBe(WHATSAPP);
});
it('matches email inboxes on provider', () => {
expect(
findConnectedInbox([OUTLOOK, GMAIL], {
channel_type: 'Channel::Email',
provider: 'google',
})
).toBe(GMAIL);
});
it('does not match a different email provider', () => {
expect(
findConnectedInbox([OUTLOOK], {
channel_type: 'Channel::Email',
provider: 'google',
})
).toBeUndefined();
});
it('returns undefined when nothing matches', () => {
expect(
findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Telegram' })
).toBeUndefined();
});
});
describe('isChannelConnected', () => {
it('is true when a matching inbox exists', () => {
expect(
isChannelConnected([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
).toBe(true);
});
it('is false when no inbox matches', () => {
expect(isChannelConnected([WHATSAPP], GMAIL)).toBe(false);
});
it('is false for a channel without an inbox stub', () => {
expect(isChannelConnected([WHATSAPP], undefined)).toBe(false);
});
});
});
@@ -0,0 +1,300 @@
import { defineComponent, h } from 'vue';
import { createStore } from 'vuex';
import { mount } from '@vue/test-utils';
import { useRoute } from 'vue-router';
import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
vi.mock('vue-router');
// Mounts the composable against a real store and the real useAccount (only
// useRoute and the underlying getters are faked), so a change to how useAccount
// resolves the current account is exercised here too. The real ./constants are
// used, so assertions validate against the actual channel identity (label keys,
// channel_type, social ordering) derived from CHANNEL_LIST.
const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
const store = createStore({
modules: {
accounts: {
namespaced: true,
getters: {
getAccount: () => () => ({
id: 1,
custom_attributes: { brand_info: brandInfo },
}),
},
},
inboxes: {
namespaced: true,
getters: { getInboxes: () => inboxes },
},
},
});
let result;
const Component = defineComponent({
setup() {
result = useDetectedChannels();
return () => h('div');
},
});
mount(Component, { global: { plugins: [store] } });
return result;
};
beforeEach(() => {
useRoute.mockReturnValue({ params: { accountId: '1' } });
// Configure the installation OAuth credentials so detected channels aren't
// hidden by the config gate; individual tests clear this to assert hiding.
window.chatwootConfig = {
fbAppId: 'fb',
instagramAppId: 'ig',
tiktokAppId: 'tt',
whatsappAppId: 'wa',
whatsappConfigurationId: 'wa-config',
};
});
afterEach(() => {
delete window.chatwootConfig;
});
describe('useDetectedChannels', () => {
describe('displayedChannels', () => {
it('maps detected socials with a url to channel rows', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'whatsapp', url: 'https://wa.me/1-415-555-2671' },
{ type: 'instagram', url: 'https://instagram.com/acme' },
],
},
});
expect(displayedChannels.value).toEqual([
{
type: 'whatsapp',
handle: '+14155552671',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
inbox: { channel_type: 'Channel::Whatsapp' },
},
{
type: 'instagram',
handle: '@acme',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
inbox: { channel_type: 'Channel::Instagram' },
},
]);
});
it('skips socials without a url or with an unknown type', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'telegram' }, // no url
{ type: 'mastodon', url: 'https://mastodon.social/@acme' }, // unknown
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
],
},
});
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'tiktok',
]);
});
it('uses the raw path for line and falls back to empty on a bad url', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'line', url: 'https://line.me/acme' },
{ type: 'facebook', url: 'not-a-url' },
],
},
});
expect(displayedChannels.value).toEqual([
{
type: 'line',
handle: 'acme',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
inbox: { channel_type: 'Channel::Line' },
},
{
type: 'facebook',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
inbox: { channel_type: 'Channel::FacebookPage' },
},
]);
});
it('omits the detected email channel while email is disabled for this phase', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
email_provider: 'google',
email: 'support@acme.com',
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
},
});
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'whatsapp',
]);
});
it('falls back to the default channel suggestions when nothing is detected', () => {
const { displayedChannels } = mountComposable({ brandInfo: undefined });
// The configured mainstream channels, with no detected handle.
expect(displayedChannels.value).toEqual([
{
type: 'whatsapp',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
inbox: { channel_type: 'Channel::Whatsapp' },
},
{
type: 'facebook',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
inbox: { channel_type: 'Channel::FacebookPage' },
},
{
type: 'instagram',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
inbox: { channel_type: 'Channel::Instagram' },
},
]);
});
it('gates the default suggestions by installation config, keeping the list non-empty', () => {
window.chatwootConfig = {}; // no OAuth credentials configured
const { displayedChannels } = mountComposable({ brandInfo: undefined });
// Only the credential-free defaults survive (Telegram, LINE).
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'telegram',
'line',
]);
});
it('hides detected channels whose installation OAuth credentials are missing', () => {
window.chatwootConfig = {}; // nothing configured
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'facebook', url: 'https://facebook.com/acme' },
{ type: 'line', url: 'https://line.me/acme' },
],
},
});
// Facebook needs fbAppId (absent → hidden); LINE needs no install credential.
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'line',
]);
});
});
describe('remainingChannels', () => {
it('returns the platforms not already shown as default rows', () => {
// Nothing detected → displayed falls back to the defaults (WhatsApp,
// Facebook, Instagram), so the footer previews the remaining platforms.
const { remainingChannels } = mountComposable({ brandInfo: {} });
expect(remainingChannels.value).toEqual([
{
type: 'line',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
inbox: { channel_type: 'Channel::Line' },
},
{
type: 'telegram',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
inbox: { channel_type: 'Channel::Telegram' },
},
{
type: 'tiktok',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
inbox: { channel_type: 'Channel::Tiktok' },
},
]);
});
it('excludes already-detected socials, preserving order', () => {
const { remainingChannels } = mountComposable({
brandInfo: {
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
},
});
expect(remainingChannels.value.map(channel => channel.type)).toEqual([
'facebook',
'line',
'instagram',
]);
});
it('excludes channels whose installation OAuth credentials are missing', () => {
window.chatwootConfig = {}; // nothing configured
const { remainingChannels } = mountComposable({ brandInfo: {} });
// The only configured channels (Telegram, LINE) are shown as default rows,
// and every other platform is gated out — so nothing remains for the footer.
expect(remainingChannels.value).toEqual([]);
});
});
describe('connectedInbox', () => {
it('returns the real inbox sharing the channel type', () => {
const inbox = {
id: 1,
channel_type: 'Channel::Whatsapp',
name: 'WA Biz',
};
const { connectedInbox } = mountComposable({
brandInfo: {},
inboxes: [inbox],
});
expect(
connectedInbox({ inbox: { channel_type: 'Channel::Whatsapp' } })
).toBe(inbox);
});
it('matches email inboxes on provider', () => {
const gmail = {
id: 1,
channel_type: 'Channel::Email',
provider: 'google',
};
const outlook = {
id: 2,
channel_type: 'Channel::Email',
provider: 'microsoft',
};
const { connectedInbox } = mountComposable({
brandInfo: {},
inboxes: [outlook, gmail],
});
expect(
connectedInbox({
inbox: { channel_type: 'Channel::Email', provider: 'google' },
})
).toBe(gmail);
});
it('returns undefined when nothing matches', () => {
const { connectedInbox } = mountComposable({
brandInfo: {},
inboxes: [],
});
expect(
connectedInbox({ inbox: { channel_type: 'Channel::Telegram' } })
).toBeUndefined();
});
});
});
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Editor from 'next/Editor/Editor.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DurationInput from 'next/input/DurationInput.vue';
@@ -162,9 +162,13 @@ const toggleAutoResolve = async () => {
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
>
<TextArea
<Editor
v-model="message"
class="w-full"
channel-type="Context::NoToolbar"
enable-variables
:enable-canned-responses="false"
:show-character-count="false"
:placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
"
@@ -10,6 +10,9 @@ export const LONGEST_WAITING = 'longest_waiting';
export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100;
export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600;
// Default age threshold for excluding stale unassigned conversations (7 days)
export const DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168;
// Options groupings
export const OPTIONS = {
ORDER: [ROUND_ROBIN, BALANCED],
@@ -106,6 +106,7 @@ const formData = computed(() => ({
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
excludeOlderThanHours: selectedPolicy.value?.excludeOlderThanHours ?? null,
}));
const handleDeleteInbox = async inboxId => {
@@ -8,6 +8,8 @@ import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import {
@@ -16,6 +18,7 @@ import {
EARLIEST_CREATED,
DEFAULT_FAIR_DISTRIBUTION_LIMIT,
DEFAULT_FAIR_DISTRIBUTION_WINDOW,
DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
const props = defineProps({
@@ -28,6 +31,7 @@ const props = defineProps({
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
}),
},
mode: {
@@ -56,7 +60,6 @@ const props = defineProps({
default: false,
},
});
const emit = defineEmits([
'submit',
'addInbox',
@@ -64,6 +67,9 @@ const emit = defineEmits([
'navigateToInbox',
'validationChange',
]);
// Duration limits for the stale-conversation threshold: 1 hour to 999 days (in minutes)
const MIN_EXCLUSION_MINUTES = 60;
const MAX_EXCLUSION_MINUTES = 1438560;
const { t } = useI18n();
const route = useRoute();
@@ -83,12 +89,28 @@ const state = reactive({
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
});
const validationState = ref({
isValid: false,
});
const exclusionUnit = ref(DURATION_UNITS.DAYS);
// DurationInput works in minutes; the policy stores hours, so bridge the two
const excludeOlderThanMinutes = computed({
get() {
return state.excludeOlderThanHours == null
? null
: state.excludeOlderThanHours * 60;
},
set(minutes) {
state.excludeOlderThanHours =
minutes == null ? null : Math.round(minutes / 60);
},
});
const createOption = (
type,
key,
@@ -170,6 +192,7 @@ const resetForm = () => {
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
});
};
@@ -177,10 +200,17 @@ const handleSubmit = () => {
emit('submit', { ...state });
};
// Pick the display unit from the stored value so non-day thresholds (e.g. 25h) don't get floored
const detectExclusionUnit = hours => {
exclusionUnit.value =
hours && hours % 24 !== 0 ? DURATION_UNITS.HOURS : DURATION_UNITS.DAYS;
};
watch(
() => props.initialData,
newData => {
Object.assign(state, newData);
detectExclusionUnit(newData.excludeOlderThanHours);
},
{ immediate: true, deep: true }
);
@@ -247,6 +277,27 @@ defineExpose({
v-model:window-unit="state.windowUnit"
/>
</div>
<div class="pt-4 pb-2 flex-col flex gap-4">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.DESCRIPTION`) }}
</p>
</div>
<div
class="flex items-center gap-2 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<DurationInput
v-model:unit="exclusionUnit"
v-model:model-value="excludeOlderThanMinutes"
:min="MIN_EXCLUSION_MINUTES"
:max="MAX_EXCLUSION_MINUTES"
/>
</div>
</div>
</div>
<Button
@@ -144,8 +144,10 @@ const showWebhookSection = computed(
() => props.healthData?.webhook_configuration !== undefined
);
// Phone-level override takes precedence over WABA-level (application), so prefer it.
const webhookUrl = computed(
() =>
props.healthData?.webhook_configuration?.phone_number ||
props.healthData?.webhook_configuration?.whatsapp_business_account ||
props.healthData?.webhook_configuration?.application
);
@@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const store = useStore();
const { t } = useI18n();
@@ -123,9 +124,16 @@ const confirmPlaceHolderText = computed(() =>
>
<div class="flex items-start gap-4">
<div
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1 text-lg"
>
<EmojiIcon
v-if="team.icon"
:value="team.icon"
:color="team.icon_color"
class="size-5"
/>
<Icon
v-else
icon="i-lucide-users-round"
class="size-4 text-n-slate-11"
/>
@@ -1,15 +1,27 @@
<script>
import validations from './helpers/validations';
import FormInput from 'v3/components/Form/Input.vue';
import { reactive } from 'vue';
import { reactive, ref, defineAsyncComponent } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { OnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
);
export default {
components: {
NextButton,
FormInput,
OnClickOutside,
EmojiIcon,
Icon,
EmojiIconPicker,
},
props: {
onSubmit: {
@@ -35,19 +47,38 @@ export default {
description = '',
name: title = '',
allow_auto_assign: allowAutoAssign = true,
icon = '',
icon_color: iconColor = '',
} = formData;
const state = reactive({
description,
title,
allowAutoAssign,
icon,
iconColor,
});
const isIconPickerOpen = ref(false);
const rules = validations;
const v$ = useVuelidate(rules, state);
return { state, v$ };
return { state, v$, isIconPickerOpen };
},
methods: {
onSelectIcon({ type, value, color }) {
this.state.icon = value;
this.state.iconColor = type === 'icon' ? color : '';
this.isIconPickerOpen = false;
},
onColorChange(color) {
this.state.iconColor = color;
},
onRemoveIcon() {
this.state.icon = '';
this.state.iconColor = '';
this.isIconPickerOpen = false;
},
handleSubmit() {
this.v$.$touch();
if (this.v$.$invalid) {
@@ -57,6 +88,8 @@ export default {
description: this.state.description,
name: this.state.title,
allow_auto_assign: this.state.allowAutoAssign,
icon: this.state.icon,
icon_color: this.state.iconColor,
});
},
},
@@ -66,16 +99,49 @@ export default {
<template>
<div class="flex-shrink-0 w-full">
<form class="mx-0 grid gap-4" @submit.prevent="handleSubmit">
<FormInput
v-model="state.title"
name="title"
spacing="compact"
:label="$t('TEAMS_SETTINGS.FORM.NAME.LABEL')"
:placeholder="$t('TEAMS_SETTINGS.FORM.NAME.PLACEHOLDER')"
:has-error="v$.title.$error"
:error-message="v$.title.$error ? v$.title.$errors[0].$message : ''"
@blur="v$.title.$touch"
/>
<div class="relative">
<FormInput
v-model="state.title"
class="!ps-12"
name="title"
spacing="compact"
:label="$t('TEAMS_SETTINGS.FORM.NAME.LABEL')"
:placeholder="$t('TEAMS_SETTINGS.FORM.NAME.PLACEHOLDER')"
:has-error="v$.title.$error"
:error-message="v$.title.$error ? v$.title.$errors[0].$message : ''"
@blur="v$.title.$touch"
/>
<OnClickOutside
class="absolute top-[1.75rem] start-0"
@trigger="isIconPickerOpen = false"
>
<NextButton
type="button"
variant="ghost"
color="slate"
class="text-lg !size-[2.5rem] !p-0 ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isIconPickerOpen = !isIconPickerOpen"
>
<EmojiIcon
v-if="state.icon"
:value="state.icon"
:color="state.iconColor"
class="size-5 text-xl !leading-5"
/>
<Icon v-else icon="i-lucide-smile-plus " class="size-4" />
</NextButton>
<EmojiIconPicker
v-if="isIconPickerOpen"
class="start-0 top-10"
:value="state.icon"
:color="state.iconColor"
show-remove-button
@select="onSelectIcon"
@color-change="onColorChange"
@remove="onRemoveIcon"
/>
</OnClickOutside>
</div>
<FormInput
v-model="state.description"
name="description"
+12 -3
View File
@@ -7,9 +7,12 @@ import { validateLoggedInRoutes } from '../helper/routeHelpers';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import AnalyticsHelper from '../helper/AnalyticsHelper';
const ONBOARDING_STEPS = ['account_details', 'enrichment'];
const ONBOARDING_STEPS = ['account_details', 'enrichment', 'inbox_setup'];
const routes = [...dashboard.routes];
const onboardingPath = step =>
step === 'inbox_setup' ? 'onboarding/inbox-setup' : 'onboarding';
export const router = createRouter({ history: createWebHistory(), routes });
export const validateAuthenticateRoutePermission = async (to, next) => {
@@ -39,12 +42,18 @@ export const validateAuthenticateRoutePermission = async (to, next) => {
isActive;
if (to.name === 'no_accounts' || !to.name) {
const target = needsOnboarding ? 'onboarding' : 'dashboard';
const target = needsOnboarding
? onboardingPath(userAccount?.onboarding_step)
: 'dashboard';
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
}
if (needsOnboarding && !isOnOnboardingView(to)) {
return next(frontendURL(`accounts/${routeAccountId}/onboarding`));
return next(
frontendURL(
`accounts/${routeAccountId}/${onboardingPath(userAccount?.onboarding_step)}`
)
);
}
if (!needsOnboarding && isOnOnboardingView(to)) {
return next(frontendURL(`accounts/${routeAccountId}/dashboard`));
@@ -208,20 +208,15 @@ const actions = {
}
},
assignAgent: async (
{ dispatch },
{ conversationId, agentId, assigneeType }
) => {
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
try {
const response = await ConversationApi.assignAgent({
conversationId,
agentId,
assigneeType,
});
dispatch('setCurrentChatAssignee', {
conversationId,
assignee: response.data,
assigneeType,
});
} catch (error) {
// Handle error
@@ -112,11 +112,8 @@ export const mutations = {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.meta.assignee = assignee;
chat.meta.assignee_type = assigneeType;
if (assigneeType === 'AgentBot') {
chat.status = 'pending';
} else if (assignee) {
chat.status = 'open';
if (assigneeType !== undefined) {
chat.meta.assignee_type = assigneeType;
}
}
},
@@ -1,9 +1,7 @@
import AssignableAgentsAPI from '../../api/assignableAgents';
import InboxesAPI from '../../api/inboxes';
const state = {
records: {},
ownerRecords: {},
uiFlags: {
isFetching: false,
},
@@ -12,7 +10,6 @@ const state = {
export const types = {
SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG',
SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS',
SET_INBOX_ASSIGNABLE_OWNERS: 'SET_INBOX_ASSIGNABLE_OWNERS',
};
export const getters = {
@@ -21,7 +18,6 @@ export const getters = {
const verifiedAgents = allAgents.filter(record => record.confirmed);
return verifiedAgents;
},
getAssignableOwners: $state => inboxId => $state.ownerRecords[inboxId] || [],
getUIFlags($state) {
return $state.uiFlags;
},
@@ -44,15 +40,6 @@ export const actions = {
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false });
}
},
async fetchAssignableOwners({ commit }, inboxId) {
const {
data: { payload },
} = await InboxesAPI.getAssignableOwners(inboxId);
commit(types.SET_INBOX_ASSIGNABLE_OWNERS, {
inboxId,
owners: payload,
});
},
};
export const mutations = {
@@ -68,12 +55,6 @@ export const mutations = {
[inboxId]: members,
};
},
[types.SET_INBOX_ASSIGNABLE_OWNERS]: ($state, { inboxId, owners }) => {
$state.ownerRecords = {
...$state.ownerRecords,
[inboxId]: owners,
};
},
};
export default {
@@ -357,12 +357,11 @@ describe('#actions', () => {
});
await actions.assignAgent(
{ dispatch },
{ conversationId: 1, agentId: 1, assigneeType: 'AgentBot' }
{ conversationId: 1, agentId: 1 }
);
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
conversationId: 1,
assignee: { id: 1, name: 'User' },
assigneeType: 'AgentBot',
});
});
});
@@ -372,7 +371,6 @@ describe('#actions', () => {
const payload = {
conversationId: 1,
assignee: { id: 1, name: 'User' },
assigneeType: 'AgentBot',
};
await actions.setCurrentChatAssignee({ commit }, payload);
expect(commit).toHaveBeenCalledTimes(1);
@@ -699,11 +699,11 @@ describe('#mutations', () => {
});
describe('#ASSIGN_AGENT', () => {
it('should assign agent bot to the correct conversation by ID', () => {
it('should assign agent to the correct conversation by ID', () => {
const assignee = { id: 1, name: 'Agent' };
const state = {
allConversations: [
{ id: 1, meta: {}, status: 'open' },
{ id: 1, meta: {} },
{ id: 2, meta: {} },
],
selectedChatId: 2,
@@ -712,18 +712,15 @@ describe('#mutations', () => {
mutations[types.ASSIGN_AGENT](state, {
conversationId: 1,
assignee,
assigneeType: 'AgentBot',
});
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
expect(state.allConversations[0].meta.assignee_type).toEqual('AgentBot');
expect(state.allConversations[0].status).toEqual('pending');
expect(state.allConversations[1].meta.assignee).toBeUndefined();
});
it('should open the conversation when assigning a user', () => {
it('should update assignee type when provided', () => {
const assignee = { id: 1, name: 'Agent' };
const state = {
allConversations: [{ id: 1, meta: {}, status: 'pending' }],
allConversations: [{ id: 1, meta: { assignee_type: 'AgentBot' } }],
};
mutations[types.ASSIGN_AGENT](state, {
@@ -732,7 +729,7 @@ describe('#mutations', () => {
assigneeType: 'User',
});
expect(state.allConversations[0].status).toEqual('open');
expect(state.allConversations[0].meta.assignee_type).toEqual('User');
});
});
@@ -1,18 +1,12 @@
import axios from 'axios';
import InboxesAPI from 'dashboard/api/inboxes';
import { actions, types } from '../../inboxAssignableAgents';
import agentsData from './fixtures';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
vi.mock('dashboard/api/inboxes');
describe('#actions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('#fetch', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
@@ -39,20 +33,4 @@ describe('#actions', () => {
]);
});
});
describe('#fetchAssignableOwners', () => {
it('stores assignable owners for the inbox', async () => {
InboxesAPI.getAssignableOwners.mockResolvedValue({
data: { payload: agentsData },
});
await actions.fetchAssignableOwners({ commit }, 1);
expect(InboxesAPI.getAssignableOwners).toHaveBeenCalledWith(1);
expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_OWNERS, {
inboxId: 1,
owners: agentsData,
});
});
});
});
@@ -13,16 +13,4 @@ describe('#mutations', () => {
expect(state.records).toEqual({ 1: agentsData });
});
});
describe('#SET_INBOX_ASSIGNABLE_OWNERS', () => {
it('adds inbox owners to ownerRecords', () => {
const state = { ownerRecords: {} };
mutations[types.SET_INBOX_ASSIGNABLE_OWNERS](state, {
owners: [...agentsData],
inboxId: 1,
});
expect(state.ownerRecords).toEqual({ 1: agentsData });
});
});
});
@@ -6,6 +6,7 @@ import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
const props = defineProps({
@@ -37,6 +38,10 @@ const props = defineProps({
type: String,
default: 'Search',
},
showEmojiIcon: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
@@ -96,8 +101,18 @@ const hasIcon = computed(() => {
hide-offline-status
rounded-full
/>
<div
v-if="hasValue && hasIcon && showEmojiIcon"
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
>
<EmojiIcon
:value="selectedItem.icon"
:color="selectedItem.icon_color"
class="size-3.5 !text-sm"
/>
</div>
<Icon
v-if="hasValue && hasIcon"
v-else-if="hasValue && hasIcon"
:icon="selectedItem.icon"
class="size-5 text-n-slate-11"
/>
@@ -124,6 +139,7 @@ const hasIcon = computed(() => {
:has-thumbnail="hasThumbnail"
:input-placeholder="inputPlaceholder"
:no-search-result="noSearchResult"
:show-emoji-icon="showEmojiIcon"
@select="onClickSelectItem"
/>
</div>
@@ -3,6 +3,7 @@ import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
@@ -11,6 +12,7 @@ export default {
WootDropdownMenu,
Avatar,
Icon,
EmojiIcon,
NextButton,
},
@@ -35,6 +37,10 @@ export default {
type: String,
default: 'No results found',
},
showEmojiIcon: {
type: Boolean,
default: false,
},
},
emits: ['select'],
@@ -67,13 +73,7 @@ export default {
this.$refs.searchbar.focus();
},
isActive(option) {
return this.selectedItems.some(item => {
if (!item || option.id !== item.id) return false;
return (
(option.assignee_type || 'User') === (item.assignee_type || 'User')
);
});
return this.selectedItems.some(item => item && option.id === item.id);
},
},
};
@@ -94,10 +94,7 @@ export default {
<div class="flex items-start justify-start flex-auto overflow-auto mt-2">
<div class="w-full max-h-[10rem]">
<WootDropdownMenu>
<WootDropdownItem
v-for="option in filteredOptions"
:key="`${option.assignee_type || 'User'}-${option.id}`"
>
<WootDropdownItem v-for="option in filteredOptions" :key="option.id">
<NextButton
slate
:variant="isActive(option) ? 'faded' : 'ghost'"
@@ -125,8 +122,18 @@ export default {
hide-offline-status
rounded-full
/>
<div
v-if="option.icon && showEmojiIcon"
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
>
<EmojiIcon
:value="option.icon"
:color="option.icon_color"
class="size-3.5 !text-sm"
/>
</div>
<Icon
v-if="option.icon"
v-else-if="option.icon"
:icon="option.icon"
class="size-5 text-n-slate-11"
/>
@@ -1,5 +1,11 @@
const DYTE_MEETING_LINK = 'https://app.dyte.io/v2/meeting';
const DYTE_MEETING_LINK = 'https://examples.realtime.cloudflare.com/meeting/';
export const buildDyteURL = dyteAuthToken => {
return `${DYTE_MEETING_LINK}?authToken=${dyteAuthToken}&showSetupScreen=true&disableVideoBackground=true`;
const params = new URLSearchParams({
authToken: dyteAuthToken,
showSetupScreen: true,
disableVideoBackground: true,
});
return `${DYTE_MEETING_LINK}?${params.toString()}`;
};
+1 -1
View File
@@ -54,7 +54,7 @@ class Account < ApplicationRecord
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :reporting_timezone
store_accessor :settings, :keep_pending_on_bot_failure
store_accessor :settings, :captain_auto_resolve_mode
store_accessor :settings, :captain_auto_resolve_mode, :captain_false_promise_harness_enabled
include AccountCaptainAutoResolve
has_many :account_users, dependent: :destroy_async
+2
View File
@@ -7,6 +7,7 @@
# conversation_priority :integer default("earliest_created"), not null
# description :text
# enabled :boolean default(TRUE), not null
# exclude_older_than_hours :integer default(168)
# fair_distribution_limit :integer default(100), not null
# fair_distribution_window :integer default(3600), not null
# name :string(255) not null
@@ -28,6 +29,7 @@ class AssignmentPolicy < ApplicationRecord
validates :name, presence: true, uniqueness: { scope: :account_id }
validates :fair_distribution_limit, numericality: { greater_than: 0 }
validates :fair_distribution_window, numericality: { greater_than: 0 }
validates :exclude_older_than_hours, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
+6 -16
View File
@@ -1,6 +1,9 @@
module AccountSettingsSchema
extend ActiveSupport::Concern
CAPTAIN_MODEL_PROPERTIES = Llm::Models.feature_keys.index_with { { 'type': %w[string null] } }.freeze
CAPTAIN_FEATURE_PROPERTIES = Llm::Models.feature_keys.index_with { { 'type': %w[boolean null] } }.freeze
SETTINGS_PARAMS_SCHEMA = {
'type': 'object',
'properties':
@@ -12,32 +15,19 @@ module AccountSettingsSchema
'auto_resolve_label': { 'type': %w[string null] },
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
'captain_auto_resolve_mode': { 'type': %w[string null], 'enum': ['evaluated', 'legacy', 'disabled', nil] },
'captain_false_promise_harness_enabled': { 'type': %w[boolean null] },
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
},
'captain_models': {
'type': %w[object null],
'properties': {
'editor': { 'type': %w[string null] },
'assistant': { 'type': %w[string null] },
'copilot': { 'type': %w[string null] },
'label_suggestion': { 'type': %w[string null] },
'audio_transcription': { 'type': %w[string null] },
'help_center_search': { 'type': %w[string null] }
},
'properties': CAPTAIN_MODEL_PROPERTIES,
'additionalProperties': false
},
'captain_features': {
'type': %w[object null],
'properties': {
'editor': { 'type': %w[boolean null] },
'assistant': { 'type': %w[boolean null] },
'copilot': { 'type': %w[boolean null] },
'label_suggestion': { 'type': %w[boolean null] },
'audio_transcription': { 'type': %w[boolean null] },
'help_center_search': { 'type': %w[boolean null] }
},
'properties': CAPTAIN_FEATURE_PROPERTIES,
'additionalProperties': false
}
},
+20 -9
View File
@@ -4,6 +4,7 @@ module CaptainFeaturable
extend ActiveSupport::Concern
included do
before_validation :normalize_captain_models
validate :validate_captain_models
# Dynamically define accessor methods for each captain feature
@@ -30,14 +31,8 @@ module CaptainFeaturable
private
def captain_models_with_defaults
stored_models = captain_models || {}
Llm::Models.feature_keys.each_with_object({}) do |feature_key, result|
stored_value = stored_models[feature_key]
result[feature_key] = if stored_value.present? && Llm::Models.valid_model_for?(feature_key, stored_value)
stored_value
else
Llm::Models.default_model_for(feature_key)
end
Llm::Models.feature_keys.index_with do |feature_key|
Llm::FeatureRouter.resolve(feature: feature_key, account: self)[:model]
end
end
@@ -52,11 +47,27 @@ module CaptainFeaturable
return if captain_models.blank?
captain_models.each do |feature_key, model_name|
next if model_name.blank?
unless Llm::Models.feature?(feature_key)
errors.add(:captain_models, "'#{feature_key}' is not a known feature")
next
end
next if Llm::Models.valid_model_for?(feature_key, model_name)
allowed_models = Llm::Models.models_for(feature_key)
errors.add(:captain_models, "'#{model_name}' is not a valid model for #{feature_key}. Allowed: #{allowed_models.join(', ')}")
end
end
def normalize_captain_models
return unless captain_models.is_a?(Hash)
normalized_models = captain_models.each_with_object({}) do |(feature_key, model_name), result|
next if model_name.blank?
result[feature_key.to_s] = model_name.to_s
end
self.captain_models = normalized_models.presence
end
end
+46 -1
View File
@@ -30,6 +30,7 @@ class Integrations::Hook < ApplicationRecord
validate :validate_settings_json_schema
validate :ensure_feature_enabled
validate :validate_openai_api_key, if: :validate_openai_api_key?
validate :validate_cloudflare_realtimekit_credentials, if: :validate_cloudflare_realtimekit_credentials?
validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } }
# TODO: This seems to be only used for slack at the moment
@@ -61,6 +62,10 @@ class Integrations::Hook < ApplicationRecord
app_id == 'openai'
end
def dyte?
app_id == 'dyte'
end
def notion?
app_id == 'notion'
end
@@ -96,6 +101,7 @@ class Integrations::Hook < ApplicationRecord
def validate_settings_json_schema
return if app.blank? || app.params[:settings_json_schema].blank?
return if legacy_dyte_settings_unchanged?
errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings)
end
@@ -106,18 +112,57 @@ class Integrations::Hook < ApplicationRecord
openai? && enabled? && (new_record? || openai_api_key_changed? || will_save_change_to_status?)
end
def validate_cloudflare_realtimekit_credentials?
dyte? && enabled? && !legacy_dyte_settings_unchanged? &&
(new_record? || cloudflare_realtimekit_credentials_changed? || will_save_change_to_status?)
end
def openai_api_key_changed?
settings_api_key(settings) != settings_api_key(settings_in_database)
end
def cloudflare_realtimekit_credentials_changed?
settings_cloudflare_realtimekit_credentials(settings) != settings_cloudflare_realtimekit_credentials(settings_in_database)
end
def legacy_dyte_settings_unchanged?
dyte? && persisted? && !will_save_change_to_settings? && legacy_dyte_settings?(settings_in_database)
end
def legacy_dyte_settings?(value)
return false if value.blank?
%w[organization_id api_key].any? { |key| settings_value(value, key).present? } &&
%w[account_id app_id api_token].none? { |key| settings_value(value, key).present? }
end
def validate_openai_api_key
return if Integrations::Openai::KeyValidator.valid?(settings_api_key(settings))
errors.add(:base, I18n.t('errors.openai.invalid_api_key'))
end
def validate_cloudflare_realtimekit_credentials
result = Integrations::Cloudflare::RealtimeKitCredentialsValidator.validate(*settings_cloudflare_realtimekit_credentials(settings))
return if result.success?
errors.add(:base, I18n.t("errors.cloudflare.realtimekit.#{result.error}"))
end
def settings_api_key(value)
value&.dig('api_key') || value&.dig(:api_key)
settings_value(value, 'api_key')
end
def settings_cloudflare_realtimekit_credentials(value)
[
settings_value(value, 'account_id'),
settings_value(value, 'app_id'),
settings_value(value, 'api_token')
]
end
def settings_value(value, key)
value&.dig(key) || value&.dig(key.to_sym)
end
def trigger_setup_if_crm
+5 -1
View File
@@ -5,6 +5,8 @@
# id :bigint not null, primary key
# allow_auto_assign :boolean default(TRUE)
# description :text
# icon :string default("")
# icon_color :string default("")
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
@@ -62,7 +64,9 @@ class Team < ApplicationRecord
def push_event_data
{
id: id,
name: name
name: name,
icon: icon,
icon_color: icon_color
}
end
end
-4
View File
@@ -30,10 +30,6 @@ class InboxPolicy < ApplicationPolicy
true
end
def assignable_owners?
true
end
def agent_bot?
true
end
@@ -35,8 +35,11 @@ class AutoAssignment::AssignmentService
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
# Apply conversation priority using assignment policy if available
# Skip stale backlog with no activity beyond the policy's age threshold (defaults to 7 days)
policy = inbox.assignment_policy
scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
# Apply conversation priority using assignment policy if available
scope = if policy&.longest_waiting?
scope.reorder(last_activity_at: :asc, created_at: :asc)
else
@@ -46,6 +49,16 @@ class AutoAssignment::AssignmentService
scope.limit(limit)
end
def apply_age_exclusions(scope, hours_threshold)
return scope if hours_threshold.blank?
hours = hours_threshold.to_i
return scope unless hours.positive?
# Use last_activity_at so reopened/active conversations aren't excluded by their original created_at
scope.where('conversations.last_activity_at >= ?', hours.hours.ago)
end
def find_available_agent(conversation = nil)
agents = filter_agents_by_team(inbox.available_agents, conversation)
return nil if agents.nil?
@@ -16,7 +16,6 @@ class Conversations::AssignmentService
def assign_agent
conversation.assignee = assignee
conversation.assignee_agent_bot = nil
conversation.status = :open if assignee
conversation.save!
assignee
end
@@ -26,7 +25,6 @@ class Conversations::AssignmentService
conversation.assignee = nil
conversation.assignee_agent_bot = agent_bot
conversation.status = :pending
conversation.save!
agent_bot
end
@@ -31,6 +31,14 @@ class UserSessionTrackingService
private
def session_attributes
client_headers = mobile_client_headers
if client_headers
return client_headers.merge(
ip_address: @request.remote_ip,
user_agent: @request.user_agent
)
end
browser = Browser.new(@request.user_agent)
attrs = {
@@ -46,6 +54,30 @@ class UserSessionTrackingService
patch_for_legacy_mobile(attrs)
end
def mobile_client_headers
name = @request.headers['X-Chatwoot-Client-Name']
return nil if name.blank?
platform = @request.headers['X-Chatwoot-Platform']
model = @request.headers['X-Chatwoot-Device-Model']
{
browser_name: name,
browser_version: @request.headers['X-Chatwoot-Client-Version'],
device_name: device_name_for_icon(platform, model),
platform_name: model,
platform_version: @request.headers['X-Chatwoot-Platform-Version']
}
end
def device_name_for_icon(platform, model)
normalized_platform = platform.to_s.downcase
return 'iPad' if normalized_platform == 'ios' && model.to_s.include?('iPad')
return 'iPhone' if normalized_platform == 'ios'
'Android'
end
def patch_for_legacy_mobile(attrs)
return attrs unless attrs[:browser_name] == 'Unknown Browser'
+35 -19
View File
@@ -1,5 +1,7 @@
class Whatsapp::FacebookApiClient
BASE_URI = 'https://graph.facebook.com'.freeze
# Base webhook fields resent on every subscribe so Meta won't reset to defaults. `calls` is added by callers only when voice is enabled.
WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes].freeze
def initialize(access_token = nil)
@access_token = access_token
@@ -60,48 +62,62 @@ class Whatsapp::FacebookApiClient
data['code_verification_status'] == 'VERIFIED'
end
WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes].freeze
def subscribe_phone_number_webhook(waba_id, phone_number_id, callback_url, verify_token, subscribed_fields: nil)
# Subscribe app to WABA first — Meta requires it before any callback override (issue #13097).
# subscribed_fields (incl. `calls` when voice is enabled) is declared here; the phone-level POST has no such field.
subscribe_app_to_waba(waba_id, subscribed_fields: subscribed_fields || WEBHOOK_DEFAULT_FIELDS)
def subscribe_waba_webhook(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
# Step 1: Subscribe app to WABA first (required before override)
# Meta requires the app to be subscribed before using override_callback_uri
# See: https://github.com/chatwoot/chatwoot/issues/13097
subscribe_app_to_waba(waba_id)
# Step 2: Override callback URL for this specific WABA
override_waba_callback(waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
# Phone-level override takes precedence over WABA-level, so numbers on one WABA can route to different URLs.
override_phone_number_callback(phone_number_id, callback_url, verify_token)
end
def subscribe_app_to_waba(waba_id)
def subscribe_app_to_waba(waba_id, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers
headers: request_headers,
body: { subscribed_fields: subscribed_fields }.to_json
)
handle_response(response, 'App subscription to WABA failed')
end
def override_waba_callback(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
def override_phone_number_callback(phone_number_id, callback_url, verify_token)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
"#{BASE_URI}/#{@api_version}/#{phone_number_id}",
headers: request_headers,
body: {
override_callback_uri: callback_url,
verify_token: verify_token,
subscribed_fields: subscribed_fields
webhook_configuration: {
override_callback_uri: callback_url,
verify_token: verify_token
}
}.to_json
)
handle_response(response, 'Webhook callback override failed')
handle_response(response, 'Phone number webhook callback override failed')
end
def unsubscribe_waba_webhook(waba_id)
def clear_phone_number_callback_override(phone_number_id)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{phone_number_id}",
headers: request_headers,
body: {
webhook_configuration: {
override_callback_uri: ''
}
}.to_json
)
handle_response(response, 'Phone number webhook callback clear failed')
end
# Fully removes this app's WABA subscription (last inbox deleted) so Meta stops delivering webhooks.
def unsubscribe_app_from_waba(waba_id)
response = HTTParty.delete(
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers
)
handle_response(response, 'Webhook unsubscription failed')
handle_response(response, 'WABA app unsubscription failed')
end
private
@@ -27,9 +27,12 @@ class Whatsapp::ReauthorizationService
def update_channel_config(channel, access_token, phone_info)
current_config = channel.provider_config || {}
# Legacy clients may omit phone_number_id; fall back to the value just fetched from Meta.
resolved_phone_number_id = @phone_number_id.presence || phone_info[:phone_number_id]
channel.provider_config = current_config.merge(
'api_key' => access_token,
'phone_number_id' => @phone_number_id,
'phone_number_id' => resolved_phone_number_id,
'business_account_id' => @business_id,
'source' => 'embedded_signup'
)
+18 -2
View File
@@ -28,6 +28,7 @@ class Whatsapp::WebhookSetupService
raise ArgumentError, 'Channel is required' if @channel.blank?
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
raise ArgumentError, 'Access token is required' if @access_token.blank?
raise ArgumentError, 'Phone number ID is required' if @channel.provider_config['phone_number_id'].blank?
end
def register_phone_number
@@ -58,8 +59,9 @@ class Whatsapp::WebhookSetupService
def setup_webhook
callback_url = build_callback_url
verify_token = @channel.provider_config['webhook_verify_token']
phone_number_id = @channel.provider_config['phone_number_id']
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
@api_client.subscribe_phone_number_webhook(@waba_id, phone_number_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
raise "Webhook setup failed: #{e.message}"
@@ -68,10 +70,24 @@ class Whatsapp::WebhookSetupService
# Subscribe to `calls` only when voice calling is enabled on the inbox
def subscribed_fields
fields = %w[messages smb_message_echoes]
fields << 'calls' if @channel.provider_config['calling_enabled']
fields << 'calls' if calls_enabled_on_waba?
fields
end
# `subscribed_fields` is a WABA-wide app subscription, so keep `calls` whenever this inbox or
# any sibling on the same WABA has voice on — otherwise a non-calling sibling's setup would
# rewrite the shared subscription and drop calls for a calling-enabled sibling.
def calls_enabled_on_waba?
return true if @channel.provider_config['calling_enabled']
Channel::Whatsapp
.where(provider: 'whatsapp_cloud')
.where.not(id: @channel.id)
.where("provider_config->>'business_account_id' = ?", @waba_id)
.where("provider_config->>'calling_enabled' = 'true'")
.exists?
end
def build_callback_url
frontend_url = ENV.fetch('FRONTEND_URL', nil)
phone_number = @channel.phone_number
@@ -6,42 +6,53 @@ class Whatsapp::WebhookTeardownService
def perform
return unless should_teardown_webhook?
teardown_webhook
api_client = Whatsapp::FacebookApiClient.new(provider_config['api_key'])
clear_phone_number_override(api_client)
unsubscribe_app_if_last_inbox(api_client)
rescue StandardError => e
handle_webhook_teardown_error(e)
# before_destroy must never block a channel delete — log and move on.
Rails.logger.error "[WHATSAPP] Webhook teardown failed for channel #{@channel&.id}: #{e.message}"
end
private
def provider_config
@channel.provider_config || {}
end
def should_teardown_webhook?
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present?
@channel.provider == 'whatsapp_cloud' &&
provider_config['source'] == 'embedded_signup' &&
provider_config['api_key'].present? &&
(provider_config['phone_number_id'].present? || provider_config['business_account_id'].present?)
end
def whatsapp_cloud_provider?
@channel.provider == 'whatsapp_cloud'
def clear_phone_number_override(api_client)
phone_number_id = provider_config['phone_number_id']
return if phone_number_id.blank?
api_client.clear_phone_number_callback_override(phone_number_id)
Rails.logger.info "[WHATSAPP] Phone-level webhook override cleared for channel #{@channel.id}"
rescue StandardError => e
Rails.logger.error "[WHATSAPP] Phone-level webhook clear failed for channel #{@channel.id}: #{e.message}"
end
def embedded_signup_source?
@channel.provider_config['source'] == 'embedded_signup'
# The app subscription is shared by every inbox on the WABA, so only unsubscribe when this is the last one.
def unsubscribe_app_if_last_inbox(api_client)
waba_id = provider_config['business_account_id']
return if waba_id.blank?
return if waba_sibling_exists?(waba_id)
api_client.unsubscribe_app_from_waba(waba_id)
Rails.logger.info "[WHATSAPP] WABA app subscription removed for channel #{@channel.id}"
rescue StandardError => e
Rails.logger.error "[WHATSAPP] WABA app unsubscribe failed for channel #{@channel.id}: #{e.message}"
end
def webhook_config_present?
@channel.provider_config['business_account_id'].present? &&
@channel.provider_config['api_key'].present?
end
def teardown_webhook
waba_id = @channel.provider_config['business_account_id']
access_token = @channel.provider_config['api_key']
api_client = Whatsapp::FacebookApiClient.new(access_token)
api_client.unsubscribe_waba_webhook(waba_id)
Rails.logger.info "[WHATSAPP] Webhook unsubscribed successfully for channel #{@channel.id}"
end
def handle_webhook_teardown_error(error)
Rails.logger.error "[WHATSAPP] Webhook teardown failed: #{error.message}"
# Don't raise the error to prevent channel deletion from failing
# Failed webhook teardown shouldn't block deletion
def waba_sibling_exists?(waba_id)
Channel::Whatsapp
.where.not(id: @channel.id)
.exists?(["provider_config ->> 'business_account_id' = ?", waba_id])
end
end
@@ -5,6 +5,7 @@ json.assignment_order assignment_policy.assignment_order
json.conversation_priority assignment_policy.conversation_priority
json.fair_distribution_limit assignment_policy.fair_distribution_limit
json.fair_distribution_window assignment_policy.fair_distribution_window
json.exclude_older_than_hours assignment_policy.exclude_older_than_hours
json.enabled assignment_policy.enabled
json.assigned_inbox_count assignment_policy.inboxes.count
json.created_at assignment_policy.created_at.to_i
@@ -1,15 +0,0 @@
owners = @assignable_agents.map { |agent| { type: 'User', resource: agent } }
owners += @agent_bots.map { |agent_bot| { type: 'AgentBot', resource: agent_bot } }
json.payload do
json.array! owners do |owner|
if owner[:type] == 'User'
json.partial! 'api/v1/models/agent', formats: [:json], resource: owner[:resource]
json.assignee_type 'User'
else
json.partial! 'api/v1/models/agent_bot_slim', formats: [:json], resource: owner[:resource]
json.assignee_type 'AgentBot'
json.icon 'i-lucide-bot'
end
end
end

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