Compare commits

...
Author SHA1 Message Date
Sivin VargheseandGitHub 721585647b Merge branch 'develop' into feat/asset-service-workers 2025-09-01 18:41:11 +05:30
Sivin VargheseandGitHub d68ac25187 feat: Display notification count in sidebar inbox item (#12324) 2025-09-01 15:55:09 +05:30
Sivin VargheseandGitHub c53e750be0 feat: Display banner and handoff for bot-managed chats (#12292) 2025-09-01 13:22:55 +05:30
Sivin VargheseandGitHub f9c258f1a0 fix: Prevent [object Object] when copying custom attributes (#12323)
# Pull Request Template

## Description

This PR fixes custom conversation attributes copying as `[object
Object]` by enhancing the clipboard helper to properly serialize objects
and handle different data types.


Fixes
[CW-5428](https://linear.app/chatwoot/issue/CW-5428/copying-custom-conversation-attribute-returns-object-object-instead-of),
https://github.com/chatwoot/chatwoot/issues/12202

## Type of change

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

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/f52db17d4d524b3cbb5badb2b6f381eb?sid=2b34f38f-e95d-4981-be5f-6cb42a0212b9


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-09-01 13:02:35 +05:30
e863a52262 chore: Update account deletion email copy (#12317)
Update the email copies for account deletion emails 

## Manual Deletion 

<img width="1378" height="678" alt="Screenshot 2025-08-29 at 2 41 40 PM"
src="https://github.com/user-attachments/assets/63d7ad97-ad51-4a8b-9ef3-d427fa467f8a"
/>

## Inactive Deletion

<img width="2946" height="1808" alt="image"
src="https://github.com/user-attachments/assets/bb50d08c-8701-4f93-af29-0b1d948a4009"
/>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-08-31 16:01:41 +02:00
Sojan JoseandGitHub 0a9edd4c3b ci(circleci): switch coverage reporting to Qlty orb (#12337) 2025-08-31 00:39:34 +05:30
PranavandGitHub f4643116df feat: Run assignment every 15 minutes (#12334)
Currently, auto-assignment runs only during conversation creation or
update events. If no agents are online when new conversations arrive,
those conversations remain unassigned.

With this change, unassigned conversations will be automatically
assigned once agents become available. The job runs every 15 minutes and
uses a fair distribution threshold of 100 to prevent a large number of
conversations from being assigned to a single available agent. This will
be customizable later.
2025-08-29 15:10:56 -07:00
9145658597 chore: Refactor UTM params to stay compliant with standards (#12312)
We were using UTM params on various branding urls which weren't
compliant to standard utm params and hence were ignored by analytics
tooling. this PR ensures that the params stays compliant with defined
standard

ref: https://en.wikipedia.org/wiki/UTM_parameters

## Changes 

- updated utm tags on widget and survey urls
- added utm on helpcenter branding

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-08-29 11:46:52 -07:00
99997a701a feat: Add twilio content templates (#12277)
Implements comprehensive Twilio WhatsApp content template support (Phase
1) enabling text, media, and quick reply templates with proper parameter
conversion, sync capabilities, and feature flag protection.

###  Features Implemented

  **Template Types Supported**

  - Basic Text Templates: Simple text with variables ({{1}}, {{2}})
  - Media Templates: Image/Video/Document templates with text variables
  - Quick Reply Templates: Interactive button templates
- Phase 2 (Future): List Picker, Call-to-Action, Catalog, Carousel,
Authentication templates

  **Template Synchronization**

- API Endpoint: POST
/api/v1/accounts/{account_id}/inboxes/{inbox_id}/sync_templates
  - Background Job: Channels::Twilio::TemplatesSyncJob
  - Storage: JSONB format in channel_twilio_sms.content_templates
  - Auto-categorization: UTILITY, MARKETING, AUTHENTICATION categories

 ###  Template Examples Tested


  #### Text template
```
  { "name": "greet", "language": "en" }
```
  #### Template with variables
```
  { "name": "order_status", "parameters": [{"type": "body", "parameters": [{"text": "John"}]}] }
```

  #### Media template with image
```
  { "name": "product_showcase", "parameters": [
    {"type": "header", "parameters": [{"image": {"link": "image.jpg"}}]},
    {"type": "body", "parameters": [{"text": "iPhone"}, {"text": "$999"}]}
  ]}
```
#### Preview

<img width="1362" height="1058" alt="CleanShot 2025-08-26 at 10 01
51@2x"
src="https://github.com/user-attachments/assets/cb280cea-08c3-44ca-8025-58a96cb3a451"
/>

<img width="1308" height="1246" alt="CleanShot 2025-08-26 at 10 02
02@2x"
src="https://github.com/user-attachments/assets/9ea8537a-61e9-40f5-844f-eaad337e1ddd"
/>

#### User guide

https://www.chatwoot.com/hc/user-guide/articles/1756195741-twilio-content-templates

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-08-29 16:13:25 +05:30
88cb5ba56f fix: Widget now shows articles based on correct locale (#12316)
# Pull Request Template

## Description

This PR fixes an issue (CW-5529) where articles displayed on the widget
were not respecting the current locale set in the URL (e.g., `/ar` was
showing English articles).

The root cause was that the article queries in the
`_featured_articles.html.erb` and `_uncategorized-block.html.erb` view
templates were missing locale filtering.

The fix involves adding `locale: @locale` to the `articles.where`
clauses in these templates to ensure that only articles matching the
current portal locale are displayed.

Fixes #CW-5529

## Type of change

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

## How Has This Been Tested?

The changes were verified against existing test cases that confirm the
expected behavior for locale-specific article filtering. Specifically,
tests for the articles controller's `index` action and article count by
locale were reviewed, which align with the implemented fixes.

## 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 (N/A, relied on existing tests)
- [ ] New and existing unit tests pass locally with my changes (Unable
to run tests in this environment)
- [ ] Any dependent changes have been merged and published in downstream
modules

---
Linear Issue:
[CW-5529](https://linear.app/chatwoot/issue/CW-5529/articles-from-the-correct-locale-is-not-shown-on-the-widget)

<a
href="https://cursor.com/background-agent?bcId=bc-2f944ea8-863e-4e80-b137-c05ce0b017cc">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/open-in-cursor-dark.svg">
<source media="(prefers-color-scheme: light)"
srcset="https://cursor.com/open-in-cursor-light.svg">
<img alt="Open in Cursor" src="https://cursor.com/open-in-cursor.svg">
  </picture>
</a>
<a
href="https://cursor.com/agents?id=bc-2f944ea8-863e-4e80-b137-c05ce0b017cc">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/open-in-web-dark.svg">
<source media="(prefers-color-scheme: light)"
srcset="https://cursor.com/open-in-web-light.svg">
    <img alt="Open in Web" src="https://cursor.com/open-in-web.svg">
  </picture>
</a>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-08-28 18:32:27 +05:30
PranavandGitHub 0c2ab7f5e7 feat(ee): Setup advanced, performant message search (#12193)
We now support searching within the actual message content, email
subject lines, and audio transcriptions. This enables a faster, more
accurate search experience going forward. Unlike the standard message
search, which is limited to the last 3 months, this search has no time
restrictions.

The search engine also accounts for small variations in queries. Minor
spelling mistakes, such as searching for slck instead of Slack, will
still return the correct results. It also ignores differences in accents
and diacritics, so searching for Deja vu will match content containing
Déjà vu.


We can also refine searches in the future by criteria such as:
- Searching within a specific inbox
- Filtering by sender or recipient
- Limiting to messages sent by an agent


Fixes https://github.com/chatwoot/chatwoot/issues/11656
Fixes https://github.com/chatwoot/chatwoot/issues/10669
Fixes https://github.com/chatwoot/chatwoot/issues/5910



---

Rake tasks to reindex all the messages. 

```sh
bundle exec rake search:all
```

Rake task to reindex messages from one account only
```sh
bundle exec rake search:account ACCOUNT_ID=1
```
2025-08-28 10:10:28 +05:30
Muhsin KelothandGitHub 583a533494 fix: Remove whatsapp prefix from twilio phone number for QR code (#12314) 2025-08-27 21:27:47 +05:30
Tanmay Deep SharmaandGitHub 1ba00075ce feat: Add BE changes for captain pdf support for faq generation (#12113) 2025-08-27 20:31:22 +05:30
Sojan JoseandGitHub 3cefa9b767 feat: add ops task to purge orphan conversations (#12279)
## Summary

Add ops task to find and remove conversations missing contacts or
inboxes. You can run this via

```
bundle exec rake chatwoot:ops:cleanup_orphan_conversations
```
 
 
fixes:
https://linear.app/chatwoot/issue/CW-5439/conversations-missing-contacts
2025-08-27 14:42:11 +02:00
Sivin VargheseandGitHub b44d178acc chore: Replace copilot input with auto-expanding textarea (#12296) 2025-08-27 15:12:48 +05:30
Sivin VargheseandGitHub 4a4965918e Merge branch 'develop' into feat/asset-service-workers 2025-08-26 12:12:04 +05:30
Muhsin KelothandGitHub e7ced1f938 Merge branch 'develop' into feat/asset-service-workers 2025-08-14 14:45:19 +05:30
Shivam MishraandGitHub 1894998803 Merge branch 'develop' into feat/asset-service-workers 2025-08-11 12:41:56 +05:30
Shivam MishraandGitHub 8e7048ae6c Merge branch 'develop' into feat/asset-service-workers 2025-07-31 15:15:48 +05:30
Shivam MishraandGitHub fc32e0afd3 Merge branch 'develop' into feat/asset-service-workers 2025-07-30 17:58:19 +04:00
Shivam Mishra 1530cee411 chore: clean sw.js 2025-07-23 14:44:05 +04:00
Shivam Mishra 8247254512 chore: revert vite config 2025-07-23 14:43:27 +04:00
Shivam Mishra cc15cfd496 chore: revert pnpm change 2025-07-23 14:42:19 +04:00
Shivam Mishra bef60d0657 feat: add caching of assets using service workers 2025-07-23 14:39:07 +04:00
Shivam Mishra 3c0ea35dcb feat: remove vite pwa 2025-07-23 14:38:04 +04:00
Shivam Mishra f9f8d7cdf7 feat: update sw 2025-07-23 12:42:31 +04:00
Shivam Mishra 552d1e9980 feat: kickstart pwa 2025-07-23 12:23:40 +04:00
87 changed files with 2983 additions and 337 deletions
+14 -22
View File
@@ -1,6 +1,7 @@
version: 2.1
orbs:
node: circleci/node@6.1.0
qlty-orb: qltysh/qlty-orb@0.0
defaults: &defaults
working_directory: ~/build
@@ -89,14 +90,6 @@ jobs:
command: |
source ~/.rvm/scripts/rvm
bundle install
# pnpm install
- run:
name: Download cc-test-reporter
command: |
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter
# Swagger verification
- run:
@@ -108,10 +101,11 @@ jobs:
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
mkdir -p ~/tmp
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# we remove the FRONTED_URL from the .env before running the tests
# Configure environment and database
- run:
name: Database Setup and Configure Environment Variables
command: |
@@ -149,17 +143,11 @@ jobs:
command: pnpm run eslint
- run:
name: Run frontend tests
name: Run frontend tests (with coverage)
command: |
mkdir -p ~/build/coverage/frontend
~/tmp/cc-test-reporter before-build
pnpm run test:coverage
- run:
name: Code Climate Test Coverage (Frontend)
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# Run backend tests
- run:
name: Run backend tests
@@ -167,18 +155,18 @@ jobs:
mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend
~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec --format progress \
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
--format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \
-- ${TESTFILES}
no_output_timeout: 30m
- run:
name: Code Climate Test Coverage (Backend)
command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
# Qlty coverage publish
- qlty-orb/coverage_publish:
files: |
coverage/coverage.json
coverage/lcov.info
- run:
name: List coverage directory contents
@@ -189,3 +177,7 @@ jobs:
root: ~/build
paths:
- coverage
- store_artifacts:
path: coverage
destination: coverage
+6 -1
View File
@@ -62,6 +62,10 @@ gem 'redis-namespace'
# super fast record imports in bulk
gem 'activerecord-import'
gem 'searchkick'
gem 'opensearch-ruby'
gem 'faraday_middleware-aws-sigv4'
##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman'
@@ -223,6 +227,7 @@ group :test do
gem 'webmock'
# test profiling
gem 'test-prof'
gem 'simplecov_json_formatter', require: false
end
group :development, :test do
@@ -247,7 +252,7 @@ group :development, :test do
gem 'rubocop-factory_bot', require: false
gem 'seed_dump'
gem 'shoulda-matchers'
gem 'simplecov', '0.17.1', require: false
gem 'simplecov', '>= 0.21', require: false
gem 'spring'
gem 'spring-watcher-listen'
end
+21 -7
View File
@@ -219,7 +219,7 @@ GEM
diff-lcs (1.5.1)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.0)
docile (1.4.1)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (3.1.2)
@@ -299,6 +299,9 @@ GEM
net-http-persistent (~> 4.0)
faraday-retry (2.2.1)
faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
faraday (>= 2.0, < 3)
fast-mcp (1.5.0)
addressable (~> 2.8)
base64
@@ -444,7 +447,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.12.0)
json (2.13.2)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -601,6 +604,9 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
opensearch-ruby (3.4.0)
faraday (>= 1.0, < 3)
multi_json (>= 1.0)
openssl (3.2.0)
orm_adapter (0.5.0)
os (1.1.4)
@@ -802,6 +808,9 @@ GEM
parser
scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5)
searchkick (5.5.2)
activemodel (>= 7.1)
hashie
securerandom (0.4.1)
seed_dump (3.3.1)
activerecord (>= 4)
@@ -848,11 +857,12 @@ GEM
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simplecov (0.17.1)
simplecov (0.22.0)
docile (~> 1.1)
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.2)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
slack-ruby-client (2.5.2)
faraday (>= 2.0)
faraday-mashify
@@ -996,6 +1006,7 @@ DEPENDENCIES
facebook-messenger
factory_bot_rails (>= 6.4.3)
faker
faraday_middleware-aws-sigv4
fcm
flag_shih_tzu
foreman
@@ -1036,6 +1047,7 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
opensearch-ruby
pg
pg_search
pgvector
@@ -1064,6 +1076,7 @@ DEPENDENCIES
ruby_llm-schema
scout_apm
scss_lint
searchkick
seed_dump
sentry-rails (>= 5.19.0)
sentry-ruby
@@ -1073,7 +1086,8 @@ DEPENDENCIES
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
sidekiq_alive
simplecov (= 0.17.1)
simplecov (>= 0.21)
simplecov_json_formatter
slack-ruby-client (~> 2.5.2)
spring
spring-watcher-listen
+4
View File
@@ -4,3 +4,7 @@
require_relative 'config/application'
Rails.application.load_tasks
# Load Enterprise Edition rake tasks if they exist
enterprise_tasks_path = Rails.root.join('enterprise/lib/tasks.rb').to_s
require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
+11
View File
@@ -74,6 +74,17 @@ module PortalHelper
end
end
def generate_portal_brand_url(brand_url, referer)
url = URI.parse(brand_url.to_s)
query_params = Rack::Utils.parse_query(url.query)
query_params['utm_medium'] = 'helpcenter'
query_params['utm_campaign'] = 'branding'
query_params['utm_source'] = URI.parse(referer).host if referer.present? && referer.match?(URI::DEFAULT_PARSER.make_regexp)
url.query = query_params.to_query
url.to_s
end
def render_category_content(content)
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
@@ -11,12 +11,14 @@ import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
import ContentTemplateSelector from './ContentTemplateSelector.vue';
const props = defineProps({
attachedFiles: { type: Array, default: () => [] },
isWhatsappInbox: { type: Boolean, default: false },
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false },
isTwilioWhatsAppInbox: { type: Boolean, default: false },
messageTemplates: { type: Array, default: () => [] },
channelType: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
@@ -32,6 +34,7 @@ const emit = defineEmits([
'discard',
'sendMessage',
'sendWhatsappMessage',
'sendTwilioMessage',
'insertEmoji',
'addSignature',
'removeSignature',
@@ -63,6 +66,20 @@ const sendWithSignature = computed(() => {
return fetchSignatureFlagFromUISettings(props.channelType);
});
const showTwilioContentTemplates = computed(() => {
return props.isTwilioWhatsAppInbox && props.inboxId;
});
const shouldShowEmojiButton = computed(() => {
return (
!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox && !props.hasNoInbox
);
});
const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const setSignature = () => {
if (signatureToApply.value) {
if (sendWithSignature.value) {
@@ -125,7 +142,7 @@ const keyboardEvents = {
action: () => {
if (
isEditorHotKeyEnabled('enter') &&
!props.isWhatsappInbox &&
isRegularMessageMode.value &&
!props.isDropdownActive
) {
emit('sendMessage');
@@ -136,7 +153,7 @@ const keyboardEvents = {
action: () => {
if (
isEditorHotKeyEnabled('cmd_enter') &&
!props.isWhatsappInbox &&
isRegularMessageMode.value &&
!props.isDropdownActive
) {
emit('sendMessage');
@@ -158,8 +175,13 @@ useKeyboardEvents(keyboardEvents);
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
<ContentTemplateSelector
v-if="showTwilioContentTemplates"
:inbox-id="inboxId"
@send-message="emit('sendTwilioMessage', $event)"
/>
<div
v-if="!isWhatsappInbox && !hasNoInbox"
v-if="shouldShowEmojiButton"
v-on-click-outside="() => (isEmojiPickerOpen = false)"
class="relative"
>
@@ -172,7 +194,7 @@ useKeyboardEvents(keyboardEvents);
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="ltr:left-0 rtl:right-0 top-full mt-1.5"
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
:on-click="onClickInsertEmoji"
/>
</div>
@@ -199,7 +221,7 @@ useKeyboardEvents(keyboardEvents);
/>
</FileUpload>
<Button
v-if="hasSelectedInbox && !isWhatsappInbox"
v-if="hasSelectedInbox && isRegularMessageMode"
icon="i-lucide-signature"
color="slate"
size="sm"
@@ -218,7 +240,7 @@ useKeyboardEvents(keyboardEvents);
@click="emit('discard')"
/>
<Button
v-if="!isWhatsappInbox"
v-if="isRegularMessageMode"
:label="sendButtonLabel"
size="sm"
class="!text-xs font-medium"
@@ -74,6 +74,9 @@ const inboxTypes = computed(() => ({
isTwilioSMS:
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
props.targetInbox?.medium === 'sms',
isTwilioWhatsapp:
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
props.targetInbox?.medium === 'whatsapp',
}));
const whatsappMessageTemplates = computed(() =>
@@ -261,6 +264,28 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
isFromWhatsApp: true,
});
};
const handleSendTwilioMessage = async ({ message, templateParams }) => {
const twilioMessagePayload = prepareWhatsAppMessagePayload({
targetInbox: props.targetInbox,
selectedContact: props.selectedContact,
message,
templateParams,
currentUser: props.currentUser,
});
await emit('createConversation', {
payload: twilioMessagePayload,
isFromWhatsApp: true,
});
};
const shouldShowMessageEditor = computed(() => {
return (
!inboxTypes.value.isWhatsapp &&
!showNoInboxAlert.value &&
!inboxTypes.value.isTwilioWhatsapp
);
});
</script>
<template>
@@ -311,7 +336,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
/>
<MessageEditor
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
v-if="shouldShowMessageEditor"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@@ -331,6 +356,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:is-loading="isCreating"
@@ -347,6 +373,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
@discard="$emit('discard')"
@send-message="handleSendMessage"
@send-whatsapp-message="handleSendWhatsappMessage"
@send-twilio-message="handleSendTwilioMessage"
/>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup>
import ContentTemplateParser from 'dashboard/components-next/content-templates/ContentTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
defineProps({
template: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const handleBack = () => {
emit('back');
};
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="w-full">
<ContentTemplateParser
:template="template"
@send-message="handleSendMessage"
@back="handleBack"
>
<template #actions="{ sendMessage, goBack, disabled }">
<div class="flex gap-3 justify-between items-end w-full h-14">
<Button
:label="t('CONTENT_TEMPLATES.FORM.BACK_BUTTON')"
color="slate"
variant="faded"
class="w-full font-medium"
@click="goBack"
/>
<Button
:label="t('CONTENT_TEMPLATES.FORM.SEND_MESSAGE_BUTTON')"
class="w-full font-medium"
:disabled="disabled"
@click="sendMessage"
/>
</div>
</template>
</ContentTemplateParser>
</div>
</div>
</template>
@@ -0,0 +1,124 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ContentTemplateForm from './ContentTemplateForm.vue';
const props = defineProps({
inboxId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['sendMessage']);
const { t } = useI18n();
const inbox = useMapGetter('inboxes/getInbox');
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const contentTemplates = computed(() => {
const inboxData = inbox.value(props.inboxId);
return inboxData?.content_templates?.templates || [];
});
const filteredTemplates = computed(() => {
return contentTemplates.value.filter(
template =>
template.friendly_name
.toLowerCase()
.includes(searchQuery.value.toLowerCase()) &&
template.status === 'approved'
);
});
const handleTriggerClick = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
emit('sendMessage', template);
selectedTemplate.value = null;
};
</script>
<template>
<div class="relative">
<Button
icon="i-ph-whatsapp-logo"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
color="slate"
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="w-full">
<Input
v-model="searchQuery"
type="search"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
"
custom-input-class="ltr:pl-10 rtl:pr-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
</div>
<div
v-for="template in filteredTemplates"
:key="template.content_sid"
tabindex="0"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<div class="flex justify-between items-center">
<span class="text-sm text-n-slate-12">{{
template.friendly_name
}}</span>
</div>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<ContentTemplateForm
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
</template>
@@ -25,7 +25,7 @@ export const generateLabelForContactableInboxesList = ({
channelType === INBOX_TYPES.TWILIO ||
channelType === INBOX_TYPES.WHATSAPP
) {
return `${name} (${phoneNumber})`;
return phoneNumber ? `${name} (${phoneNumber})` : name;
}
return name;
};
@@ -53,6 +53,7 @@ const transformInbox = ({
email,
phoneNumber,
channelType,
medium,
...rest,
});
@@ -0,0 +1,278 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
template: {
type: Object,
default: () => ({}),
validator: value => {
if (!value || typeof value !== 'object') return false;
if (!value.friendly_name) return false;
return true;
},
},
});
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
const VARIABLE_PATTERN = /{{([^}]+)}}/g;
const { t } = useI18n();
const processedParams = ref({});
const languageLabel = computed(() => {
return `${t('CONTENT_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || 'en'}`;
});
const categoryLabel = computed(() => {
return `${t('CONTENT_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || 'utility'}`;
});
const templateBody = computed(() => {
return props.template.body || '';
});
const hasMediaTemplate = computed(() => {
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
});
const hasVariables = computed(() => {
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
});
const mediaVariableKey = computed(() => {
if (!hasMediaTemplate.value) return null;
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
if (!mediaUrl) return null;
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
});
const hasMediaVariable = computed(() => {
return hasMediaTemplate.value && mediaVariableKey.value !== null;
});
const templateMediaUrl = computed(() => {
if (!hasMediaTemplate.value) return '';
return props.template?.types?.['twilio/media']?.media?.[0] || '';
});
const variablePattern = computed(() => {
if (!hasVariables.value) return [];
const matches = templateBody.value.match(VARIABLE_PATTERN) || [];
return matches.map(match => match.replace(/[{}]/g, ''));
});
const renderedTemplate = computed(() => {
let rendered = templateBody.value;
if (processedParams.value && Object.keys(processedParams.value).length > 0) {
// Replace variables in the format {{1}}, {{2}}, etc.
rendered = rendered.replace(VARIABLE_PATTERN, (match, variable) => {
const cleanVariable = variable.trim();
return processedParams.value[cleanVariable] || match;
});
}
return rendered;
});
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaVariable.value) return false;
if (hasVariables.value) {
const hasEmptyVariable = variablePattern.value.some(
variable => !processedParams.value[variable]
);
if (hasEmptyVariable) return true;
}
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
!processedParams.value[mediaVariableKey.value]
) {
return true;
}
return false;
});
const v$ = useVuelidate(
{
processedParams: {
requiredIfKeysPresent: requiredIf(
() => hasVariables.value || hasMediaVariable.value
),
},
},
{ processedParams }
);
const initializeTemplateParameters = () => {
processedParams.value = {};
if (hasVariables.value) {
variablePattern.value.forEach(variable => {
processedParams.value[variable] = '';
});
}
if (hasMediaVariable.value && mediaVariableKey.value) {
processedParams.value[mediaVariableKey.value] = '';
}
};
const sendMessage = () => {
v$.value.$touch();
if (v$.value.$invalid || isFormInvalid.value) return;
const { friendly_name, language } = props.template;
// Process parameters and extract filename from media URL if needed
const processedParameters = { ...processedParams.value };
// For media templates, extract filename from full URL
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
processedParameters[mediaVariableKey.value]
) {
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
processedParameters[mediaVariableKey.value]
);
}
const payload = {
message: renderedTemplate.value,
templateParams: {
name: friendly_name,
language,
processed_params: processedParameters,
},
};
emit('sendMessage', payload);
};
const resetTemplate = () => {
emit('resetTemplate');
};
const goBack = () => {
emit('back');
};
onMounted(initializeTemplateParameters);
watch(
() => props.template,
() => {
initializeTemplateParameters();
v$.value.$reset();
},
{ deep: true }
);
defineExpose({
processedParams,
hasVariables,
hasMediaTemplate,
renderedTemplate,
v$,
sendMessage,
resetTemplate,
goBack,
});
</script>
<template>
<div>
<div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ template.friendly_name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ languageLabel }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ renderedTemplate }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ categoryLabel }}
</div>
</div>
<div v-if="hasVariables || hasMediaVariable">
<!-- Media URL for media templates -->
<div v-if="hasMediaVariable" class="mb-4">
<p class="mb-2.5 text-sm font-semibold">
{{ $t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_LABEL') }}
</p>
<div class="flex items-center mb-2.5">
<Input
v-model="processedParams[mediaVariableKey]"
type="url"
class="flex-1"
:placeholder="
templateMediaUrl ||
t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_PLACEHOLDER')
"
/>
</div>
</div>
<!-- Body Variables Section -->
<div v-if="hasVariables">
<p class="mb-2.5 text-sm font-semibold">
{{ $t('CONTENT_TEMPLATES.PARSER.VARIABLES_LABEL') }}
</p>
<div
v-for="variable in variablePattern"
:key="`variable-${variable}`"
class="flex items-center mb-2.5"
>
<Input
v-model="processedParams[variable]"
type="text"
class="flex-1"
:placeholder="
t('CONTENT_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
variable: variable,
})
"
/>
</div>
</div>
<p
v-if="v$.$dirty && (v$.$invalid || isFormInvalid)"
class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
>
{{ $t('CONTENT_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
</p>
</div>
<slot
name="actions"
:send-message="sendMessage"
:reset-template="resetTemplate"
:go-back="goBack"
:is-valid="!v$.$invalid && !isFormInvalid"
:disabled="isFormInvalid"
/>
</div>
</template>
@@ -1,25 +1,49 @@
<script setup>
import { ref } from 'vue';
import { ref, nextTick, onMounted } from 'vue';
const emit = defineEmits(['send']);
const message = ref('');
const textareaRef = ref(null);
const adjustHeight = () => {
if (!textareaRef.value) return;
// Reset height to auto to get the correct scrollHeight
textareaRef.value.style.height = 'auto';
// Set the height to the scrollHeight
textareaRef.value.style.height = `${textareaRef.value.scrollHeight}px`;
};
const sendMessage = () => {
if (message.value.trim()) {
emit('send', message.value);
message.value = '';
// Reset textarea height after sending
nextTick(() => {
adjustHeight();
});
}
};
const handleInput = () => {
nextTick(adjustHeight);
};
onMounted(() => {
nextTick(adjustHeight);
});
</script>
<template>
<form class="relative" @submit.prevent="sendMessage">
<input
<textarea
ref="textareaRef"
v-model="message"
type="text"
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-n-slate-11 text-sm border border-n-weak rounded-lg focus:outline-none focus:ring-1 focus:ring-n-blue-11 focus:border-n-blue-11"
@keyup.enter="sendMessage"
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
rows="1"
@input="handleInput"
@keydown.enter.exact.prevent="sendMessage"
/>
<button
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
@@ -128,7 +128,7 @@ const menuItems = computed(() => {
to: accountScopedRoute('inbox_view'),
activeOn: ['inbox_view', 'inbox_view_conversation'],
getterKeys: {
badge: 'notifications/getHasUnreadNotifications',
count: 'notifications/getUnreadCount',
},
},
{
@@ -1,4 +1,5 @@
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import Icon from 'next/icon/Icon.vue';
@@ -16,12 +17,16 @@ const props = defineProps({
const emit = defineEmits(['toggle']);
const showBadge = useMapGetter(props.getterKeys.badge);
const dynamicCount = useMapGetter(props.getterKeys.count);
const count = computed(() =>
dynamicCount.value > 99 ? '99+' : dynamicCount.value
);
</script>
<template>
<component
:is="to ? 'router-link' : 'div'"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 min-w-0"
role="button"
draggable="false"
:to="to"
@@ -40,9 +45,21 @@ const showBadge = useMapGetter(props.getterKeys.badge);
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
/>
</div>
<span class="text-sm font-medium leading-5 flex-grow">
{{ label }}
</span>
<div class="flex items-center gap-1.5 flex-grow min-w-0">
<span class="text-sm font-medium leading-5 truncate">
{{ label }}
</span>
<span
v-if="dynamicCount && !expandable"
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
:class="{
'text-n-blue-text outline-n-slate-6': isActive,
'text-n-slate-11 outline-n-strong': !isActive,
}"
>
{{ count }}
</span>
</div>
<span
v-if="expandable"
v-show="isExpanded"
@@ -1,5 +1,6 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import { useMapGetter } from 'dashboard/composables/store';
@@ -100,20 +101,24 @@ const handleReset = () => {
};
const sendMessage = async message => {
if (selectedCopilotThreadId.value) {
await store.dispatch('copilotMessages/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
threadId: selectedCopilotThreadId.value,
message,
});
} else {
const response = await store.dispatch('copilotThreads/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
message,
});
selectedCopilotThreadId.value = response.id;
try {
if (selectedCopilotThreadId.value) {
await store.dispatch('copilotMessages/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
threadId: selectedCopilotThreadId.value,
message,
});
} else {
const response = await store.dispatch('copilotThreads/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
message,
});
selectedCopilotThreadId.value = response.id;
}
} catch (error) {
useAlert(error.message);
}
};
@@ -95,6 +95,10 @@ export default {
type: Boolean,
default: false,
},
enableContentTemplates: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
required: true,
@@ -121,6 +125,7 @@ export default {
'toggleInsertArticle',
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
],
setup() {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
@@ -347,6 +352,15 @@ export default {
sm
@click="$emit('selectWhatsappTemplate')"
/>
<NextButton
v-if="enableContentTemplates"
v-tooltip.top-end="'Content Templates'"
icon="i-ph-whatsapp-logo"
slate
faded
sm
@click="$emit('selectContentTemplate')"
/>
<VideoCallButton
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
:conversation-id="conversationId"
@@ -361,7 +375,7 @@ export default {
<transition name="modal-fade">
<div
v-show="uploadRef && uploadRef.dropActive"
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
class="flex fixed top-0 right-0 bottom-0 left-0 z-20 flex-col gap-2 justify-center items-center w-full h-full text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
>
<fluent-icon icon="cloud-backup" size="40" />
<h4 class="text-2xl break-words text-n-slate-12">
@@ -0,0 +1,97 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import TemplatesPicker from './ContentTemplatesPicker.vue';
import TemplateParser from '../../../../components-next/content-templates/ContentTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
inboxId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['onSend', 'cancel', 'update:show']);
const { t } = useI18n();
const selectedContentTemplate = ref(null);
const localShow = computed({
get() {
return props.show;
},
set(value) {
emit('update:show', value);
},
});
const modalHeaderContent = computed(() => {
return selectedContentTemplate.value
? t('CONTENT_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
templateName: selectedContentTemplate.value.friendly_name,
})
: t('CONTENT_TEMPLATES.MODAL.SUBTITLE');
});
const pickTemplate = template => {
selectedContentTemplate.value = template;
};
const onResetTemplate = () => {
selectedContentTemplate.value = null;
};
const onSendMessage = message => {
emit('onSend', message);
};
const onClose = () => {
emit('cancel');
};
</script>
<template>
<woot-modal v-model:show="localShow" :on-close="onClose" size="modal-big">
<woot-modal-header
:header-title="$t('CONTENT_TEMPLATES.MODAL.TITLE')"
:header-content="modalHeaderContent"
/>
<div class="px-8 py-6 row">
<TemplatesPicker
v-if="!selectedContentTemplate"
:inbox-id="inboxId"
@on-select="pickTemplate"
/>
<TemplateParser
v-else
:template="selectedContentTemplate"
@reset-template="onResetTemplate"
@send-message="onSendMessage"
>
<template #actions="{ sendMessage, resetTemplate, disabled }">
<div class="flex gap-2 mt-6">
<Button
:label="t('CONTENT_TEMPLATES.PARSER.GO_BACK_LABEL')"
color="slate"
variant="faded"
class="flex-1"
@click="resetTemplate"
/>
<Button
:label="t('CONTENT_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
class="flex-1"
:disabled="disabled"
@click="sendMessage"
/>
</div>
</template>
</TemplateParser>
</div>
</woot-modal>
</template>
@@ -0,0 +1,169 @@
<script setup>
import { ref, computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
const props = defineProps({
inboxId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['onSelect']);
const { t } = useI18n();
const store = useStore();
const query = ref('');
const isRefreshing = ref(false);
const twilioTemplates = computed(() => {
const inbox = store.getters['inboxes/getInbox'](props.inboxId);
return inbox?.content_templates?.templates || [];
});
const filteredTemplateMessages = computed(() =>
twilioTemplates.value.filter(
template =>
template.friendly_name
.toLowerCase()
.includes(query.value.toLowerCase()) && template.status === 'approved'
)
);
const getTemplateType = template => {
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.MEDIA');
}
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
}
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
};
const refreshTemplates = async () => {
isRefreshing.value = true;
try {
await store.dispatch('inboxes/syncTemplates', props.inboxId);
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_SUCCESS'));
} catch (error) {
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_ERROR'));
} finally {
isRefreshing.value = false;
}
};
</script>
<template>
<div class="w-full">
<div class="flex gap-2 mb-2.5">
<div
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
>
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
<input
v-model="query"
type="search"
:placeholder="t('CONTENT_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
/>
</div>
<button
:disabled="isRefreshing"
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
:title="t('CONTENT_TEMPLATES.PICKER.REFRESH_BUTTON')"
@click="refreshTemplates"
>
<Icon
icon="i-lucide-refresh-ccw"
class="text-n-slate-12 size-4"
:class="{ 'animate-spin': isRefreshing }"
/>
</button>
</div>
<div
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
>
<div
v-for="(template, i) in filteredTemplateMessages"
:key="template.content_sid"
>
<button
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
@click="emit('onSelect', template)"
>
<div>
<div class="flex justify-between items-center mb-2.5">
<p class="text-sm">
{{ template.friendly_name }}
</p>
<div class="flex gap-2">
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{ getTemplateType(template) }}
</span>
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{
`${t('CONTENT_TEMPLATES.PICKER.LABELS.LANGUAGE')}: ${template.language}`
}}
</span>
</div>
</div>
<!-- Body -->
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.BODY') }}
</p>
<p class="text-sm label-body">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
</div>
<div class="flex justify-between items-center mt-3">
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.LABELS.CATEGORY') }}
</p>
<p class="text-sm">{{ template.category || 'utility' }}</p>
</div>
<div class="text-xs text-n-slate-11">
{{ new Date(template.created_at).toLocaleDateString() }}
</div>
</div>
</div>
</button>
<hr
v-if="i != filteredTemplateMessages.length - 1"
:key="`hr-${i}`"
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
/>
</div>
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
<div v-if="query && twilioTemplates.length">
<p>
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
<strong>{{ query }}</strong>
</p>
</div>
<div v-else-if="!twilioTemplates.length" class="space-y-4">
<p class="text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.label-body {
font-family: monospace;
}
</style>
@@ -15,7 +15,7 @@ import ReplyEmailHead from './ReplyEmailHead.vue';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import ReplyBoxBanner from './ReplyBoxBanner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
@@ -27,6 +27,7 @@ import {
replaceVariablesInMessage,
} from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
@@ -52,8 +53,8 @@ export default {
ArticleSearchPopover,
AttachmentPreview,
AudioRecorder,
Banner,
CannedResponse,
ReplyBoxBanner,
EmojiInput,
MessageSignatureMissingAlert,
ReplyBottomPanel,
@@ -61,6 +62,7 @@ export default {
ReplyToMessage,
ReplyTopPanel,
ResizableTextArea,
ContentTemplates,
WhatsappTemplates,
WootMessageEditor,
},
@@ -109,6 +111,7 @@ export default {
toEmails: '',
doAutoSaveDraft: () => {},
showWhatsAppTemplatesModal: false,
showContentTemplatesModal: false,
updateEditorSelectionWith: '',
undefinedVariableMessage: '',
showMentions: false,
@@ -155,38 +158,12 @@ export default {
return false;
},
assignedAgent: {
get() {
return this.currentChat.meta.assignee;
},
set(agent) {
const agentId = agent ? agent.id : 0;
this.$store.dispatch('setCurrentChatAssignee', agent);
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
});
},
},
showSelfAssignBanner() {
if (this.message !== '' && !this.isOnPrivateNote) {
if (!this.assignedAgent) {
return true;
}
if (this.assignedAgent.id !== this.currentUser.id) {
return true;
}
}
return false;
},
showWhatsappTemplates() {
return this.isAWhatsAppCloudChannel && !this.isPrivate;
},
showContentTemplates() {
return this.isATwilioWhatsAppChannel && !this.isPrivate;
},
isPrivate() {
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
return this.isOnPrivateNote;
@@ -659,28 +636,11 @@ export default {
hideWhatsappTemplatesModal() {
this.showWhatsAppTemplatesModal = false;
},
onClickSelfAssign() {
const {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
avatar_url,
} = this.currentUser;
const selfAssign = {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
thumbnail: avatar_url,
};
this.assignedAgent = selfAssign;
openContentTemplateModal() {
this.showContentTemplatesModal = true;
},
hideContentTemplatesModal() {
this.showContentTemplatesModal = false;
},
confirmOnSendReply() {
if (this.isReplyButtonDisabled) {
@@ -774,6 +734,13 @@ export default {
});
this.hideWhatsappTemplatesModal();
},
async onSendContentTemplateReply(messagePayload) {
this.sendMessage({
conversationId: this.currentChat.id,
...messagePayload,
});
this.hideContentTemplatesModal();
},
replaceText(message) {
if (this.sendWithSignature && !this.private) {
// if signature is enabled, append it to the message
@@ -1099,16 +1066,7 @@ export default {
</script>
<template>
<Banner
v-if="showSelfAssignBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg banner--self-assign"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@primary-action="onClickSelfAssign"
/>
<ReplyBoxBanner :message="message" :is-on-private-note="isOnPrivateNote" />
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
<ReplyTopPanel
:mode="replyType"
@@ -1217,6 +1175,7 @@ export default {
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:enable-whats-app-templates="showWhatsappTemplates"
:enable-content-templates="showContentTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
@@ -1239,6 +1198,7 @@ export default {
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@@ -1251,6 +1211,14 @@ export default {
@cancel="hideWhatsappTemplatesModal"
/>
<ContentTemplates
:inbox-id="inbox.id"
:show="showContentTemplatesModal"
@close="hideContentTemplatesModal"
@on-send="onSendContentTemplateReply"
@cancel="hideContentTemplatesModal"
/>
<woot-confirm-modal
ref="confirmDialog"
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
@@ -1264,10 +1232,6 @@ export default {
@apply mb-0;
}
.banner--self-assign {
@apply py-2;
}
.attachment-preview-box {
@apply bg-transparent py-0 px-4;
}
@@ -0,0 +1,129 @@
<script setup>
import { computed } from 'vue';
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';
const props = defineProps({
message: {
type: String,
default: '',
},
isOnPrivateNote: {
type: Boolean,
default: false,
},
});
const store = useStore();
const { t } = useI18n();
const currentChat = useMapGetter('getSelectedChat');
const currentUser = useMapGetter('getCurrentUser');
const assignedAgent = computed({
get() {
return currentChat.value?.meta?.assignee;
},
set(agent) {
const agentId = agent ? agent.id : 0;
store.dispatch('setCurrentChatAssignee', agent);
store.dispatch('assignAgent', {
conversationId: currentChat.value?.id,
agentId,
});
},
});
const isUserTyping = computed(
() => props.message !== '' && !props.isOnPrivateNote
);
const isUnassigned = computed(() => !assignedAgent.value);
const isAssignedToOtherAgent = computed(
() => assignedAgent.value?.id !== currentUser.value?.id
);
const showSelfAssignBanner = computed(() => {
return (
isUserTyping.value && (isUnassigned.value || isAssignedToOtherAgent.value)
);
});
const showBotHandoffBanner = computed(
() =>
isUserTyping.value &&
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
);
const botHandoffActionLabel = computed(() => {
return assignedAgent.value?.id === currentUser.value?.id
? t('CONVERSATION.BOT_HANDOFF_REOPEN_ACTION')
: t('CONVERSATION.BOT_HANDOFF_ACTION');
});
const selfAssignConversation = async () => {
const { avatar_url, ...rest } = currentUser.value || {};
assignedAgent.value = { ...rest, thumbnail: avatar_url };
};
const needsAssignmentToCurrentUser = computed(() => {
return isUnassigned.value || isAssignedToOtherAgent.value;
});
const onClickSelfAssign = async () => {
try {
await selfAssignConversation();
useAlert(t('CONVERSATION.CHANGE_AGENT'));
} catch (error) {
useAlert(t('CONVERSATION.CHANGE_AGENT_FAILED'));
}
};
const reopenConversation = async () => {
await store.dispatch('toggleStatus', {
conversationId: currentChat.value?.id,
status: wootConstants.STATUS_TYPE.OPEN,
});
};
const onClickBotHandoff = async () => {
try {
await reopenConversation();
if (needsAssignmentToCurrentUser.value) {
await selfAssignConversation();
}
useAlert(t('CONVERSATION.BOT_HANDOFF_SUCCESS'));
} catch (error) {
useAlert(t('CONVERSATION.BOT_HANDOFF_ERROR'));
}
};
</script>
<template>
<Banner
v-if="showSelfAssignBanner && !showBotHandoffBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg !py-2"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@primary-action="onClickSelfAssign"
/>
<Banner
v-if="showBotHandoffBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg !py-2"
:banner-message="$t('CONVERSATION.BOT_HANDOFF_MESSAGE')"
has-action-button
:action-button-label="botHandoffActionLabel"
@primary-action="onClickBotHandoff"
/>
</template>
@@ -125,3 +125,23 @@ export const getHostNameFromURL = url => {
return null;
}
};
/**
* Extracts filename from a URL
* @param {string} url - The URL to extract filename from
* @returns {string} - The extracted filename or original URL if extraction fails
*/
export const extractFilenameFromUrl = url => {
if (!url || typeof url !== 'string') return url;
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split('/').pop();
return filename || url;
} catch (error) {
// If URL parsing fails, try to extract filename using regex
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
return match ? match[1] : url;
}
};
@@ -7,6 +7,7 @@ import {
hasValidAvatarUrl,
timeStampAppendedURL,
getHostNameFromURL,
extractFilenameFromUrl,
} from '../URLHelper';
describe('#URL Helpers', () => {
@@ -263,4 +264,58 @@ describe('#URL Helpers', () => {
expect(getHostNameFromURL('https://chatwoot.help')).toBe('chatwoot.help');
});
});
describe('extractFilenameFromUrl', () => {
it('should extract filename from a valid URL', () => {
expect(
extractFilenameFromUrl('https://example.com/path/to/file.jpg')
).toBe('file.jpg');
expect(extractFilenameFromUrl('https://example.com/image.png')).toBe(
'image.png'
);
expect(
extractFilenameFromUrl(
'https://example.com/folder/document.pdf?query=1'
)
).toBe('document.pdf');
expect(
extractFilenameFromUrl('https://example.com/file.txt#section')
).toBe('file.txt');
});
it('should handle URLs without filename', () => {
expect(extractFilenameFromUrl('https://example.com/')).toBe(
'https://example.com/'
);
expect(extractFilenameFromUrl('https://example.com')).toBe(
'https://example.com'
);
});
it('should handle invalid URLs gracefully', () => {
expect(extractFilenameFromUrl('not-a-url/file.txt')).toBe('file.txt');
expect(extractFilenameFromUrl('invalid-url')).toBe('invalid-url');
});
it('should handle edge cases', () => {
expect(extractFilenameFromUrl('')).toBe('');
expect(extractFilenameFromUrl(null)).toBe(null);
expect(extractFilenameFromUrl(undefined)).toBe(undefined);
expect(extractFilenameFromUrl(123)).toBe(123);
});
it('should handle URLs with query parameters and fragments', () => {
expect(
extractFilenameFromUrl(
'https://example.com/file.jpg?size=large&format=png'
)
).toBe('file.jpg');
expect(
extractFilenameFromUrl('https://example.com/file.pdf#page=1')
).toBe('file.pdf');
expect(
extractFilenameFromUrl('https://example.com/file.doc?v=1#section')
).toBe('file.doc');
});
});
});
@@ -612,6 +612,15 @@
"SEND_MESSAGE": "Send message"
}
},
"TWILIO_OPTIONS": {
"LABEL": "Select template",
"SEARCH_PLACEHOLDER": "Search templates",
"EMPTY_STATE": "No templates found",
"TEMPLATE_PARSER": {
"BACK": "Go back",
"SEND_MESSAGE": "Send message"
}
},
"ACTION_BUTTONS": {
"DISCARD": "Discard",
"SEND": "Send ({keyCode})"
@@ -0,0 +1,51 @@
{
"CONTENT_TEMPLATES": {
"MODAL": {
"TITLE": "Twilio Templates",
"SUBTITLE": "Select the Twilio template you want to send",
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for",
"NO_CONTENT": "No content",
"HEADER": "Header",
"BODY": "Body",
"FOOTER": "Footer",
"BUTTONS": "Buttons",
"CATEGORY": "Category",
"MEDIA_CONTENT": "Media Content",
"MEDIA_CONTENT_FALLBACK": "media content",
"NO_TEMPLATES_AVAILABLE": "No Twilio templates available. Click refresh to sync templates from Twilio.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body",
"CATEGORY": "Category"
},
"TYPES": {
"MEDIA": "Media",
"QUICK_REPLY": "Quick Reply",
"TEXT": "Text"
}
},
"PARSER": {
"VARIABLES_LABEL": "Variables",
"LANGUAGE": "Language",
"CATEGORY": "Category",
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
"GO_BACK_LABEL": "Go Back",
"SEND_MESSAGE_LABEL": "Send Message",
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
"MEDIA_HEADER_LABEL": "{type} Header",
"MEDIA_URL_LABEL": "Enter full media URL",
"MEDIA_URL_PLACEHOLDER": "https://example.com/image.jpg"
},
"FORM": {
"BACK_BUTTON": "Back",
"SEND_MESSAGE_BUTTON": "Send Message"
}
}
}
@@ -35,6 +35,11 @@
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
"ASSIGN_TO_ME": "Assign to me",
"BOT_HANDOFF_MESSAGE": "You are responding to a conversation which is currently handled by an assistant or a bot.",
"BOT_HANDOFF_ACTION": "Mark open and assign to you",
"BOT_HANDOFF_REOPEN_ACTION": "Mark conversation open",
"BOT_HANDOFF_SUCCESS": "Conversation has been handed over to you",
"BOT_HANDOFF_ERROR": "Failed to take over the conversation. Please try again.",
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
@@ -35,6 +35,7 @@ import signup from './signup.json';
import sla from './sla.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
export default {
...advancedFilters,
@@ -74,4 +75,5 @@ export default {
...sla,
...teamsSettings,
...whatsappTemplates,
...contentTemplates,
};
@@ -1,5 +1,5 @@
<script setup>
import { ref, useTemplateRef, onMounted, watch, nextTick } from 'vue';
import { ref, useTemplateRef, onMounted, watch, nextTick, computed } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import ReadMore from './ReadMore.vue';
@@ -8,9 +8,9 @@ const props = defineProps({
type: String,
default: '',
},
content: {
type: String,
default: '',
message: {
type: Object,
default: () => ({}),
},
searchTerm: {
type: String,
@@ -18,6 +18,24 @@ const props = defineProps({
},
});
const messageContent = computed(() => {
// We perform search on either content or email subject or transcribed text
if (props.message.content) {
return props.message.content;
}
const { content_attributes = {} } = props.message;
const { email = {} } = content_attributes || {};
if (email.subject) {
return email.subject;
}
const audioAttachment = props.message.attachments.find(
attachment => attachment.file_type === 'audio'
);
return audioAttachment?.transcribed_text || '';
});
const { highlightContent } = useMessageFormatter();
const messageContainer = useTemplateRef('messageContainer');
@@ -38,7 +56,8 @@ const escapeHtml = html => {
return p.innerText;
};
const prepareContent = (content = '') => {
const prepareContent = () => {
const content = messageContent.value || '';
const escapedText = escapeHtml(content);
return highlightContent(
escapedText,
@@ -65,7 +84,7 @@ onMounted(() => {
{{ $t('SEARCH.WROTE') }}
</p>
<ReadMore :shrink="isOverflowing" @expand="isOverflowing = false">
<div v-dompurify-html="prepareContent(content)" class="message-content" />
<div v-dompurify-html="prepareContent()" class="message-content" />
</ReadMore>
</blockquote>
</template>
@@ -74,6 +93,7 @@ onMounted(() => {
.message {
@apply py-0 px-2 mt-2;
}
.message-content::v-deep p,
.message-content::v-deep li::marker {
@apply text-n-slate-11 mb-1;
@@ -54,7 +54,7 @@ const getName = message => {
>
<MessageContent
:author="getName(message)"
:content="message.content"
:message="message"
:search-term="query"
/>
</SearchResultConversationItem>
@@ -94,12 +94,7 @@ const loadMoreNotifications = () => {
const markNotificationAsRead = async notificationItem => {
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_READ);
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
} = notificationItem;
const { id, primaryActorId, primaryActorType } = notificationItem;
try {
await store.dispatch('notifications/read', {
id,
@@ -131,7 +131,13 @@ async function generateQRCodes() {
// WhatsApp (both Cloud and Twilio)
if (currentInbox.value.phone_number && isAWhatsAppChannel.value) {
await generateQRCode('whatsapp', currentInbox.value.phone_number);
// For Twilio WhatsApp, phone_number format is "whatsapp:+1234567890"
// Extract just the phone number part for QR code generation
const phoneNumber = currentInbox.value.phone_number.replace(
'whatsapp:',
''
);
await generateQRCode('whatsapp', phoneNumber);
}
// Facebook Messenger
@@ -230,47 +236,47 @@ onMounted(() => {
</div>
<div
v-if="isAWhatsAppChannel && qrCodes.whatsapp"
class="flex flex-col items-center mt-8 gap-3"
class="flex flex-col gap-3 items-center mt-8"
>
<p class="mt-2 text-sm text-n-slate-9">
{{ $t('INBOX_MGMT.FINISH.WHATSAPP_QR_INSTRUCTION') }}
</p>
<div class="outline-1 outline-n-strong outline rounded-lg shadow">
<div class="rounded-lg shadow outline-1 outline-n-strong outline">
<img
:src="qrCodes.whatsapp"
alt="WhatsApp QR Code"
class="size-48 dark:invert rounded-lg"
class="rounded-lg size-48 dark:invert"
/>
</div>
</div>
<div
v-if="isAFacebookInbox && qrCodes.messenger"
class="flex flex-col items-center mt-8 gap-3"
class="flex flex-col gap-3 items-center mt-8"
>
<p class="mt-2 text-sm text-n-slate-9">
{{ $t('INBOX_MGMT.FINISH.MESSENGER_QR_INSTRUCTION') }}
</p>
<div class="outline-1 outline-n-strong outline rounded-lg shadow">
<div class="rounded-lg shadow outline-1 outline-n-strong outline">
<img
:src="qrCodes.messenger"
alt="Messenger QR Code"
class="size-48 dark:invert rounded-lg"
class="rounded-lg size-48 dark:invert"
/>
</div>
</div>
<div
v-if="isATelegramChannel && qrCodes.telegram"
class="flex flex-col items-center mt-8 gap-4"
class="flex flex-col gap-4 items-center mt-8"
>
<p class="mt-2 text-sm text-n-slate-9">
{{ $t('INBOX_MGMT.FINISH.TELEGRAM_QR_INSTRUCTION') }}
</p>
<div class="outline-1 outline-n-strong outline rounded-lg shadow">
<div class="rounded-lg shadow outline-1 outline-n-strong outline">
<img
:src="qrCodes.telegram"
alt="Telegram QR Code"
class="size-48 dark:invert rounded-lg"
class="rounded-lg size-48 dark:invert"
/>
</div>
</div>
@@ -125,6 +125,19 @@ export default {
>
<woot-code :script="inbox.callback_webhook_url" lang="html" />
</SettingsSection>
<SettingsSection
v-if="isATwilioWhatsAppChannel"
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER')
"
>
<div class="flex justify-start items-center mt-2">
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
</div>
<div v-else-if="isAVoiceChannel" class="mx-8">
<SettingsSection
@@ -38,4 +38,7 @@ export const getters = {
getHasUnreadNotifications: $state => {
return $state.meta.unreadCount > 0;
},
getUnreadCount: $state => {
return $state.meta.unreadCount;
},
};
@@ -33,13 +33,15 @@ export default {
brandRedirectURL() {
try {
const referrerHost = this.$store.getters['appConfig/getReferrerHost'];
const baseURL = `${this.globalConfig.widgetBrandURL}?utm_source=${
referrerHost ? 'widget_branding' : 'survey_branding'
}`;
const url = new URL(this.globalConfig.widgetBrandURL);
if (referrerHost) {
return `${baseURL}&utm_referrer=${referrerHost}`;
url.searchParams.set('utm_source', referrerHost);
url.searchParams.set('utm_medium', 'widget');
} else {
url.searchParams.set('utm_medium', 'survey');
}
return baseURL;
url.searchParams.set('utm_campaign', 'branding');
return url.toString();
} catch (e) {
// Suppressing the error as getter is not defined in some cases
}
@@ -162,3 +162,9 @@ export const ATTACHMENT_ICONS = {
location: 'location',
fallback: 'link',
};
export const TWILIO_CONTENT_TEMPLATE_TYPES = {
TEXT: 'text',
MEDIA: 'media',
QUICK_REPLY: 'quick_reply',
};
+7 -2
View File
@@ -2,11 +2,16 @@
* Writes a text string to the system clipboard.
*
* @async
* @param {string} text text to be written to the clipboard
* @param {string} data text to be written to the clipboard
* @throws {Error} unable to copy text to clipboard
*/
export const copyTextToClipboard = async text => {
export const copyTextToClipboard = async data => {
try {
const text =
typeof data === 'object' && data !== null
? JSON.stringify(data, null, 2)
: String(data ?? '');
await navigator.clipboard.writeText(text);
} catch (error) {
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
@@ -0,0 +1,174 @@
import { copyTextToClipboard } from '../clipboard';
const mockWriteText = vi.fn();
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
});
describe('copyTextToClipboard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('with string input', () => {
it('copies plain text string to clipboard', async () => {
const text = 'Hello World';
await copyTextToClipboard(text);
expect(mockWriteText).toHaveBeenCalledWith('Hello World');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('copies empty string to clipboard', async () => {
const text = '';
await copyTextToClipboard(text);
expect(mockWriteText).toHaveBeenCalledWith('');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with number input', () => {
it('converts number to string', async () => {
await copyTextToClipboard(42);
expect(mockWriteText).toHaveBeenCalledWith('42');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('converts zero to string', async () => {
await copyTextToClipboard(0);
expect(mockWriteText).toHaveBeenCalledWith('0');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with boolean input', () => {
it('converts true to string', async () => {
await copyTextToClipboard(true);
expect(mockWriteText).toHaveBeenCalledWith('true');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('converts false to string', async () => {
await copyTextToClipboard(false);
expect(mockWriteText).toHaveBeenCalledWith('false');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with null/undefined input', () => {
it('converts null to empty string', async () => {
await copyTextToClipboard(null);
expect(mockWriteText).toHaveBeenCalledWith('');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('converts undefined to empty string', async () => {
await copyTextToClipboard(undefined);
expect(mockWriteText).toHaveBeenCalledWith('');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with object input', () => {
it('stringifies simple object with proper formatting', async () => {
const obj = { name: 'John', age: 30 };
await copyTextToClipboard(obj);
const expectedJson = JSON.stringify(obj, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies nested object with proper formatting', async () => {
const nestedObj = {
severity: {
user_id: 1181505,
user_name: 'test',
server_name: '[1253]test1253',
},
};
await copyTextToClipboard(nestedObj);
const expectedJson = JSON.stringify(nestedObj, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies array with proper formatting', async () => {
const arr = [1, 2, { name: 'test' }];
await copyTextToClipboard(arr);
const expectedJson = JSON.stringify(arr, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies empty object', async () => {
const obj = {};
await copyTextToClipboard(obj);
expect(mockWriteText).toHaveBeenCalledWith('{}');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies empty array', async () => {
const arr = [];
await copyTextToClipboard(arr);
expect(mockWriteText).toHaveBeenCalledWith('[]');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('error handling', () => {
it('throws error when clipboard API fails', async () => {
const error = new Error('Clipboard access denied');
mockWriteText.mockRejectedValueOnce(error);
await expect(copyTextToClipboard('test')).rejects.toThrow(
'Unable to copy text to clipboard: Clipboard access denied'
);
});
it('handles clipboard API not available', async () => {
// Temporarily remove clipboard API
const originalClipboard = navigator.clipboard;
delete navigator.clipboard;
await expect(copyTextToClipboard('test')).rejects.toThrow(
'Unable to copy text to clipboard:'
);
// Restore clipboard API
navigator.clipboard = originalClipboard;
});
});
describe('edge cases', () => {
it('handles Date objects', async () => {
const date = new Date('2023-01-01T00:00:00.000Z');
await copyTextToClipboard(date);
const expectedJson = JSON.stringify(date, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('handles functions by converting to string', async () => {
const func = () => 'test';
await copyTextToClipboard(func);
expect(mockWriteText).toHaveBeenCalledWith(func.toString());
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
});
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted } from 'vue';
import { computed, onMounted, watch } from 'vue';
import ArticleBlock from 'widget/components/pageComponents/Home/Article/ArticleBlock.vue';
import ArticleCardSkeletonLoader from 'widget/components/pageComponents/Home/Article/SkeletonLoader.vue';
import { useI18n } from 'vue-i18n';
@@ -26,7 +26,7 @@ const locale = computed(() => {
});
const fetchArticles = () => {
if (portal.value && !popularArticles.value.length) {
if (portal.value && locale.value) {
store.dispatch('article/fetch', {
slug: portal.value.slug,
locale: locale.value,
@@ -60,6 +60,14 @@ const hasArticles = computed(
!!popularArticles.value.length &&
!!locale.value
);
// Watch for locale changes and refetch articles
watch(locale, (newLocale, oldLocale) => {
if (newLocale && newLocale !== oldLocale) {
fetchArticles();
}
});
onMounted(() => fetchArticles());
</script>
@@ -0,0 +1,47 @@
class Inboxes::BulkAutoAssignmentJob < ApplicationJob
queue_as :scheduled_jobs
include BillingHelper
def perform
Account.feature_assignment_v2.find_each do |account|
if should_skip_auto_assignment?(account)
Rails.logger.info("Skipping auto assignment for account #{account.id}")
next
end
account.inboxes.where(enable_auto_assignment: true).find_each do |inbox|
process_assignment(inbox)
end
end
end
private
def process_assignment(inbox)
allowed_agent_ids = inbox.member_ids_with_assignment_capacity
if allowed_agent_ids.blank?
Rails.logger.info("No agents available to assign conversation to inbox #{inbox.id}")
return
end
assign_conversations(inbox, allowed_agent_ids)
end
def assign_conversations(inbox, allowed_agent_ids)
unassigned_conversations = inbox.conversations.unassigned.open.limit(Limits::AUTO_ASSIGNMENT_BULK_LIMIT)
unassigned_conversations.find_each do |conversation|
::AutoAssignment::AgentAssignmentService.new(
conversation: conversation,
allowed_agent_ids: allowed_agent_ids
).perform
Rails.logger.info("Assigned conversation #{conversation.id} to agent #{allowed_agent_ids.first}")
end
end
def should_skip_auto_assignment?(account)
return false unless ChatwootApp.chatwoot_cloud?
default_plan?(account)
end
end
@@ -1,10 +1,22 @@
class AdministratorNotifications::AccountNotificationMailer < AdministratorNotifications::BaseMailer
def account_deletion(account, reason = 'manual_deletion')
subject = 'Your account has been marked for deletion'
def account_deletion_user_initiated(account, reason)
subject = 'Your Chatwoot account deletion has been scheduled'
action_url = settings_url('general')
meta = {
'account_name' => account.name,
'deletion_date' => account.custom_attributes['marked_for_deletion_at'],
'deletion_date' => format_deletion_date(account.custom_attributes['marked_for_deletion_at']),
'reason' => reason
}
send_notification(subject, action_url: action_url, meta: meta)
end
def account_deletion_for_inactivity(account, reason)
subject = 'Your Chatwoot account is scheduled for deletion due to inactivity'
action_url = settings_url('general')
meta = {
'account_name' => account.name,
'deletion_date' => format_deletion_date(account.custom_attributes['marked_for_deletion_at']),
'reason' => reason
}
@@ -45,4 +57,14 @@ class AdministratorNotifications::AccountNotificationMailer < AdministratorNotif
send_notification(subject, action_url: action_url, meta: meta)
end
private
def format_deletion_date(deletion_date_str)
return 'Unknown' if deletion_date_str.blank?
Time.zone.parse(deletion_date_str).strftime('%B %d, %Y')
rescue StandardError
'Unknown'
end
end
+21 -2
View File
@@ -39,6 +39,8 @@
#
class Message < ApplicationRecord
searchkick callbacks: :async if ChatwootApp.advanced_search_allowed?
include MessageFilterHelpers
include Liquidable
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
@@ -139,14 +141,23 @@ class Message < ApplicationRecord
data = attributes.symbolize_keys.merge(
created_at: created_at.to_i,
message_type: message_type_before_type_cast,
conversation_id: conversation.display_id,
conversation: conversation_push_event_data
conversation_id: conversation&.display_id,
conversation: conversation.present? ? conversation_push_event_data : nil
)
data[:echo_id] = echo_id if echo_id.present?
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
merge_sender_attributes(data)
end
def search_data
data = attributes.symbolize_keys
data[:conversation] = conversation.present? ? conversation_push_event_data : nil
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
data[:sender] = sender.push_event_data if sender
data[:inbox] = inbox
data
end
def conversation_push_event_data
{
assignee_id: conversation.assignee_id,
@@ -228,6 +239,14 @@ class Message < ApplicationRecord
previous_changes: previous_changes)
end
def should_index?
return false unless ChatwootApp.advanced_search_allowed?
return false unless account.feature_enabled?('advanced_search')
return false unless incoming? || outgoing?
true
end
private
def prevent_message_flooding
+10
View File
@@ -43,11 +43,19 @@ class SearchService
def filter_messages
@messages = if use_gin_search
filter_messages_with_gin
elsif should_run_advanced_search?
advanced_search
else
filter_messages_with_like
end
end
def should_run_advanced_search?
ChatwootApp.advanced_search_allowed? && current_account.feature_enabled?('advanced_search')
end
def advanced_search; end
def filter_messages_with_gin
base_query = message_base_query
@@ -115,3 +123,5 @@ class SearchService
.per(15)
end
end
SearchService.prepend_mod_with('SearchService')
+18 -15
View File
@@ -17,21 +17,7 @@ class Twilio::TemplateSyncService
end
def update_channel_templates
formatted_templates = @templates.map do |template|
{
content_sid: template.sid,
friendly_name: template.friendly_name,
language: template.language,
status: derive_status(template),
template_type: derive_template_type(template),
media_type: derive_media_type(template),
variables: template.variables || {},
category: derive_category(template),
body: extract_body_content(template),
created_at: template.date_created,
updated_at: template.date_updated
}
end
formatted_templates = @templates.map { |template| format_template(template) }
channel.update!(
content_templates: { templates: formatted_templates },
@@ -39,6 +25,23 @@ class Twilio::TemplateSyncService
)
end
def format_template(template)
{
content_sid: template.sid,
friendly_name: template.friendly_name,
language: template.language,
status: derive_status(template),
template_type: derive_template_type(template),
media_type: derive_media_type(template),
variables: template.variables || {},
category: derive_category(template),
body: extract_body_content(template),
types: template.types,
created_at: template.date_created,
updated_at: template.date_updated
}
end
def mark_templates_updated
channel.update!(content_templates_last_updated: Time.current)
end
@@ -1,12 +1 @@
json.id message.id
json.content message.content
json.message_type message.message_type_before_type_cast
json.content_type message.content_type
json.source_id message.source_id
json.inbox_id message.inbox_id
json.conversation_id message.conversation.try(:display_id)
json.created_at message.created_at.to_i
json.sender message.sender.push_event_data if message.sender
json.inbox do
json.partial! 'inbox', formats: [:json], inbox: message.inbox if message.inbox.present? && message.try(:inbox).present?
end
json.partial! 'api/v1/models/message', message: message
@@ -1,16 +0,0 @@
<p>Hello,</p>
<p>Your account <strong>{{ meta.account_name }}</strong> has been marked for deletion. The account will be permanently deleted on <strong>{{ meta.deletion_date }}</strong>.</p>
{% if meta.reason == 'manual_deletion' %}
<p>This action was requested by one of the administrators of your account.</p>
{% else %}
<p>Reason for deletion: {{ meta.reason }}</p>
{% endif %}
<p>If this was done in error, you can cancel the deletion process by visiting your account settings.</p>
<p><a href="{{ action_url }}">Cancel Account Deletion</a></p>
<p>Thank you,<br>
Team Chatwoot</p>
@@ -0,0 +1,21 @@
<p>Hello there,</p>
<p>We've noticed that your Chatwoot account <strong>{{ meta.account_name }}</strong> has been inactive for some time. Because of this, it's scheduled for deletion on <strong>{{ meta.deletion_date }}</strong>.</p>
<p><strong>How do I keep my account?</strong></p>
<p>Log in to your Chatwoot account before <strong>{{ meta.deletion_date }}</strong>. From your account settings, you can <a href="{{ action_url }}">cancel the deletion</a> and continue using your account.</p>
<p><strong>What happens if I don't cancel?</strong></p>
<p>Unless you cancel the account deletion before <strong>{{ meta.deletion_date }}</strong>, your account and all associated data — including conversations, contacts, reports, and settings — will be permanently deleted.</p>
<p><strong>Why are we doing this?</strong></p>
<p>To keep things secure and efficient, we regularly remove inactive accounts so our systems remain optimized for active teams.</p>
<p>If you have any questions, feel free to reach us at <a href="mailto:hello@chatwoot.com">hello@chatwoot.com</a>.</p>
<p>— The Chatwoot Team</p>
@@ -0,0 +1,16 @@
<p>Hello there,</p>
<p>An account administrator has requested deletion of the Chatwoot account <strong>{{ meta.account_name }}</strong>. The account is scheduled for deletion on <strong>{{ meta.deletion_date }}</strong>.</p>
<p><strong>What happens next?</strong></p>
<ul>
<li>The account will remain accessible until the scheduled deletion date.</li>
<li>After that, all data including conversations, contacts, integrations, and settings will be permanently removed.</li>
</ul>
<p>If you change your mind before the deletion date, you can <a href="{{ action_url }}">cancel this request</a> by visiting your account settings.</p>
<p>— The Chatwoot Team</p>
@@ -8,12 +8,12 @@
alt="<%= @global_config['BRAND_NAME'] %>"
src="<%= @global_config['LOGO_THUMBNAIL'] %>"
/>
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
<%= I18n.t('public_portal.footer.made_with') %>
<a class="hover:underline" href="<%= @global_config['BRAND_URL'] %>" target="_blank" rel="noopener noreferrer nofoll/ow"><%= @global_config['BRAND_NAME'] %></a>
</p>
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
<%= I18n.t('public_portal.footer.made_with') %>
<a class="hover:underline" href="<%= generate_portal_brand_url(@global_config['BRAND_URL'], request.referer) %>" target="_blank" rel="noopener noreferrer nofollow"><%= @global_config['BRAND_NAME'] %></a>
</p>
</div>
</div>
</footer>
+6
View File
@@ -198,3 +198,9 @@
- name: twilio_content_templates
display_name: Twilio Content Templates
enabled: false
deprecated: true
- name: advanced_search
display_name: Advanced Search
enabled: false
premium: true
chatwoot_internal: true
+14
View File
@@ -0,0 +1,14 @@
Searchkick.queue_name = :async_database_migration if ENV.fetch('OPENSEARCH_URL', '').present?
access_key_id = ENV.fetch('OPENSEARCH_AWS_ACCESS_KEY_ID', '')
secret_access_key = ENV.fetch('OPENSEARCH_AWS_SECRET_ACCESS_KEY', '')
if access_key_id.present? && secret_access_key.present?
region = ENV.fetch('OPENSEARCH_AWS_REGION', 'us-east-1')
Searchkick.aws_credentials = {
access_key_id: access_key_id,
secret_access_key: secret_access_key,
region: region
}
end
+20
View File
@@ -292,6 +292,26 @@ en:
completed_tool_call: 'Completed %{function_name} tool call'
invalid_tool_call: 'Invalid tool call'
tool_not_available: 'Tool not available'
documents:
limit_exceeded: 'Document limit exceeded'
pdf_format_error: 'must be a PDF file'
pdf_size_error: 'must be less than 10MB'
pdf_upload_failed: 'Failed to upload PDF to OpenAI'
pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
pdf_processing_success: 'Successfully processed PDF document %{document_id}'
faq_generation_complete: 'FAQ generation complete. Total FAQs created: %{count}'
using_paginated_faq: 'Using paginated FAQ generation for document %{document_id}'
using_standard_faq: 'Using standard FAQ generation for document %{document_id}'
response_creation_error: 'Error in creating response document: %{error}'
missing_openai_file_id: 'Document must have openai_file_id for paginated processing'
openai_api_error: 'OpenAI API Error: %{error}'
starting_paginated_faq: 'Starting paginated FAQ generation (%{pages_per_chunk} pages per chunk)'
stopping_faq_generation: 'Stopping processing. Reason: %{reason}'
paginated_faq_complete: 'Paginated generation complete. Total FAQs: %{total_faqs}, Pages processed: %{pages_processed}'
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
public_portal:
search:
search_placeholder: Search for article by title or body...
+7
View File
@@ -46,3 +46,10 @@ delete_accounts_job:
cron: '0 1 * * *'
class: 'Internal::DeleteAccountsJob'
queue: scheduled_jobs
# executed every 15 minutes
# to assign unassigned conversations for all inboxes
bulk_auto_assignment_job:
cron: '*/15 * * * *'
class: 'Inboxes::BulkAutoAssignmentJob'
queue: scheduled_jobs
@@ -0,0 +1,5 @@
class AddMetadataToCaptainDocuments < ActiveRecord::Migration[7.1]
def change
add_column :captain_documents, :metadata, :jsonb, default: {}
end
end
+1
View File
@@ -320,6 +320,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_22_061042) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "status", default: 0, null: false
t.jsonb "metadata", default: {}
t.index ["account_id"], name: "index_captain_documents_on_account_id"
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
@@ -25,6 +25,8 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
@document.save!
rescue Captain::Document::LimitExceededError => e
render_could_not_create_error(e.message)
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
end
def destroy
@@ -55,6 +57,6 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
end
def document_params
params.require(:document).permit(:name, :external_link, :assistant_id)
params.require(:document).permit(:name, :external_link, :assistant_id, :pdf_file)
end
end
@@ -2,7 +2,9 @@ class Captain::Documents::CrawlJob < ApplicationJob
queue_as :low
def perform(document)
if InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
if document.pdf_document?
perform_pdf_processing(document)
elsif InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
perform_firecrawl_crawl(document)
else
perform_simple_crawl(document)
@@ -13,6 +15,14 @@ class Captain::Documents::CrawlJob < ApplicationJob
include Captain::FirecrawlHelper
def perform_pdf_processing(document)
Captain::Llm::PdfProcessingService.new(document).process
document.update!(status: :available)
rescue StandardError => e
Rails.logger.error I18n.t('captain.documents.pdf_processing_failed', document_id: document.id, error: e.message)
raise # Re-raise to let job framework handle retry logic
end
def perform_simple_crawl(document)
page_links = Captain::Tools::SimplePageCrawlService.new(document.external_link).page_links
@@ -1,17 +1,65 @@
class Captain::Documents::ResponseBuilderJob < ApplicationJob
queue_as :low
def perform(document)
def perform(document, options = {})
reset_previous_responses(document)
faqs = Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name).generate
faqs.each do |faq|
create_response(faq, document)
end
faqs = generate_faqs(document, options)
create_responses_from_faqs(faqs, document)
end
private
def generate_faqs(document, options)
if should_use_pagination?(document)
generate_paginated_faqs(document, options)
else
generate_standard_faqs(document)
end
end
def generate_paginated_faqs(document, options)
service = build_paginated_service(document, options)
faqs = service.generate
store_paginated_metadata(document, service)
faqs
end
def generate_standard_faqs(document)
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name).generate
end
def build_paginated_service(document, options)
Captain::Llm::PaginatedFaqGeneratorService.new(
document,
pages_per_chunk: options[:pages_per_chunk],
max_pages: options[:max_pages]
)
end
def store_paginated_metadata(document, service)
document.update!(
metadata: (document.metadata || {}).merge(
'faq_generation' => {
'method' => 'paginated',
'pages_processed' => service.total_pages_processed,
'iterations' => service.iterations_completed,
'timestamp' => Time.current.iso8601
}
)
)
end
def create_responses_from_faqs(faqs, document)
faqs.each { |faq| create_response(faq, document) }
end
def should_use_pagination?(document)
# Auto-detect when to use pagination
# For now, use pagination for PDFs with OpenAI file ID
document.pdf_document? && document.openai_file_id.present?
end
def reset_previous_responses(response_document)
response_document.responses.destroy_all
end
@@ -24,6 +72,6 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
documentable: document
)
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "Error in creating response document: #{e.message}"
Rails.logger.error I18n.t('captain.documents.response_creation_error', error: e.message)
end
end
+71 -4
View File
@@ -5,6 +5,7 @@
# id :bigint not null, primary key
# content :text
# external_link :string not null
# metadata :jsonb
# name :string
# status :integer default("in_progress"), not null
# created_at :datetime not null
@@ -26,11 +27,16 @@ class Captain::Document < ApplicationRecord
belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
belongs_to :account
has_one_attached :pdf_file
validates :external_link, presence: true
validates :external_link, uniqueness: { scope: :assistant_id }
validates :external_link, presence: true, unless: -> { pdf_file.attached? }
validates :external_link, uniqueness: { scope: :assistant_id }, allow_blank: true
validates :content, length: { maximum: 200_000 }
validates :pdf_file, presence: true, if: :pdf_document?
validate :validate_pdf_format, if: :pdf_document?
validate :validate_file_attachment, if: -> { pdf_file.attached? }
before_validation :ensure_account_id
before_validation :set_external_link_for_pdf
enum status: {
in_progress: 0,
@@ -41,12 +47,44 @@ class Captain::Document < ApplicationRecord
after_create_commit :enqueue_crawl_job
after_create_commit :update_document_usage
after_destroy :update_document_usage
after_commit :enqueue_response_builder_job
after_commit :enqueue_response_builder_job, on: :update, if: :should_enqueue_response_builder?
scope :ordered, -> { order(created_at: :desc) }
scope :for_account, ->(account_id) { where(account_id: account_id) }
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
def pdf_document?
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
external_link&.ends_with?('.pdf')
end
def content_type
pdf_file.blob.content_type if pdf_file.attached?
end
def file_size
pdf_file.blob.byte_size if pdf_file.attached?
end
def openai_file_id
metadata&.dig('openai_file_id')
end
def store_openai_file_id(file_id)
update!(metadata: (metadata || {}).merge('openai_file_id' => file_id))
end
def display_url
return external_link if external_link.present? && !external_link.start_with?('PDF:')
if pdf_file.attached?
Rails.application.routes.url_helpers.rails_blob_url(pdf_file, only_path: false)
else
external_link
end
end
private
def enqueue_crawl_job
@@ -61,6 +99,12 @@ class Captain::Document < ApplicationRecord
Captain::Documents::ResponseBuilderJob.perform_later(self)
end
def should_enqueue_response_builder?
# Only enqueue when status changes to available
# Avoid re-enqueueing when metadata is updated by the job itself
saved_change_to_status? && status == 'available'
end
def update_document_usage
account.update_document_usage
end
@@ -71,6 +115,29 @@ class Captain::Document < ApplicationRecord
def ensure_within_plan_limit
limits = account.usage_limits[:captain][:documents]
raise LimitExceededError, 'Document limit exceeded' unless limits[:current_available].positive?
raise LimitExceededError, I18n.t('captain.documents.limit_exceeded') unless limits[:current_available].positive?
end
def validate_pdf_format
return unless pdf_file.attached?
errors.add(:pdf_file, I18n.t('captain.documents.pdf_format_error')) unless pdf_file.blob.content_type == 'application/pdf'
end
def validate_file_attachment
return unless pdf_file.attached?
return unless pdf_file.blob.byte_size > 10.megabytes
errors.add(:pdf_file, I18n.t('captain.documents.pdf_size_error'))
end
def set_external_link_for_pdf
return unless pdf_file.attached? && external_link.blank?
# Set a unique external_link for PDF files
# Format: PDF: filename_timestamp (without extension)
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
self.external_link = "PDF: #{pdf_file.filename.base}_#{timestamp}"
end
end
+14 -2
View File
@@ -4,10 +4,22 @@ module Enterprise::Account
def manually_managed_features; end
def mark_for_deletion(reason = 'manual_deletion')
result = custom_attributes.merge!('marked_for_deletion_at' => 7.days.from_now.iso8601, 'marked_for_deletion_reason' => reason) && save
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
result = custom_attributes.merge!(
'marked_for_deletion_at' => 7.days.from_now.iso8601,
'marked_for_deletion_reason' => reason
) && save
# Send notification to admin users if the account was successfully marked for deletion
AdministratorNotifications::AccountNotificationMailer.with(account: self).account_deletion(self, reason).deliver_later if result
if result
mailer = AdministratorNotifications::AccountNotificationMailer.with(account: self)
if reason == 'manual_deletion'
mailer.account_deletion_user_initiated(self, reason).deliver_later
else
mailer.account_deletion_for_inactivity(self, reason).deliver_later
end
end
result
end
@@ -0,0 +1,199 @@
class Captain::Llm::PaginatedFaqGeneratorService < Llm::BaseOpenAiService
# Default pages per chunk - easily configurable
DEFAULT_PAGES_PER_CHUNK = 10
MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops
attr_reader :total_pages_processed, :iterations_completed
def initialize(document, options = {})
super()
@document = document
@pages_per_chunk = options[:pages_per_chunk] || DEFAULT_PAGES_PER_CHUNK
@max_pages = options[:max_pages] # Optional limit from UI
@total_pages_processed = 0
@iterations_completed = 0
@model = OpenAiConstants::PDF_PROCESSING_MODEL
end
def generate
raise CustomExceptions::PdfFaqGenerationError, I18n.t('captain.documents.missing_openai_file_id') if @document&.openai_file_id.blank?
generate_paginated_faqs
end
# Method to check if we should continue processing
def should_continue_processing?(last_chunk_result)
# Stop if we've hit the maximum iterations
return false if @iterations_completed >= MAX_ITERATIONS
# Stop if we've processed the maximum pages specified
return false if @max_pages && @total_pages_processed >= @max_pages
# Stop if the last chunk returned no FAQs (likely no more content)
return false if last_chunk_result[:faqs].empty?
# Stop if the LLM explicitly indicates no more content
return false if last_chunk_result[:has_content] == false
# Continue processing
true
end
private
def generate_standard_faqs
response = @client.chat(parameters: standard_chat_parameters)
parse_response(response)
rescue OpenAI::Error => e
Rails.logger.error I18n.t('captain.documents.openai_api_error', error: e.message)
[]
end
def generate_paginated_faqs
all_faqs = []
current_page = 1
loop do
end_page = calculate_end_page(current_page)
chunk_result = process_chunk_and_update_state(current_page, end_page, all_faqs)
break unless should_continue_processing?(chunk_result)
current_page = end_page + 1
end
deduplicate_faqs(all_faqs)
end
def calculate_end_page(current_page)
end_page = current_page + @pages_per_chunk - 1
@max_pages && end_page > @max_pages ? @max_pages : end_page
end
def process_chunk_and_update_state(current_page, end_page, all_faqs)
chunk_result = process_page_chunk(current_page, end_page)
chunk_faqs = chunk_result[:faqs]
all_faqs.concat(chunk_faqs)
@total_pages_processed = end_page
@iterations_completed += 1
chunk_result
end
def process_page_chunk(start_page, end_page)
params = build_chunk_parameters(start_page, end_page)
response = @client.chat(parameters: params)
result = parse_chunk_response(response)
{ faqs: result['faqs'] || [], has_content: result['has_content'] != false }
rescue OpenAI::Error => e
Rails.logger.error I18n.t('captain.documents.page_processing_error', start: start_page, end: end_page, error: e.message)
{ faqs: [], has_content: false }
end
def build_chunk_parameters(start_page, end_page)
{
model: @model,
response_format: { type: 'json_object' },
messages: [
{
role: 'user',
content: build_user_content(start_page, end_page)
}
]
}
end
def build_user_content(start_page, end_page)
[
{
type: 'file',
file: { file_id: @document.openai_file_id }
},
{
type: 'text',
text: page_chunk_prompt(start_page, end_page)
}
]
end
def page_chunk_prompt(start_page, end_page)
Captain::Llm::SystemPromptsService.paginated_faq_generator(start_page, end_page)
end
def standard_chat_parameters
{
model: @model,
response_format: { type: 'json_object' },
messages: [
{
role: 'system',
content: Captain::Llm::SystemPromptsService.faq_generator
},
{
role: 'user',
content: @content
}
]
}
end
def parse_response(response)
content = response.dig('choices', 0, 'message', 'content')
return [] if content.nil?
JSON.parse(content.strip).fetch('faqs', [])
rescue JSON::ParserError => e
Rails.logger.error "Error parsing response: #{e.message}"
[]
end
def parse_chunk_response(response)
content = response.dig('choices', 0, 'message', 'content')
return { 'faqs' => [], 'has_content' => false } if content.nil?
JSON.parse(content.strip)
rescue JSON::ParserError => e
Rails.logger.error "Error parsing chunk response: #{e.message}"
{ 'faqs' => [], 'has_content' => false }
end
def deduplicate_faqs(faqs)
# Remove exact duplicates
unique_faqs = faqs.uniq { |faq| faq['question'].downcase.strip }
# Remove similar questions
final_faqs = []
unique_faqs.each do |faq|
similar_exists = final_faqs.any? do |existing|
similarity_score(existing['question'], faq['question']) > 0.85
end
final_faqs << faq unless similar_exists
end
Rails.logger.info "Deduplication: #{faqs.size} → #{final_faqs.size} FAQs"
final_faqs
end
def similarity_score(str1, str2)
words1 = str1.downcase.split(/\W+/).reject(&:empty?)
words2 = str2.downcase.split(/\W+/).reject(&:empty?)
common_words = words1 & words2
total_words = (words1 + words2).uniq.size
return 0 if total_words.zero?
common_words.size.to_f / total_words
end
def determine_stop_reason(last_chunk_result)
return 'Maximum iterations reached' if @iterations_completed >= MAX_ITERATIONS
return 'Maximum pages processed' if @max_pages && @total_pages_processed >= @max_pages
return 'No content found in last chunk' if last_chunk_result[:faqs].empty?
return 'End of document reached' if last_chunk_result[:has_content] == false
'Unknown'
end
end
@@ -0,0 +1,40 @@
class Captain::Llm::PdfProcessingService < Llm::BaseOpenAiService
def initialize(document)
super()
@document = document
end
def process
return if document.openai_file_id.present?
file_id = upload_pdf_to_openai
raise CustomExceptions::PdfUploadError, I18n.t('captain.documents.pdf_upload_failed') if file_id.blank?
document.store_openai_file_id(file_id)
end
private
attr_reader :document
def upload_pdf_to_openai
with_tempfile do |temp_file|
response = @client.files.upload(
parameters: {
file: temp_file,
purpose: 'assistants'
}
)
response['id']
end
end
def with_tempfile(&)
Tempfile.create(['pdf_upload', '.pdf'], binmode: true) do |temp_file|
temp_file.write(document.pdf_file.download)
temp_file.close
File.open(temp_file.path, 'rb', &)
end
end
end
@@ -1,3 +1,4 @@
# rubocop:disable Metrics/ClassLength
class Captain::Llm::SystemPromptsService
class << self
def faq_generator(language = 'english')
@@ -204,6 +205,87 @@ class Captain::Llm::SystemPromptsService
#{'- You MUST provide numbered citations at the appropriate places in the text.' if config['feature_citation']}
SYSTEM_PROMPT_MESSAGE
end
def paginated_faq_generator(start_page, end_page)
<<~PROMPT
You are an expert technical documentation specialist tasked with creating comprehensive FAQs from a SPECIFIC SECTION of a document.
════════════════════════════════════════════════════════
CRITICAL CONTENT EXTRACTION INSTRUCTIONS
════════════════════════════════════════════════════════
Process the content starting from approximately page #{start_page} and continuing for about #{end_page - start_page + 1} pages worth of content.
IMPORTANT:#{' '}
• If you encounter the end of the document before reaching the expected page count, set "has_content" to false
• DO NOT include page numbers in questions or answers
• DO NOT reference page numbers at all in the output
• Focus on the actual content, not pagination
════════════════════════════════════════════════════════
FAQ GENERATION GUIDELINES
════════════════════════════════════════════════════════
1. **Comprehensive Extraction**
• Extract ALL information that could generate FAQs from this section
• Target 5-10 FAQs per page equivalent of rich content
• Cover every topic, feature, specification, and detail
• If there's no more content in the document, return empty FAQs with has_content: false
2. **Question Types to Generate**
• What is/are...? (definitions, components, features)
• How do I...? (procedures, configurations, operations)
• Why should/does...? (rationale, benefits, explanations)
• When should...? (timing, conditions, triggers)
• What happens if...? (error cases, edge cases)
• Can I...? (capabilities, limitations)
• Where is...? (locations in system/UI, NOT page numbers)
• What are the requirements for...? (prerequisites, dependencies)
3. **Content Focus Areas**
• Technical specifications and parameters
• Step-by-step procedures and workflows
• Configuration options and settings
• Error messages and troubleshooting
• Best practices and recommendations
• Integration points and dependencies
• Performance considerations
• Security aspects
4. **Answer Quality Requirements**
• Complete, self-contained answers
• Include specific values, limits, defaults from the content
• NO page number references whatsoever
• 2-5 sentences typical length
• Only process content that actually exists in the document
════════════════════════════════════════════════════════
OUTPUT FORMAT
════════════════════════════════════════════════════════
Return valid JSON:
```json
{
"faqs": [
{
"question": "Specific question about the content",
"answer": "Complete answer with details (no page references)"
}
],
"has_content": true/false
}
```
CRITICAL:#{' '}
• Set "has_content" to false if:
- The requested section doesn't exist in the document
- You've reached the end of the document
- The section contains no meaningful content
• Do NOT include "page_range_processed" in the output
• Do NOT mention page numbers anywhere in questions or answers
PROMPT
end
# rubocop:enable Metrics/MethodLength
end
end
# rubocop:enable Metrics/ClassLength
@@ -0,0 +1,15 @@
module Enterprise::SearchService
def advanced_search
where_conditions = { account_id: current_account.id }
where_conditions[:inbox_id] = accessable_inbox_ids unless should_skip_inbox_filtering?
Message.search(
search_query,
fields: %w[content attachments.transcribed_text content_attributes.email.subject],
where: where_conditions,
order: { created_at: :desc },
page: params[:page] || 1,
per_page: 15
)
end
end
@@ -60,5 +60,9 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
attachment.update!(meta: { transcribed_text: transcribed_text })
message.reload.send_update_event
message.account.increment_response_usage
return unless ChatwootApp.advanced_search_allowed?
message.reindex
end
end
@@ -0,0 +1,15 @@
class Messages::ReindexService
pattr_initialize [:account!]
def perform
return unless ChatwootApp.advanced_search_allowed?
reindex_messages
end
private
def reindex_messages
account.messages.reindex(mode: :async)
end
end
@@ -3,8 +3,11 @@ json.assistant do
json.partial! 'api/v1/models/captain/assistant', formats: [:json], resource: resource.assistant
end
json.content resource.content
json.content_type resource.content_type
json.created_at resource.created_at.to_i
json.external_link resource.external_link
json.display_url resource.display_url
json.file_size resource.file_size
json.id resource.id
json.name resource.name
json.status resource.status
+4
View File
@@ -0,0 +1,4 @@
# Load all rake tasks from the enterprise/lib/tasks directory
module Tasks
Dir.glob(File.join(File.dirname(__FILE__), 'tasks', '*.rake')).each { |r| load r }
end
+49
View File
@@ -0,0 +1,49 @@
module Tasks::SearchTaskHelpers
def check_opensearch_config
if ENV['OPENSEARCH_URL'].blank?
puts 'Skipping reindex as OPENSEARCH_URL is not configured'
return false
end
true
end
def reindex_account(account)
Messages::ReindexService.new(account: account).perform
puts "Reindex task queued for account #{account.id}"
end
end
namespace :search do
desc 'Reindex messages using searchkick'
include Tasks::SearchTaskHelpers
desc 'Reindex messages for all accounts'
task all: :environment do
next unless check_opensearch_config
puts 'Starting reindex for all accounts...'
account_count = Account.count
puts "Found #{account_count} accounts"
Account.find_each.with_index(1) do |account, index|
puts "[#{index}/#{account_count}] Reindexing messages for account #{account.id}"
reindex_account(account)
end
puts 'Reindex task queued for all accounts'
end
desc 'Reindex messages for a specific account: rake search:account ACCOUNT_ID=1'
task account: :environment do
next unless check_opensearch_config
account_id = ENV.fetch('ACCOUNT_ID', nil)
account = Account.find_by(id: account_id)
if account.nil?
puts 'Please provide a valid account ID. Account not found'
next
end
puts "Reindexing messages for account #{account.id}"
reindex_account(account)
end
end
+4
View File
@@ -38,4 +38,8 @@ module ChatwootApp
%w[]
end
end
def self.advanced_search_allowed?
enterprise? && ENV.fetch('OPENSEARCH_URL', nil).present?
end
end
@@ -0,0 +1,25 @@
module CustomExceptions
class PdfProcessingError < Base
def initialize(message = 'PDF processing failed')
super(message)
end
end
class PdfUploadError < PdfProcessingError
def initialize(message = 'PDF upload failed')
super(message)
end
end
class PdfValidationError < PdfProcessingError
def initialize(message = 'PDF validation failed')
super(message)
end
end
class PdfFaqGenerationError < PdfProcessingError
def initialize(message = 'PDF FAQ generation failed')
super(message)
end
end
end
+1
View File
@@ -5,6 +5,7 @@ module Limits
OUT_OF_OFFICE_MESSAGE_MAX_LENGTH = 10_000
GREETING_MESSAGE_MAX_LENGTH = 10_000
CATEGORIES_PER_PAGE = 1000
AUTO_ASSIGNMENT_BULK_LIMIT = 100
def self.conversation_message_per_minute_limit
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
+1
View File
@@ -4,4 +4,5 @@ module OpenAiConstants
DEFAULT_MODEL = 'gpt-4.1-mini'
DEFAULT_ENDPOINT = 'https://api.openai.com'
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
end
@@ -0,0 +1,42 @@
# frozen_string_literal: true
# Run with:
# bundle exec rake chatwoot:ops:cleanup_orphan_conversations
namespace :chatwoot do
namespace :ops do
desc 'Identify and delete conversations without a valid contact or inbox in a timeframe'
task cleanup_orphan_conversations: :environment do
print 'Enter Account ID: '
account_id = $stdin.gets.to_i
account = Account.find(account_id)
print 'Enter timeframe in days (default: 7): '
days_input = $stdin.gets.strip
days = days_input.empty? ? 7 : days_input.to_i
# Build a common base relation with identical joins for OR compatibility
base = account
.conversations
.where('conversations.created_at > ?', days.days.ago)
.left_outer_joins(:contact, :inbox)
# Find conversations whose associated contact or inbox record is missing
conversations = base.where(contacts: { id: nil }).or(base.where(inboxes: { id: nil }))
count = conversations.count
puts "Found #{count} conversations without a valid contact or inbox."
if count.positive?
print 'Do you want to delete these conversations? (y/N): '
confirm = $stdin.gets.strip.downcase
if %w[y yes].include?(confirm)
conversations.destroy_all
puts 'Conversations deleted.'
else
puts 'No conversations were deleted.'
end
end
end
end
end
+66
View File
@@ -1,5 +1,71 @@
/* eslint-disable no-restricted-globals, no-console */
/* globals clients */
const CACHE_NAME = 'vite-assets-cache-v1'; // Bump this to invalidate old caches
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
const CACHE_HEADER_NAME = 'x-woot-sw-cache-time';
async function addCacheTime(response) {
const headers = new Headers(response.headers);
headers.set(CACHE_HEADER_NAME, Date.now().toString());
const body = await response.blob();
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
function isFresh(response) {
const cachedTime = response.headers.get(CACHE_HEADER_NAME);
if (!cachedTime) return false;
return Date.now() - Number(cachedTime) < CACHE_TTL;
}
async function handleAssetRequest(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
if (cached && isFresh(cached)) {
return cached;
}
try {
const response = await fetch(request);
if (response.ok) {
const responseToCache = await addCacheTime(response);
// We naively cache everything - the browser will purge when needed
cache.put(request, responseToCache.clone());
return responseToCache;
}
return cached || response;
} catch {
return cached || new Response('', { status: 504, statusText: 'Gateway Timeout' });
}
}
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/vite/assets/')) {
event.respondWith(handleAssetRequest(event.request));
}
});
// Clean up old caches on activate
// To trigger a cleanup we can simply change the `CACHE_NAME`
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys
.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
)
)
);
self.clients.claim();
});
self.addEventListener('push', event => {
let notification = event.data && event.data.json();
+8
View File
@@ -0,0 +1,8 @@
require 'simplecov'
require 'simplecov_json_formatter'
# Configure SimpleCov to emit JSON for Qlty and HTML locally if needed
SimpleCov.formatter = SimpleCov::Formatter::JSONFormatter
SimpleCov.start 'rails' do
SimpleCov.coverage_dir 'coverage'
end
@@ -105,5 +105,29 @@ RSpec.describe Captain::Documents::CrawlJob, type: :job do
described_class.perform_now(document)
end
end
context 'when document is a PDF' do
let(:pdf_document) do
doc = create(:captain_document, external_link: 'https://example.com/document')
allow(doc).to receive(:pdf_document?).and_return(true)
allow(doc).to receive(:update!).and_return(true)
doc
end
it 'processes PDF using PdfProcessingService' do
pdf_service = instance_double(Captain::Llm::PdfProcessingService)
expect(Captain::Llm::PdfProcessingService).to receive(:new).with(pdf_document).and_return(pdf_service)
expect(pdf_service).to receive(:process)
expect(pdf_document).to receive(:update!).with(status: :available)
described_class.perform_now(pdf_document)
end
it 'handles PDF processing errors' do
allow(Captain::Llm::PdfProcessingService).to receive(:new).and_raise(StandardError, 'Processing failed')
expect { described_class.perform_now(pdf_document) }.to raise_error(StandardError, 'Processing failed')
end
end
end
end
@@ -64,5 +64,41 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
.with(spanish_document.content, 'portuguese')
end
end
context 'when processing a PDF document' do
let(:pdf_document) do
doc = create(:captain_document, assistant: assistant)
allow(doc).to receive(:pdf_document?).and_return(true)
allow(doc).to receive(:openai_file_id).and_return('file-123')
allow(doc).to receive(:update!).and_return(true)
allow(doc).to receive(:metadata).and_return({})
doc
end
let(:paginated_service) { instance_double(Captain::Llm::PaginatedFaqGeneratorService) }
let(:pdf_faqs) do
[{ 'question' => 'What is in the PDF?', 'answer' => 'Important content' }]
end
before do
allow(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new)
.with(pdf_document, anything)
.and_return(paginated_service)
allow(paginated_service).to receive(:generate).and_return(pdf_faqs)
allow(paginated_service).to receive(:total_pages_processed).and_return(10)
allow(paginated_service).to receive(:iterations_completed).and_return(1)
end
it 'uses paginated FAQ generator for PDFs' do
expect(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new).with(pdf_document, anything)
described_class.new.perform(pdf_document)
end
it 'stores pagination metadata' do
expect(pdf_document).to receive(:update!).with(hash_including(metadata: hash_including('faq_generation')))
described_class.new.perform(pdf_document)
end
end
end
end
+14 -5
View File
@@ -229,18 +229,27 @@ RSpec.describe Account, type: :model do
describe '#mark_for_deletion' do
it 'sets the marked_for_deletion_at and marked_for_deletion_reason attributes' do
expect do
account.mark_for_deletion('test_reason')
account.mark_for_deletion('inactivity')
end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(nil).to(be_present)
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('test_reason')
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('inactivity')
end
it 'sends a notification email to admin users' do
it 'sends a user-initiated deletion email when reason is manual_deletion' do
mailer = double
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
expect(mailer).to receive(:account_deletion).with(account, 'test_reason').and_return(mailer)
expect(mailer).to receive(:account_deletion_user_initiated).with(account, 'manual_deletion').and_return(mailer)
expect(mailer).to receive(:deliver_later)
account.mark_for_deletion('test_reason')
account.mark_for_deletion('manual_deletion')
end
it 'sends a system-initiated deletion email when reason is not manual_deletion' do
mailer = double
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
expect(mailer).to receive(:account_deletion_for_inactivity).with(account, 'inactivity').and_return(mailer)
expect(mailer).to receive(:deliver_later)
account.mark_for_deletion('inactivity')
end
it 'returns true when successful' do
@@ -0,0 +1,85 @@
require 'rails_helper'
RSpec.describe Captain::Document, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
describe 'PDF support' do
let(:pdf_document) do
doc = build(:captain_document, assistant: assistant, account: account)
doc.pdf_file.attach(
io: StringIO.new('PDF content'),
filename: 'test.pdf',
content_type: 'application/pdf'
)
doc
end
describe 'validations' do
it 'allows PDF file without external link' do
pdf_document.external_link = nil
expect(pdf_document).to be_valid
end
it 'validates PDF file size' do
doc = build(:captain_document, assistant: assistant, account: account)
doc.pdf_file.attach(
io: StringIO.new('x' * 11.megabytes),
filename: 'large.pdf',
content_type: 'application/pdf'
)
doc.external_link = nil
expect(doc).not_to be_valid
expect(doc.errors[:pdf_file]).to include(I18n.t('captain.documents.pdf_size_error'))
end
end
describe '#pdf_document?' do
it 'returns true for attached PDF' do
expect(pdf_document.pdf_document?).to be true
end
it 'returns true for .pdf external links' do
doc = build(:captain_document, external_link: 'https://example.com/document.pdf')
expect(doc.pdf_document?).to be true
end
it 'returns false for non-PDF documents' do
doc = build(:captain_document, external_link: 'https://example.com')
expect(doc.pdf_document?).to be false
end
end
describe '#display_url' do
it 'returns Rails blob URL for attached PDFs' do
pdf_document.save!
# The display_url method calls rails_blob_url which returns a URL containing 'rails/active_storage'
url = pdf_document.display_url
expect(url).to be_present
end
it 'returns external_link for web documents' do
doc = create(:captain_document, external_link: 'https://example.com')
expect(doc.display_url).to eq('https://example.com')
end
end
describe '#store_openai_file_id' do
it 'stores the file ID in metadata' do
pdf_document.save!
pdf_document.store_openai_file_id('file-abc123')
expect(pdf_document.reload.openai_file_id).to eq('file-abc123')
end
end
describe 'automatic external_link generation' do
it 'generates unique external_link for PDFs' do
pdf_document.external_link = nil
pdf_document.save!
expect(pdf_document.external_link).to start_with('PDF: test_')
end
end
end
end
@@ -0,0 +1,106 @@
require 'rails_helper'
require 'custom_exceptions/pdf_processing_error'
RSpec.describe Captain::Llm::PaginatedFaqGeneratorService do
let(:document) { create(:captain_document) }
let(:service) { described_class.new(document, pages_per_chunk: 5) }
let(:openai_client) { instance_double(OpenAI::Client) }
before do
# Mock OpenAI configuration
installation_config = instance_double(InstallationConfig, value: 'test-api-key')
allow(InstallationConfig).to receive(:find_by!)
.with(name: 'CAPTAIN_OPEN_AI_API_KEY')
.and_return(installation_config)
allow(OpenAI::Client).to receive(:new).and_return(openai_client)
end
describe '#generate' do
context 'when document lacks OpenAI file ID' do
before do
allow(document).to receive(:openai_file_id).and_return(nil)
end
it 'raises an error' do
expect { service.generate }.to raise_error(CustomExceptions::PdfFaqGenerationError)
end
end
context 'when generating FAQs from PDF pages' do
let(:faq_response) do
{
'choices' => [{
'message' => {
'content' => JSON.generate({
'faqs' => [
{ 'question' => 'What is this document about?', 'answer' => 'It explains key concepts.' }
],
'has_content' => true
})
}
}]
}
end
let(:empty_response) do
{
'choices' => [{
'message' => {
'content' => JSON.generate({
'faqs' => [],
'has_content' => false
})
}
}]
}
end
before do
allow(document).to receive(:openai_file_id).and_return('file-123')
end
it 'generates FAQs from paginated content' do
allow(openai_client).to receive(:chat).and_return(faq_response, empty_response)
faqs = service.generate
expect(faqs).to have_attributes(size: 1)
expect(faqs.first['question']).to eq('What is this document about?')
end
it 'stops when no more content' do
allow(openai_client).to receive(:chat).and_return(empty_response)
faqs = service.generate
expect(faqs).to be_empty
end
it 'respects max iterations limit' do
allow(openai_client).to receive(:chat).and_return(faq_response)
# Force max iterations
service.instance_variable_set(:@iterations_completed, 19)
service.generate
expect(service.iterations_completed).to eq(20)
end
end
end
describe '#should_continue_processing?' do
it 'stops at max iterations' do
service.instance_variable_set(:@iterations_completed, 20)
expect(service.should_continue_processing?(faqs: ['faq'], has_content: true)).to be false
end
it 'stops when no FAQs returned' do
expect(service.should_continue_processing?(faqs: [], has_content: true)).to be false
end
it 'continues when FAQs exist and under limits' do
expect(service.should_continue_processing?(faqs: ['faq'], has_content: true)).to be true
end
end
end
@@ -0,0 +1,58 @@
require 'rails_helper'
require 'custom_exceptions/pdf_processing_error'
RSpec.describe Captain::Llm::PdfProcessingService do
let(:document) { create(:captain_document) }
let(:service) { described_class.new(document) }
before do
# Mock OpenAI configuration
installation_config = instance_double(InstallationConfig, value: 'test-api-key')
allow(InstallationConfig).to receive(:find_by!)
.with(name: 'CAPTAIN_OPEN_AI_API_KEY')
.and_return(installation_config)
end
describe '#process' do
context 'when document already has OpenAI file ID' do
before do
allow(document).to receive(:openai_file_id).and_return('existing-file-id')
end
it 'skips upload' do
expect(document).not_to receive(:store_openai_file_id)
service.process
end
end
context 'when uploading PDF to OpenAI' do
let(:mock_client) { instance_double(OpenAI::Client) }
let(:pdf_content) { 'PDF content' }
before do
allow(document).to receive(:openai_file_id).and_return(nil)
# Use a simple double for ActiveStorage since it's a complex Rails object
pdf_file = double('pdf_file', download: pdf_content) # rubocop:disable RSpec/VerifiedDoubles
allow(document).to receive(:pdf_file).and_return(pdf_file)
allow(OpenAI::Client).to receive(:new).and_return(mock_client)
# Use a simple double for OpenAI::Files as it may not be loaded
files_api = double('files_api') # rubocop:disable RSpec/VerifiedDoubles
allow(files_api).to receive(:upload).and_return({ 'id' => 'file-abc123' })
allow(mock_client).to receive(:files).and_return(files_api)
end
it 'uploads PDF and stores file ID' do
expect(document).to receive(:store_openai_file_id).with('file-abc123')
service.process
end
it 'raises error when upload fails' do
allow(mock_client.files).to receive(:upload).and_return({ 'id' => nil })
expect { service.process }.to raise_error(CustomExceptions::PdfUploadError)
end
end
end
end
+32
View File
@@ -0,0 +1,32 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Arial >> >> >> /MediaBox [0 0 612 792] /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT
/F1 12 Tf
100 700 Td
(Sample PDF) Tj
ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000274 00000 n
trailer
<< /Size 5 /Root 1 0 R >>
startxref
362
%%EOF
+36
View File
@@ -291,4 +291,40 @@ describe PortalHelper do
end
end
end
describe '#generate_portal_brand_url' do
it 'builds URL with UTM params and referer host as source (happy path)' do
result = helper.generate_portal_brand_url('https://brand.example.com', 'https://app.chatwoot.com/some/page')
uri = URI.parse(result)
params = Rack::Utils.parse_query(uri.query)
expect(uri.scheme).to eq('https')
expect(uri.host).to eq('brand.example.com')
expect(params['utm_medium']).to eq('helpcenter')
expect(params['utm_campaign']).to eq('branding')
expect(params['utm_source']).to eq('app.chatwoot.com')
end
it 'returns utm string when brand_url is nil or empty' do
expect(helper.generate_portal_brand_url(nil,
'https://app.chatwoot.com')).to eq(
'?utm_campaign=branding&utm_medium=helpcenter&utm_source=app.chatwoot.com'
)
expect(helper.generate_portal_brand_url('',
'https://app.chatwoot.com')).to eq(
'?utm_campaign=branding&utm_medium=helpcenter&utm_source=app.chatwoot.com'
)
end
it 'omits utm_source when referer is nil or invalid' do
r1 = helper.generate_portal_brand_url('https://brand.example.com', nil)
p1 = Rack::Utils.parse_query(URI.parse(r1).query)
expect(p1.key?('utm_source')).to be(false)
r2 = helper.generate_portal_brand_url('https://brand.example.com', '::not-a-valid-url')
p2 = Rack::Utils.parse_query(URI.parse(r2).query)
expect(p2.key?('utm_source')).to be(false)
expect(p2['utm_medium']).to eq('helpcenter')
expect(p2['utm_campaign']).to eq('branding')
end
end
end
@@ -0,0 +1,93 @@
require 'rails_helper'
RSpec.describe Inboxes::BulkAutoAssignmentJob do
let(:account) { create(:account, custom_attributes: { 'plan_name' => 'Startups' }) }
let(:agent) { create(:user, account: account, role: :agent, auto_offline: false) }
let(:inbox) { create(:inbox, account: account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil, status: :open) }
let(:assignment_service) { double }
describe '#perform' do
before do
allow(assignment_service).to receive(:perform)
end
context 'when inbox has inbox members' do
before do
create(:inbox_member, user: agent, inbox: inbox)
account.enable_features!('assignment_v2')
inbox.update!(enable_auto_assignment: true)
end
it 'assigns unassigned conversations in enabled inboxes' do
allow(AutoAssignment::AgentAssignmentService).to receive(:new).with(
conversation: conversation,
allowed_agent_ids: [agent.id]
).and_return(assignment_service)
described_class.perform_now
expect(AutoAssignment::AgentAssignmentService).to have_received(:new).with(
conversation: conversation,
allowed_agent_ids: [agent.id]
)
end
it 'skips inboxes with auto assignment disabled' do
inbox.update!(enable_auto_assignment: false)
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
described_class.perform_now
expect(AutoAssignment::AgentAssignmentService).not_to have_received(:new).with(
conversation: conversation,
allowed_agent_ids: [agent.id]
)
end
context 'when account is on default plan in chatwoot cloud' do
before do
account.update!(custom_attributes: {})
InstallationConfig.create(name: 'CHATWOOT_CLOUD_PLANS', value: [{ 'name' => 'default' }])
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
end
it 'skips auto assignment' do
allow(Rails.logger).to receive(:info)
expect(Rails.logger).to receive(:info).with("Skipping auto assignment for account #{account.id}")
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
described_class.perform_now
end
end
end
context 'when inbox has no members' do
before do
account.enable_features!('assignment_v2')
inbox.update!(enable_auto_assignment: true)
end
it 'does not assign conversations' do
allow(Rails.logger).to receive(:info)
expect(Rails.logger).to receive(:info).with("No agents available to assign conversation to inbox #{inbox.id}")
described_class.perform_now
end
end
context 'when assignment_v2 feature is disabled' do
before do
account.disable_features!('assignment_v2')
end
it 'skips auto assignment' do
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
described_class.perform_now
end
end
end
end
@@ -1,116 +1,46 @@
require 'rails_helper'
require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb'
RSpec.describe AdministratorNotifications::AccountNotificationMailer do
include_context 'with smtp config'
let(:account) { create(:account, name: 'Test Account') }
let(:mailer) { described_class.with(account: account) }
let(:class_instance) { described_class.new }
let!(:account) { create(:account) }
let!(:admin) { create(:user, account: account, role: :administrator) }
before do
allow(described_class).to receive(:new).and_return(class_instance)
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
account.custom_attributes['marked_for_deletion_at'] = 7.days.from_now.iso8601
account.save!
end
describe 'account_deletion' do
let(:reason) { 'manual_deletion' }
let(:mail) { described_class.with(account: account).account_deletion(account, reason) }
let(:deletion_date) { 7.days.from_now.iso8601 }
before do
account.update!(custom_attributes: {
'marked_for_deletion_at' => deletion_date,
'marked_for_deletion_reason' => reason
})
end
it 'renders the subject' do
expect(mail.subject).to eq('Your account has been marked for deletion')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
it 'includes the account name in the email body' do
expect(mail.body.encoded).to include(account.name)
end
it 'includes the deletion date in the email body' do
expect(mail.body.encoded).to include(deletion_date)
end
it 'includes a link to cancel the deletion' do
expect(mail.body.encoded).to include('Cancel Account Deletion')
end
context 'when reason is manual_deletion' do
it 'includes the administrator message' do
expect(mail.body.encoded).to include('This action was requested by one of the administrators of your account')
end
end
context 'when reason is not manual_deletion' do
let(:reason) { 'inactivity' }
it 'includes the reason directly' do
expect(mail.body.encoded).to include('Reason for deletion: inactivity')
end
describe '#account_deletion_user_initiated' do
it 'sets the correct subject for user-initiated deletion' do
mail = mailer.account_deletion_user_initiated(account, 'manual_deletion')
expect(mail.subject).to eq('Your Chatwoot account deletion has been scheduled')
end
end
describe 'contact_import_complete' do
let!(:data_import) { build(:data_import, total_records: 10, processed_records: 8) }
let(:mail) { described_class.with(account: account).contact_import_complete(data_import).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Contact Import Completed')
end
it 'renders the processed records' do
expect(mail.body.encoded).to include('Number of records imported: 8')
expect(mail.body.encoded).to include('Number of records failed: 2')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
describe '#account_deletion_for_inactivity' do
it 'sets the correct subject for system-initiated deletion' do
mail = mailer.account_deletion_for_inactivity(account, 'Account Inactive')
expect(mail.subject).to eq('Your Chatwoot account is scheduled for deletion due to inactivity')
end
end
describe 'contact_import_failed' do
let(:mail) { described_class.with(account: account).contact_import_failed.deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Contact Import Failed')
describe '#format_deletion_date' do
it 'formats a valid date string' do
date_str = '2024-12-31T23:59:59Z'
formatted = described_class.new.send(:format_deletion_date, date_str)
expect(formatted).to eq('December 31, 2024')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
end
describe 'contact_export_complete' do
let!(:file_url) { 'http://test.com/test' }
let(:mail) { described_class.with(account: account).contact_export_complete(file_url, admin.email).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("Your contact's export file is available to download.")
it 'handles blank dates' do
formatted = described_class.new.send(:format_deletion_date, nil)
expect(formatted).to eq('Unknown')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
end
describe 'automation_rule_disabled' do
let(:rule) { instance_double(AutomationRule, name: 'Test Rule') }
let(:mail) { described_class.with(account: account).automation_rule_disabled(rule).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Automation rule disabled due to validation errors.')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
it 'includes the rule name in the email body' do
expect(mail.body.encoded).to include('Test Rule')
it 'handles invalid dates' do
formatted = described_class.new.send(:format_deletion_date, 'invalid-date')
expect(formatted).to eq('Unknown')
end
end
end
+53
View File
@@ -613,4 +613,57 @@ RSpec.describe Message do
end
end
end
describe '#should_index?' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:message) { create(:message, conversation: conversation, account: account) }
before do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
account.enable_features('advanced_search')
end
context 'when advanced search is not allowed globally' do
before do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
end
it 'returns false' do
expect(message.should_index?).to be false
end
end
context 'when advanced search feature is not enabled for account' do
before do
account.disable_features('advanced_search')
end
it 'returns false' do
expect(message.should_index?).to be false
end
end
context 'when message type is not incoming or outgoing' do
before do
message.message_type = 'activity'
end
it 'returns false' do
expect(message.should_index?).to be false
end
end
context 'when all conditions are met' do
it 'returns true for incoming message' do
message.message_type = 'incoming'
expect(message.should_index?).to be true
end
it 'returns true for outgoing message' do
message.message_type = 'outgoing'
expect(message.should_index?).to be true
end
end
end
end
-2
View File
@@ -1,7 +1,5 @@
require 'simplecov'
require 'webmock/rspec'
SimpleCov.start 'rails'
WebMock.disable_net_connect!(allow_localhost: true)
RSpec.configure do |config|
-2
View File
@@ -1,6 +1,4 @@
ENV['RAILS_ENV'] ||= 'test'
require 'simplecov'
SimpleCov.start 'rails'
require File.expand_path('../config/environment', __dir__)
require 'rails/test_help'