Compare commits

..
Author SHA1 Message Date
Muhsin KelothandGitHub d98eae0b2d Merge branch 'feat/whatsapp-embedded-signup' into feat/whatsapp-es-reauth-flow 2025-07-10 12:02:21 +05:30
Muhsin KelothandGitHub e86f9c3005 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-07-10 12:00:33 +05:30
66ce599e4e feat: Whatsapp es reauth flow fe (#11915)
# Pull Request Template

## Description

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

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

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


## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-10 11:52:25 +05:30
Muhsin KelothandGitHub 17500cc62d chore: Auto assign PR to author when PR opened (#11890)
- gh action to auto-assign PR to author when PR opened
2025-07-10 11:36:37 +05:30
Muhsin KelothandGitHub b9d3a34a73 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-07-10 10:03:46 +05:30
Tanmay Deep Sharma 50d023caed remove extra committed file 2025-07-10 09:06:05 +07:00
Tanmay Deep Sharma 9007bf1ecf feat: Add WhatsApp reauthorization bankend flow 2025-07-10 03:09:16 +07:00
Sivin VargheseandGitHub 5140deb6f6 feat: Captain settings header component (#11912) 2025-07-09 22:21:25 +05:30
Sivin VargheseandGitHub 3e5ca9bca9 fix: Widget message input resize issue (#11896) 2025-07-09 09:23:20 +05:30
Tanmay Deep SharmaandGitHub 358ec8120b Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-07-09 10:01:37 +07:00
5ebe8c71ec feat: Support customizable welcome text, availability messages, and UI toggles (#11891)
# Pull Request Template

## Description

This PR allows users to dynamically pass custom welcome and availability
messages, along with UI feature toggles, via `window.chatwootSettings`.
If any of the following settings are provided, the widget will use them;
otherwise, it falls back to default behavior.

**New options:**
```
window.chatwootSettings = {
  welcomeTitle: 'Need help?',                        // Custom widget title
  welcomeDescription: 'We’re here to support you.',        // Subtitle in the header
  availableMessage: 'We’re online and ready to chat!', // Shown when team is online
  unavailableMessage: 'We’re currently offline.',      // Shown when team is unavailable

  enableFileUpload: true,          // Enable file attachments
  enableEmojiPicker: true,         // Enable emoji picker in chat input
  enableEndConversation: true     // Allow users to end the conversation
}
```


Fixes
https://linear.app/chatwoot/issue/CW-4589/add-options-to-windowchatwootsettings

## Type of change

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

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/413fc4aa59384366b071450bd19d1bf8?sid=ff30fb4c-267c-4beb-80ab-d6f583aa960d

## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-08 14:26:00 -07:00
Muhsin KelothandGitHub c73f33619d Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-07-08 17:32:22 +05:30
Sivin VargheseandGitHub 8c78573d9d chore: Remove defer attribute from widget-loader script (#11887) 2025-07-08 15:31:33 +05:30
c553997af8 fix: Translate Priority and Messages types in Automations and Macros (#11741)
# Pull Request Template

## Description

With these fixes, I could improve some translations in portuguese, and
also I added some improvements to make some drowpdown values, that were
not translatable into translatable strings, into the Automations, Macros
and Custom Attributes page. I also fixed some typos.

Here are the main improvements.

- ~Fixed typo in portuguese into `Reports > Agents` page:~
~Before:

![image](https://github.com/user-attachments/assets/5a911108-76a6-4e54-82f1-1185c3e1981b)
After:

![image](https://github.com/user-attachments/assets/2a4fc5bf-3239-47b5-9113-ba66e0f44b9c)~

- Added the possibility to make the `Priority` and `Message types`
translatables in other languages, into Macros and Automations page. Also
added the same feature for Custom attributes page at `applies to` and
`type` fields:
Before:

![image](https://github.com/user-attachments/assets/d53b3e6d-be3e-4e02-9478-7d3121cc11cb)

![image](https://github.com/user-attachments/assets/28a7bffe-fe8b-43f6-8e30-9d62ab8adf14)

![image](https://github.com/user-attachments/assets/18a983c1-2db8-445d-a4cc-982beb88015a)

![image](https://github.com/user-attachments/assets/2be8b0f2-ed9e-4bac-aeb0-596533200da4)
After:

![image](https://github.com/user-attachments/assets/0fa904ae-7b48-4ce4-afb8-e0586c5624b7)

![image](https://github.com/user-attachments/assets/a524f119-9222-4e98-9cd7-2fca3303e8d5)

![image](https://github.com/user-attachments/assets/7062f277-e9c9-4473-980b-6ca2d6bdcefc)

![image](https://github.com/user-attachments/assets/0bb66f07-ee10-4833-b950-b9aa9441a312)

- ~Improve Bots page. In the Brazilian portuguese is very common and
widely used bots to refer to chatbots, using `robô` as a direct
translations sounds weird, `robô` is used more often when we are talking
about robots, not chatbots.
Before:

![image](https://github.com/user-attachments/assets/3966cc11-51f4-4e1a-bc81-ada2795408e8)
After:

![image](https://github.com/user-attachments/assets/c9ec0ad5-974e-40d9-9542-031db99839e2)~

- Added multiselect both `no options` and `Select` placeholder
translatable strings:
Before:

![image](https://github.com/user-attachments/assets/545641d4-87ae-4305-8adc-3b73bbaf2ab1)

![image](https://github.com/user-attachments/assets/8800c001-abe7-41bb-bd68-feb5db13b8f0)
After:

![image](https://github.com/user-attachments/assets/46748652-28f2-4ae3-9d20-55db1015aaae)

- Added `.pnpm-store` to `.gitignore`, when I'm using docker, the pnpm
always creates this folder into my root directory, so I imagine the same
could happens with others, so I fixed it.


## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

I've added some translations for my language (brazilian portuguese), so
i just switched the languages between the original in EN to PT_BR.

## Checklist:

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

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-08 12:40:40 +05:30
Tanmay Deep Sharma c8f0cbbfa1 Merge remote-tracking branch 'origin/develop' into feat/whatsapp-embedded-signup 2025-07-08 11:56:52 +07:00
Shivam MishraandGitHub 30a3a35281 feat: remove colon and semicolons when sanitizing inbox name (#11889) 2025-07-08 09:41:40 +05:30
Muhsin KelothandGitHub 340fcb6152 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-26 11:08:43 +05:30
Sivin VargheseandGitHub cacd192714 Update app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue 2025-06-12 22:57:15 +05:30
iamsivin b531dd0461 chore: Update png 2025-06-12 22:37:44 +05:30
iamsivin cb3721a425 chore: Fix styles 2025-06-12 22:25:59 +05:30
Muhsin KelothandGitHub 23ed1cc17f Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-12 11:28:41 +05:30
Muhsin Keloth d32d47b41b chore: hide webhook details for ES 2025-06-12 11:27:52 +05:30
Muhsin Keloth a1a3f62d1b Update inboxMgmt.json 2025-06-11 17:51:09 +05:30
Muhsin Keloth ccff83e253 chore: remove unused variables 2025-06-11 17:46:48 +05:30
Muhsin Keloth fdbf8b52f3 chore: revert codeclimate.yml 2025-06-11 17:23:43 +05:30
Muhsin Keloth 8544c87cfd Update .codeclimate.yml 2025-06-11 17:19:51 +05:30
Muhsin Keloth b04ae3e251 Update .codeclimate.yml 2025-06-11 17:13:52 +05:30
Muhsin Keloth f0f3e615b3 fix: complex logical expression 2025-06-11 17:12:52 +05:30
Tanmay Deep Sharma a1ce805fa5 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-06-11 15:39:40 +05:30
Tanmay Deep Sharma e326a7a8c8 revert the linting of db file 2025-06-11 15:39:04 +05:30
Muhsin Keloth be8a441bfc chore: revert the changes 2025-06-11 15:26:35 +05:30
Muhsin Keloth 212f07711d Update .codeclimate.yml 2025-06-11 15:21:19 +05:30
Muhsin Keloth e6012d7e86 Update .codeclimate.yml 2025-06-11 15:21:06 +05:30
Muhsin Keloth 9d86792468 chore: final fixes 2025-06-11 15:20:41 +05:30
Muhsin Keloth 6e1c3ee176 chore: add more helpers 2025-06-11 15:07:43 +05:30
Muhsin Keloth 2f8214eb87 chore: more fixes 2025-06-11 15:01:53 +05:30
Muhsin Keloth 48293001ed chore: fix issues 2025-06-11 14:52:48 +05:30
Muhsin Keloth 83cd7b6788 Merge branch 'feat/whatsapp-embedded-signup' of https://github.com/chatwoot/chatwoot into feat/whatsapp-embedded-signup 2025-06-11 14:48:32 +05:30
Muhsin Keloth 65bb584f48 chore: more code cleanups 2025-06-11 14:48:15 +05:30
Muhsin KelothandGitHub 9ad1b219a3 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-11 14:41:20 +05:30
Muhsin Keloth f812ca3b99 fix: code complexity issues 2025-06-11 14:40:16 +05:30
Muhsin KelothandGitHub c6df54cc29 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-11 14:24:30 +05:30
Muhsin Keloth 384e616c1b Merge branch 'feat/whatsapp-embedded-signup' of https://github.com/chatwoot/chatwoot into feat/whatsapp-embedded-signup 2025-06-11 14:22:58 +05:30
Tanmay Deep Sharma 34a2eff135 make whatsapp embedded signup api version dynamic 2025-06-11 13:00:19 +05:30
Muhsin KelothandTanmay Deep Sharma 1c09cc5aa5 chore: design fixes 2025-06-11 12:30:48 +05:30
Muhsin Keloth 5bf5a0e0d0 chore: design fixes 2025-06-11 10:12:57 +05:30
Muhsin Keloth e3d76fa622 chore: add composable 2025-06-11 00:12:53 +05:30
Tanmay Deep Sharma 4f123e68a8 add spec for embedded signup 2025-06-10 20:31:24 +05:30
Muhsin KelothandGitHub 27284dd69e Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-10 20:11:07 +05:30
Muhsin Keloth a5321376e7 chore: front end improvments 2025-06-10 16:06:22 +05:30
Muhsin Keloth 20f63cf957 chore: make WhatsApp component composition styles 2025-06-10 15:04:14 +05:30
Muhsin Keloth 712054a8b7 Update WhatsappEmbeddedSignup.vue 2025-06-10 14:48:22 +05:30
Muhsin KelothandGitHub 7a4dfd5d4b Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-10 14:29:55 +05:30
Tanmay Deep Sharma a17b7815e3 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-06-10 14:23:42 +05:30
Tanmay Deep Sharma 8025cd5aab sanitise the phone number before inserting to the database 2025-06-10 14:23:17 +05:30
Muhsin Keloth 7319d822fd chore: minor fixes 2025-06-10 09:54:42 +05:30
Muhsin Keloth 23a06739fc Update WhatsappEmbeddedSignup.vue 2025-06-09 16:00:35 +05:30
Muhsin Keloth 0771d0f269 chore: style fixes 2025-06-09 15:54:58 +05:30
Muhsin Keloth eb6c4a258a chore: code cleanups 2025-06-09 14:53:35 +05:30
Tanmay Deep Sharma 317a8ee4d5 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-06-09 10:26:22 +05:30
Tanmay Deep Sharma 4a7a68e6e7 fix: update WhatsApp GlobalConfigService.load calls 2025-06-09 10:24:26 +05:30
Tanmay Deep SharmaandGitHub 4c21a8d7b4 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-09 09:47:18 +05:30
Tanmay Deep Sharma 71ddd6c2c2 update the callback url, dynamically 2025-06-09 02:38:19 +05:30
Tanmay Deep Sharma 34852fccc1 update step four content in case of embedded signup 2025-06-09 02:35:13 +05:30
Tanmay Deep Sharma 149085e3d8 add webhook registration to embedded signup 2025-06-09 02:08:32 +05:30
Tanmay Deep Sharma e0b52c3fc2 add webhook registration page as well 2025-06-09 02:02:21 +05:30
Tanmay Deep Sharma 806ddaa9ab remove developer docs files 2025-06-08 18:17:13 +05:30
Tanmay Deep Sharma f24514433b register phone number on cloud, for sending messages 2025-06-08 16:33:14 +05:30
Tanmay Deep Sharma 700ce0540a Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-06-08 16:16:32 +05:30
Tanmay Deep Sharma 7cc082e586 incorporate the new UI designs for whatsapp inbox 2025-06-08 16:11:23 +05:30
Tanmay Deep Sharma 392fa50aee Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-06 13:34:39 +05:30
Muhsin KelothandGitHub 3582d47299 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-05-29 17:21:39 +05:30
Muhsin KelothandGitHub 06e4708e12 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-05-29 13:27:39 +05:30
Tanmay Deep Sharma 9e40a79958 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-05-29 13:05:15 +05:30
Tanmay Deep Sharma 947e4b59e8 remove manual whastapp registeration 2025-05-29 13:02:49 +05:30
Muhsin KelothandGitHub 8816e8651b Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-05-29 10:20:04 +05:30
Tanmay Deep Sharma f429db89b4 add mintlify intro docs 2025-05-28 23:42:34 +05:30
Tanmay Deep Sharma 203c010846 Merge remote-tracking branch 'origin/develop' into feat/whatsapp-embedded-signup 2025-05-28 22:23:06 +05:30
Tanmay Deep Sharma e1fdc30c31 fix circleci checks 2025-05-28 20:59:08 +05:30
Tanmay Deep Sharma 1eca659b93 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-05-28 20:23:12 +05:30
Tanmay Deep Sharma 7a70cea36b fix circleci checks 2025-05-28 20:22:37 +05:30
Tanmay Deep SharmaGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
7c4e8346f8 Potential fix for code scanning alert no. 97: Incomplete URL substring sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-28 20:22:03 +05:30
Tanmay Deep SharmaGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
e4cd585513 Potential fix for code scanning alert no. 95: Clear-text storage of sensitive information
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-28 20:20:37 +05:30
Tanmay Deep Sharma 9e5e90c258 remove un-necessary changes 2025-05-28 20:10:56 +05:30
Tanmay Deep Sharma c8a66b9ebb feat: add Whatsapp embedded signup API 2025-05-28 20:05:57 +05:30
Muhsin Keloth 454686ab5d chore: add front end changes 2025-05-19 14:12:43 +05:30
99 changed files with 2744 additions and 3013 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Auto-assign PR to Author
on:
pull_request:
types: [opened]
jobs:
auto-assign:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Auto-assign PR to author
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const pull_number = context.payload.pull_request.number;
const author = context.payload.pull_request.user.login;
await github.rest.issues.addAssignees({
owner,
repo,
issue_number: pull_number,
assignees: [author]
});
console.log(`Assigned PR #${pull_number} to ${author}`);
-2
View File
@@ -174,8 +174,6 @@ gem 'neighbor'
gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
# PDF text extraction
gem 'pdf-reader', '~> 2.0'
gem 'iso-639'
gem 'ruby-openai'
+1 -14
View File
@@ -25,7 +25,6 @@ GIT
GEM
remote: https://rubygems.org/
specs:
Ascii85 (2.0.1)
actioncable (7.1.5.1)
actionpack (= 7.1.5.1)
activesupport (= 7.1.5.1)
@@ -127,7 +126,6 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
afm (0.2.2)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
@@ -367,7 +365,6 @@ GEM
hana (1.3.7)
hash_diff (1.1.1)
hashdiff (1.1.0)
hashery (2.1.2)
hashie (5.0.0)
html2text (0.4.0)
nokogiri (>= 1.0, < 2.0)
@@ -557,12 +554,6 @@ GEM
parser (3.3.8.0)
ast (~> 2.4.1)
racc
pdf-reader (2.14.1)
Ascii85 (>= 1.0, < 3.0, != 2.0.0)
afm (~> 0.2.1)
hashery (~> 2.0)
ruby-rc4
ttfunk
pg (1.5.3)
pg_search (2.3.6)
activerecord (>= 5.2)
@@ -720,7 +711,6 @@ GEM
faraday (>= 1)
faraday-multipart (>= 1)
ruby-progressbar (1.13.0)
ruby-rc4 (0.1.5)
ruby-vips (2.1.4)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
@@ -829,8 +819,6 @@ GEM
i18n
timeout (0.4.3)
trailblazer-option (0.1.2)
ttfunk (1.8.0)
bigdecimal (~> 3.1)
twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, < 3.0)
@@ -975,7 +963,6 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
pdf-reader (~> 2.0)
pg
pg_search
pgvector
@@ -1037,4 +1024,4 @@ RUBY VERSION
ruby 3.4.4p34
BUNDLED WITH
2.6.7
2.5.16
+2
View File
@@ -67,6 +67,8 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
@@ -39,8 +39,9 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
@@ -0,0 +1,125 @@
class Whatsapp::EmbeddedController < ApplicationController
include EnsureCurrentAccountHelper
before_action :authenticate_user!
before_action :current_account
before_action :check_authorization
def new
# Configuration endpoint for embedded signup initialization
render json: {
status: 'ready',
app_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
config_id: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
app_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
}
end
def embedded_signup
# Complete embedded signup using auth code + business info from frontend
validate_authorization_code!
return if performed?
validate_required_parameters!
return if performed?
channel = process_signup
@inbox = channel.inbox
# Return the inbox object using the standard jbuilder template
render 'embedded_signup', formats: [:json]
rescue StandardError => e
handle_signup_error(e)
end
def reauthorize
# Reauthorize existing WhatsApp inbox using embedded signup flow
validate_authorization_code!
return if performed?
validate_required_parameters!
return if performed?
validate_inbox_id!
return if performed?
channel = process_reauthorization
@inbox = channel.inbox
# Clear reauthorization required flag
channel.reauthorized!
render json: { message: 'WhatsApp channel reauthorized successfully' }, status: :ok
rescue StandardError => e
handle_signup_error(e)
end
private
def validate_authorization_code!
return if params[:code].present?
render json: {
error: 'Missing authorization code',
message: 'Authorization code is required for embedded signup'
}, status: :bad_request
end
def validate_required_parameters!
return if params[:business_id].present? && params[:waba_id].present?
render json: {
error: 'Missing required parameters: business_id and waba_id are required'
}, status: :bad_request
end
def validate_inbox_id!
return if params[:inbox_id].present?
render json: {
error: 'Missing inbox_id parameter'
}, status: :bad_request
end
def process_signup
service = Whatsapp::EmbeddedSignupService.new(
{
account: Current.account,
code: params[:code],
business_id: params[:business_id],
waba_id: params[:waba_id],
phone_number_id: params[:phone_number_id]
}
)
service.perform
end
def process_reauthorization
service = Whatsapp::EmbeddedSignupService.new(
{
account: Current.account,
code: params[:code],
business_id: params[:business_id],
waba_id: params[:waba_id],
phone_number_id: params[:phone_number_id],
inbox_id: params[:inbox_id]
}
)
service.perform_reauthorization
end
def handle_signup_error(error)
Rails.logger.error("WhatsApp embedded signup processing error: #{error.message}")
Rails.logger.error(error.backtrace.join("\n"))
render json: {
error: 'Internal server error',
message: error.message
}, status: :internal_server_error
end
def check_authorization
authorize(Current.account, :update?)
end
end
@@ -15,14 +15,6 @@ class CaptainDocument extends ApiClient {
},
});
}
uploadPdf(formData) {
return axios.post(`${this.url}/upload_pdf`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
}
export default new CaptainDocument();
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -2,43 +2,28 @@
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DocumentForm from './DocumentForm.vue';
const emit = defineEmits(['close', 'success']);
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const documentForm = ref(null);
const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
const handleSubmit = async newDocument => {
try {
if (newDocument.type === 'pdf') {
// Handle PDF upload
const formData = new FormData();
formData.append('pdf_document', newDocument.pdf_document);
formData.append('assistant_id', newDocument.assistant_id);
await store.dispatch('captainDocuments/uploadPdf', formData);
useAlert('PDF uploaded successfully! Processing will begin shortly.');
} else {
// Handle URL creation
await store.dispatch('captainDocuments/create', {
document: {
external_link: newDocument.external_link,
assistant_id: newDocument.assistant_id,
},
});
useAlert('Document created successfully!');
}
// Emit success event to refresh the list
emit('success');
await store.dispatch('captainDocuments/create', newDocument);
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage =
error?.response?.data?.message ||
'Failed to create document. Please try again.';
error?.response?.message || t(`${i18nKey}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
@@ -57,8 +42,8 @@ defineExpose({ dialogRef });
<template>
<Dialog
ref="dialogRef"
:title="$t('CAPTAIN.DOCUMENTS.CREATE.TITLE')"
:description="$t('CAPTAIN.DOCUMENTS.CREATE.DESCRIPTION')"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.DOCUMENTS.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
@@ -1,5 +1,6 @@
<script setup>
import { reactive, computed, ref } from 'vue';
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, url } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
@@ -7,36 +8,27 @@ import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import FileUpload from 'dashboard/components-next/fileUpload/FileUpload.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainDocuments/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
};
const initialState = {
url: '',
name: '',
assistantId: null,
selectedFile: null,
sourceType: 'url', // 'url' or 'pdf'
};
const state = reactive({ ...initialState });
const uploadError = ref('');
const validationRules = computed(() => {
const rules = {
assistantId: { required },
};
if (state.sourceType === 'url') {
rules.url = { required, url, minLength: minLength(1) };
}
return rules;
});
const validationRules = {
url: { required, url, minLength: minLength(1) },
assistantId: { required },
};
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
@@ -49,258 +41,70 @@ const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = (field, errorMessage) => {
return v$.value[field]?.$error ? errorMessage : '';
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.DOCUMENTS.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
url: getErrorMessage('url', 'Please enter a valid URL'),
assistantId: getErrorMessage('assistantId', 'Please select an assistant'),
url: getErrorMessage('url', 'URL'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
}));
const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => {
if (state.sourceType === 'pdf') {
return {
type: 'pdf',
pdf_document: state.selectedFile,
assistant_id: state.assistantId,
};
}
return {
type: 'url',
external_link: state.url,
assistant_id: state.assistantId,
};
};
const prepareDocumentDetails = () => ({
external_link: state.url,
assistant_id: state.assistantId,
});
const handleSubmit = async () => {
uploadError.value = '';
// Validate form
const isFormValid = await v$.value.$validate();
if (!isFormValid) {
return;
}
// For PDF uploads, check if file is selected
if (state.sourceType === 'pdf' && !state.selectedFile) {
uploadError.value = 'Please select a PDF file to upload.';
return;
}
emit('submit', prepareDocumentDetails());
};
// PDF Upload handlers
const handleFileSelected = file => {
state.selectedFile = file;
uploadError.value = '';
};
const handleFileError = error => {
uploadError.value = error;
state.selectedFile = null;
};
const clearSelectedFile = () => {
state.selectedFile = null;
uploadError.value = '';
};
const handleSourceTypeChange = type => {
state.sourceType = type;
uploadError.value = '';
state.selectedFile = null;
state.url = '';
};
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<!-- Source Type Selection -->
<div class="flex flex-col gap-2">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.DOCUMENTS.CREATE.FORM.SOURCE_TYPE.LABEL') }}
</label>
<div class="flex gap-2">
<button
type="button"
class="flex-1 p-3 rounded-lg bg-n-alpha-2 hover:border-n-slate-7 transition-colors focus:outline-none"
:class="{
'ring-2 ring-woot-500 text-woot-500 border-0':
state.sourceType === 'url',
'border-2 border-n-slate-7 text-n-slate-11':
state.sourceType !== 'url',
}"
@click="handleSourceTypeChange('url')"
>
<div class="flex items-center justify-center gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.9 12C3.9 10.29 5.29 8.9 7 8.9H11V7H7C4.24 7 2 9.24 2 12S4.24 17 7 17H11V15.1H7C5.29 15.1 3.9 13.71 3.9 12ZM8 13H16V11H8V13ZM17 7H13V8.9H17C18.71 8.9 20.1 10.29 20.1 12S18.71 15.1 17 15.1H13V17H17C19.76 17 22 14.76 22 12S19.76 7 17 7Z"
fill="currentColor"
/>
</svg>
<span class="text-sm font-medium">{{
$t('CAPTAIN.DOCUMENTS.CREATE.FORM.SOURCE_TYPE.WEBSITE_URL')
}}</span>
</div>
</button>
<button
type="button"
class="flex-1 p-3 rounded-lg bg-n-alpha-2 hover:border-n-slate-7 transition-colors focus:outline-none"
:class="{
'ring-2 ring-woot-500 text-woot-500 border-0':
state.sourceType === 'pdf',
'border-2 border-n-slate-7 text-n-slate-11':
state.sourceType !== 'pdf',
}"
@click="handleSourceTypeChange('pdf')"
>
<div class="flex items-center justify-center gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
fill="currentColor"
/>
</svg>
<span class="text-sm font-medium">{{
$t('CAPTAIN.DOCUMENTS.CREATE.FORM.SOURCE_TYPE.PDF_UPLOAD')
}}</span>
</div>
</button>
</div>
</div>
<!-- URL Input (when URL is selected) -->
<div v-if="state.sourceType === 'url'">
<Input
v-model="state.url"
label="Website URL"
placeholder="Enter the website URL to crawl"
:message="formErrors.url"
:message-type="formErrors.url ? 'error' : 'info'"
/>
</div>
<!-- PDF Upload (when PDF is selected) -->
<div v-if="state.sourceType === 'pdf'" class="flex flex-col gap-2">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.DOCUMENTS.CREATE.FORM.PDF_UPLOAD.LABEL') }}
</label>
<!-- File upload area -->
<div v-if="!state.selectedFile">
<FileUpload
accept="application/pdf"
:max-size-m-b="25"
placeholder="Select a PDF file"
upload-text="Click to select PDF or drag and drop"
drag-text="Drop PDF file here"
@file-selected="handleFileSelected"
@file-error="handleFileError"
/>
</div>
<!-- Selected file display -->
<div v-else class="p-3 bg-n-alpha-2 rounded-lg border border-n-slate-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="text-red-600"
>
<path
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
fill="currentColor"
/>
</svg>
<div>
<p class="text-sm font-medium text-n-slate-12">
{{ state.selectedFile.name }}
</p>
<p class="text-xs text-n-slate-8">
{{
Math.round((state.selectedFile.size / 1024 / 1024) * 100) /
100
}}
{{ $t('GENERAL.FILE_SIZE.MB') }}
</p>
</div>
</div>
<button
type="button"
class="p-1 rounded hover:bg-n-alpha-3 text-n-slate-8 hover:text-n-slate-10"
@click="clearSelectedFile"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<!-- Upload error -->
<p v-if="uploadError" class="text-sm text-red-600">{{ uploadError }}</p>
</div>
<!-- Assistant Selection -->
<Input
v-model="state.url"
:label="t('CAPTAIN.DOCUMENTS.FORM.URL.LABEL')"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.URL.PLACEHOLDER')"
:message="formErrors.url"
:message-type="formErrors.url ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.DOCUMENTS.CREATE.FORM.ASSISTANT.LABEL') }}
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
:options="assistantList"
:has-error="!!formErrors.assistantId"
placeholder="Choose an assistant"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
:message="formErrors.assistantId"
/>
</div>
<!-- Action Buttons -->
<div class="flex items-center justify-between w-full gap-3">
<Button
type="button"
variant="faded"
color="slate"
label="Cancel"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
label="Create Document"
:label="t('CAPTAIN.FORM.CREATE')"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"
@@ -0,0 +1,28 @@
<script setup>
import SettingsHeader from './SettingsHeader.vue';
</script>
<template>
<Story
title="Captain/PageComponents/SettingsHeader"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default">
<div class="px-6 py-4 bg-n-background">
<SettingsHeader
heading="General Settings"
description="Configure general preferences for your workspace."
/>
</div>
</Variant>
<Variant title="Long Description">
<div class="px-6 py-4 bg-n-background">
<SettingsHeader
heading="Integrations"
description="Manage and configure third-party integrations such as Slack, Zapier, and Webhooks to enhance your workflow."
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,19 @@
<script setup>
defineProps({
heading: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
});
</script>
<template>
<header class="flex flex-col items-start gap-2">
<h2 class="text-n-slate-12 text-base font-medium">{{ heading }}</h2>
<p class="text-n-slate-11 text-sm">{{ description }}</p>
</header>
</template>
@@ -1,202 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
accept: {
type: String,
default: '*',
},
multiple: {
type: Boolean,
default: false,
},
maxSizeMB: {
type: Number,
default: 10,
},
disabled: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
uploadText: {
type: String,
default: '',
},
dragText: {
type: String,
default: '',
},
});
const emit = defineEmits(['fileSelected', 'fileError']);
const fileInput = ref(null);
const isDragOver = ref(false);
const acceptTypes = computed(() => props.accept);
const maxSize = computed(() => props.maxSizeMB * 1024 * 1024);
const handleFileClick = () => {
if (!props.disabled) {
fileInput.value?.click();
}
};
const validateFile = file => {
// Check file size
if (file.size > maxSize.value) {
const errorMsg = `File "${file.name}" is too large. Maximum size is ${props.maxSizeMB}MB.`;
emit('fileError', errorMsg);
return false;
}
// Check file type if specified
if (
props.accept !== '*' &&
!file.type.match(new RegExp(props.accept.replace(/\*/g, '.*')))
) {
const errorMsg = `File type not supported. Only ${props.accept} files are allowed.`;
emit('fileError', errorMsg);
return false;
}
return true;
};
const handleFileChange = event => {
const files = event.target.files;
if (files && files.length > 0) {
const file = files[0];
if (validateFile(file)) {
emit('fileSelected', file);
}
}
// Reset input value to allow selecting the same file again
event.target.value = '';
};
const handleDragOver = event => {
event.preventDefault();
if (!props.disabled) {
isDragOver.value = true;
}
};
const handleDragLeave = event => {
event.preventDefault();
// Only set to false if we're leaving the drop zone completely
if (!event.currentTarget.contains(event.relatedTarget)) {
isDragOver.value = false;
}
};
const handleDrop = event => {
event.preventDefault();
isDragOver.value = false;
if (props.disabled) return;
const files = event.dataTransfer.files;
if (files && files.length > 0) {
const file = files[0];
if (validateFile(file)) {
emit('fileSelected', file);
}
}
};
const displayText = computed(() => {
if (isDragOver.value && props.dragText) {
return props.dragText;
}
return props.uploadText || props.placeholder;
});
</script>
<template>
<div
class="file-upload-zone"
:class="{
'drag-over': isDragOver,
disabled: disabled,
}"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@click="handleFileClick"
>
<input
ref="fileInput"
type="file"
:accept="acceptTypes"
:multiple="multiple"
:disabled="disabled"
class="hidden"
@change="handleFileChange"
/>
<div class="upload-content">
<div class="upload-icon">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="text-n-slate-8"
>
<path
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
fill="currentColor"
/>
</svg>
</div>
<div class="upload-text">
<p class="text-sm font-medium text-n-slate-11">
{{ displayText }}
</p>
<p class="text-xs text-n-slate-8 mt-1">
{{
$t('CAPTAIN.DOCUMENTS.CREATE.FORM.FILE_UPLOAD.MAX_SIZE', {
size: maxSizeMB,
})
}}
</p>
</div>
</div>
</div>
</template>
<style scoped>
.file-upload-zone {
@apply border-2 border-dashed border-n-slate-6 rounded-lg p-6;
@apply bg-n-alpha-2 hover:bg-n-alpha-3 cursor-pointer;
@apply transition-all duration-200 ease-in-out;
@apply flex flex-col items-center justify-center text-center;
min-height: 120px;
}
.file-upload-zone:hover {
@apply border-n-slate-7;
}
.file-upload-zone.drag-over {
@apply border-n-blue-9 bg-n-blue-2/50;
}
.file-upload-zone.disabled {
@apply opacity-50 cursor-not-allowed;
@apply hover:bg-n-alpha-2 hover:border-n-slate-6;
}
.upload-content {
@apply flex flex-col items-center gap-3;
}
.upload-icon {
@apply p-2 rounded-full bg-n-alpha-3;
}
</style>
@@ -133,7 +133,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'multi_select'"
@@ -152,7 +156,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<input
v-else-if="inputType === 'email'"
@@ -203,7 +203,7 @@ export default {
v-model="values"
track-by="id"
label="name"
placeholder="Select"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
@@ -211,7 +211,11 @@ export default {
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'search_select'"
@@ -221,7 +225,7 @@ export default {
v-model="values"
track-by="id"
label="name"
placeholder="Select"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
@@ -229,7 +233,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
<input
@@ -9,6 +9,8 @@ import {
teams,
labels,
statusFilterOptions,
messageTypeOptions,
priorityOptions,
campaigns,
contacts,
inboxes,
@@ -16,7 +18,6 @@ import {
countries,
slaPolicies,
} from 'dashboard/helper/specs/fixtures/automationFixtures.js';
import { MESSAGE_CONDITION_VALUES } from 'dashboard/constants/automation';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables');
@@ -71,7 +72,9 @@ describe('useAutomation', () => {
case 'country_code':
return countries;
case 'message_type':
return MESSAGE_CONDITION_VALUES;
return messageTypeOptions;
case 'priority':
return priorityOptions;
default:
return [];
}
@@ -93,6 +96,8 @@ describe('useAutomation', () => {
return [];
case 'add_sla':
return slaPolicies;
case 'change_priority':
return priorityOptions;
default:
return [];
}
@@ -218,8 +223,9 @@ describe('useAutomation', () => {
expect(getConditionDropdownValues('browser_language')).toEqual(languages);
expect(getConditionDropdownValues('country_code')).toEqual(countries);
expect(getConditionDropdownValues('message_type')).toEqual(
MESSAGE_CONDITION_VALUES
messageTypeOptions
);
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
});
it('gets action dropdown values correctly', () => {
@@ -231,6 +237,7 @@ describe('useAutomation', () => {
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
expect(getActionDropdownValues('send_message')).toEqual([]);
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
expect(getActionDropdownValues('change_priority')).toEqual(priorityOptions);
});
it('handles event change correctly', () => {
@@ -5,6 +5,9 @@ import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/automationHelper.js');
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
describe('useMacros', () => {
const mockLabels = [
@@ -148,9 +151,11 @@ describe('useMacros', () => {
it('returns PRIORITY_CONDITION_VALUES for change_priority type', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('change_priority')).toEqual(
PRIORITY_CONDITION_VALUES
);
const expectedPriority = PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: `MACROS.PRIORITY_TYPES.${item.i18nKey}`,
}));
expect(getMacroDropdownValues('change_priority')).toEqual(expectedPriority);
});
it('returns an empty array for unknown types', () => {
@@ -8,6 +8,10 @@ import {
getActionOptions,
getConditionOptions,
} from 'dashboard/helper/automationHelper';
import {
MESSAGE_CONDITION_VALUES,
PRIORITY_CONDITION_VALUES,
} from 'dashboard/constants/automation';
/**
* This is a shared composables that holds utilites used to build dropdown and file options
@@ -60,6 +64,20 @@ export default function useAutomationValues() {
];
});
const messageTypeOptions = computed(() =>
MESSAGE_CONDITION_VALUES.map(item => ({
id: item.id,
name: t(`AUTOMATION.MESSAGE_TYPES.${item.i18nKey}`),
}))
);
const priorityOptions = computed(() =>
PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: t(`AUTOMATION.PRIORITY_TYPES.${item.i18nKey}`),
}))
);
/**
* Adds a translated "None" option to the beginning of a list
* @param {Array} list - The list to add "None" to
@@ -87,6 +105,8 @@ export default function useAutomationValues() {
customAttributes: getters['attributes/getAttributes'].value,
inboxes: inboxes.value,
statusFilterOptions: statusFilterOptions.value,
priorityOptions: priorityOptions.value,
messageTypeOptions: messageTypeOptions.value,
teams: teams.value,
languages,
countries,
@@ -108,6 +128,7 @@ export default function useAutomationValues() {
languages,
type,
addNoneToListFn: addNoneToList,
priorityOptions: priorityOptions.value,
});
};
@@ -115,6 +136,8 @@ export default function useAutomationValues() {
booleanFilterOptions,
statusFilterItems,
statusFilterOptions,
priorityOptions,
messageTypeOptions,
getConditionDropdownValues,
getActionDropdownValues,
agents,
@@ -1,4 +1,5 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters } from 'dashboard/composables/store';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
@@ -7,6 +8,7 @@ import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
* @returns {Object} An object containing the getMacroDropdownValues function
*/
export const useMacros = () => {
const { t } = useI18n();
const getters = useStoreGetters();
const labels = computed(() => getters['labels/getLabels'].value);
@@ -32,7 +34,10 @@ export const useMacros = () => {
name: i.title,
}));
case 'change_priority':
return PRIORITY_CONDITION_VALUES;
return PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: t(`MACROS.PRIORITY_TYPES.${item.i18nKey}`),
}));
default:
return [];
}
@@ -0,0 +1,355 @@
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Auth from 'dashboard/api/auth';
export function useWhatsappEmbeddedSignup() {
const store = useStore();
const router = useRouter();
const { t } = useI18n();
// State
const fbSdkLoaded = ref(false);
const isProcessing = ref(false);
const processingMessage = ref('');
const authCodeReceived = ref(false);
const authCode = ref(null);
const businessData = ref(null);
const isAuthenticating = ref(false);
// Computed
const authHeaders = computed(() => {
if (Auth.hasAuthCookie()) {
const {
'access-token': accessToken,
'token-type': tokenType,
client,
expiry,
uid,
} = Auth.getAuthData();
return {
'access-token': accessToken,
'token-type': tokenType,
client,
expiry,
uid,
};
}
return {};
});
const benefits = computed(() => [
{
key: 'EASY_SETUP',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.EASY_SETUP'),
},
{
key: 'SECURE_AUTH',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.SECURE_AUTH'),
},
{
key: 'AUTO_CONFIG',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.AUTO_CONFIG'),
},
]);
const showLoader = computed(
() => isAuthenticating.value || isProcessing.value
);
// Error handling
const handleSignupError = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
const errorMessage =
data.error ||
data.message ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
useAlert(errorMessage);
};
const handleSignupCancellation = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
let message = t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED');
if (data.data?.current_step) {
message += ` (Step: ${data.data.current_step})`;
}
useAlert(message);
};
const handleSignupSuccess = inboxData => {
isProcessing.value = false;
isAuthenticating.value = false;
// Update the store with the new inbox data
if (inboxData && inboxData.id) {
// Add the new inbox to the store
store.commit('inboxes/ADD_INBOXES', inboxData);
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: inboxData.id,
},
});
} else {
// Fallback if inbox data is not properly formatted
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
router.replace({
name: 'settings_inbox_list',
});
}
};
// Signup flow
const completeSignupFlow = async businessDataParam => {
if (!authCodeReceived.value || !authCode.value) {
handleSignupError({
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
});
return;
}
isProcessing.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
);
try {
// Send both auth code and business info together (synchronous flow)
const accountId = store.getters.getCurrentAccountId;
const response = await fetch('/whatsapp/embedded_signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content'),
...authHeaders.value,
},
body: JSON.stringify({
account_id: accountId,
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam.phone_number_id,
}),
});
const responseData = await response.json();
if (response.ok) {
// Clear the stored auth code for security
authCode.value = null;
// Handle synchronous success response
handleSignupSuccess(responseData);
} else {
throw new Error(responseData.message || responseData.error);
}
} catch (error) {
handleSignupError({ error: error.message });
}
};
const isValidBusinessData = businessDataLocal => {
return (
businessDataLocal &&
(businessDataLocal.business_id || businessDataLocal.businessId) &&
(businessDataLocal.waba_id || businessDataLocal.wabaId)
);
};
// Message handling
const handleEmbeddedSignupData = async data => {
// Handle different embedded signup events per Facebook documentation
if (data.event === 'FINISH') {
// Facebook might send business data in different structures
let businessDataLocal = data.data;
// If data.data doesn't exist, try other possible structures
if (!businessDataLocal) {
businessDataLocal = data.business_data || data.details || data;
}
// Validate we have the required business information
if (isValidBusinessData(businessDataLocal)) {
// Normalize the data structure to match our backend expectations
const normalizedData = {
business_id:
businessDataLocal.business_id || businessDataLocal.businessId,
waba_id: businessDataLocal.waba_id || businessDataLocal.wabaId,
phone_number_id:
businessDataLocal.phone_number_id ||
businessDataLocal.phoneNumberId ||
businessDataLocal.phone_id,
};
// Store business data
businessData.value = normalizedData;
// Check if we already have auth code and process immediately
if (authCodeReceived.value && authCode.value) {
await completeSignupFlow(normalizedData);
} else {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
);
}
} else {
handleSignupError({
error: t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
),
});
}
} else if (data.event === 'CANCEL') {
handleSignupCancellation(data);
} else if (data.event === 'error') {
handleSignupError({
error:
data.error_message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
error_id: data.error_id,
session_id: data.session_id,
});
}
};
const fbLoginCallback = response => {
if (response.authResponse && response.authResponse.code) {
// Authorization code received from Facebook
authCode.value = response.authResponse.code;
authCodeReceived.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
);
// Check if we already have business data and process immediately
if (businessData.value) {
completeSignupFlow(businessData.value);
}
} else if (response.error) {
handleSignupError({ error: response.error });
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
}
};
const handleSignupMessage = event => {
// Handle Facebook embedded signup message events
try {
const originUrl = new URL(event.origin);
const allowedHosts = ['facebook.com', 'www.facebook.com'];
if (!allowedHosts.includes(originUrl.hostname)) return;
} catch (error) {
// Invalid origin URL, reject the event
return;
}
try {
const data = JSON.parse(event.data);
if (data.type === 'WA_EMBEDDED_SIGNUP') {
handleEmbeddedSignupData(data);
}
} catch (error) {
// Handle non-JSON messages silently
}
};
// Facebook SDK
const loadFacebookSdk = () => {
if (window.FB) {
fbSdkLoaded.value = true;
return;
}
const script = document.createElement('script');
script.src = 'https://connect.facebook.net/en_US/sdk.js';
script.async = true;
script.defer = true;
script.onload = () => {
window.FB.init({
appId: window.chatwootConfig?.whatsappAppId,
status: true,
xfbml: true,
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
});
fbSdkLoaded.value = true;
};
document.body.appendChild(script);
};
const launchEmbeddedSignup = () => {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
);
if (!window.FB) {
loadFacebookSdk();
setTimeout(() => launchEmbeddedSignup(), 1000);
return;
}
isAuthenticating.value = true;
// Following Facebook's embedded signup documentation
window.FB.login(fbLoginCallback, {
config_id: window.chatwootConfig?.whatsappConfigurationId,
response_type: 'code',
override_default_response_type: true,
extras: {
setup: {},
featureType: '', // Leave empty for default flow
sessionInfoVersion: '3',
},
});
};
// Lifecycle
const setupMessageListener = () => {
window.addEventListener('message', handleSignupMessage);
};
const cleanupMessageListener = () => {
window.removeEventListener('message', handleSignupMessage);
};
// Initialize
const initialize = () => {
loadFacebookSdk();
setupMessageListener();
};
return {
// State
fbSdkLoaded,
isProcessing,
processingMessage,
authCodeReceived,
authCode,
businessData,
isAuthenticating,
// Computed
benefits,
showLoader,
// Methods
launchEmbeddedSignup,
initialize,
cleanupMessageListener,
};
}
@@ -0,0 +1,317 @@
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Auth from 'dashboard/api/auth';
export function useWhatsappReauthorization(inboxId) {
const store = useStore();
const router = useRouter();
const { t } = useI18n();
// State
const fbSdkLoaded = ref(false);
const isProcessing = ref(false);
const processingMessage = ref('');
const authCodeReceived = ref(false);
const authCode = ref(null);
const businessData = ref(null);
const isAuthenticating = ref(false);
// Computed
const authHeaders = computed(() => {
if (Auth.hasAuthCookie()) {
const {
'access-token': accessToken,
'token-type': tokenType,
client,
expiry,
uid,
} = Auth.getAuthData();
return {
'access-token': accessToken,
'token-type': tokenType,
client,
expiry,
uid,
};
}
return {};
});
const showLoader = computed(
() => isAuthenticating.value || isProcessing.value
);
// Error handling
const handleReauthorizationError = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
const errorMessage =
data.error ||
data.message ||
t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.ERROR');
useAlert(errorMessage);
};
const handleReauthorizationCancellation = () => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.CANCELLED'));
};
const handleReauthorizationSuccess = async () => {
isProcessing.value = false;
isAuthenticating.value = false;
// Refresh the inbox to update reauthorization status
await store.dispatch('inboxes/get');
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.SUCCESS'));
// Reload the page to reflect the changes
router.go(0);
};
// Reauthorization flow
const completeReauthorizationFlow = async businessDataParam => {
if (!authCodeReceived.value || !authCode.value) {
handleReauthorizationError({
error: t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.AUTH_NOT_COMPLETED'),
});
return;
}
isProcessing.value = true;
processingMessage.value = t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.PROCESSING'
);
try {
// Send reauthorization request
const accountId = store.getters.getCurrentAccountId;
const response = await fetch('/whatsapp/reauthorize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content'),
...authHeaders.value,
},
body: JSON.stringify({
account_id: accountId,
inbox_id: inboxId,
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam.phone_number_id,
}),
});
const responseData = await response.json();
if (response.ok) {
// Clear the stored auth code for security
authCode.value = null;
// Handle synchronous success response
handleReauthorizationSuccess();
} else {
throw new Error(responseData.message || responseData.error);
}
} catch (error) {
handleReauthorizationError({ error: error.message });
}
};
const isValidBusinessData = businessDataLocal => {
return (
businessDataLocal &&
(businessDataLocal.business_id || businessDataLocal.businessId) &&
(businessDataLocal.waba_id || businessDataLocal.wabaId)
);
};
// Message handling
const handleEmbeddedSignupData = async data => {
// Handle different embedded signup events per Facebook documentation
if (data.event === 'FINISH') {
// Facebook might send business data in different structures
let businessDataLocal = data.data;
// If data.data doesn't exist, try other possible structures
if (!businessDataLocal) {
businessDataLocal = data.business_data || data.details || data;
}
// Validate we have the required business information
if (isValidBusinessData(businessDataLocal)) {
// Normalize the data structure to match our backend expectations
const normalizedData = {
business_id:
businessDataLocal.business_id || businessDataLocal.businessId,
waba_id: businessDataLocal.waba_id || businessDataLocal.wabaId,
phone_number_id:
businessDataLocal.phone_number_id ||
businessDataLocal.phoneNumberId ||
businessDataLocal.phone_id,
};
// Store business data
businessData.value = normalizedData;
// Check if we already have auth code and process immediately
if (authCodeReceived.value && authCode.value) {
await completeReauthorizationFlow(normalizedData);
} else {
processingMessage.value = t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.WAITING_FOR_AUTH'
);
}
} else {
handleReauthorizationError({
error: t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.INVALID_BUSINESS_DATA'
),
});
}
} else if (data.event === 'CANCEL') {
handleReauthorizationCancellation();
} else if (data.event === 'error') {
handleReauthorizationError({
error:
data.error_message ||
t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.ERROR'),
});
}
};
const fbLoginCallback = response => {
if (response.authResponse && response.authResponse.code) {
// Authorization code received from Facebook
authCode.value = response.authResponse.code;
authCodeReceived.value = true;
processingMessage.value = t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.WAITING_FOR_BUSINESS_INFO'
);
// Check if we already have business data and process immediately
if (businessData.value) {
completeReauthorizationFlow(businessData.value);
}
} else if (response.error) {
handleReauthorizationError({ error: response.error });
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.CANCELLED'));
}
};
const handleSignupMessage = event => {
// Handle Facebook embedded signup message events
try {
const originUrl = new URL(event.origin);
const allowedHosts = ['facebook.com', 'www.facebook.com'];
if (!allowedHosts.includes(originUrl.hostname)) return;
} catch (error) {
// Invalid origin URL, reject the event
return;
}
try {
const data = JSON.parse(event.data);
if (data.type === 'WA_EMBEDDED_SIGNUP') {
handleEmbeddedSignupData(data);
}
} catch (error) {
// Handle non-JSON messages silently
}
};
// Facebook SDK
const loadFacebookSdk = () => {
if (window.FB) {
fbSdkLoaded.value = true;
return;
}
const script = document.createElement('script');
script.src = 'https://connect.facebook.net/en_US/sdk.js';
script.async = true;
script.defer = true;
script.onload = () => {
window.FB.init({
appId: window.chatwootConfig?.whatsappAppId,
status: true,
xfbml: true,
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
});
fbSdkLoaded.value = true;
};
document.body.appendChild(script);
};
const launchReauthorization = () => {
processingMessage.value = t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.AUTH_PROCESSING'
);
if (!window.FB) {
loadFacebookSdk();
setTimeout(() => launchReauthorization(), 1000);
return;
}
isAuthenticating.value = true;
// Following Facebook's embedded signup documentation
// Using auth_type: 'reauthorize' to force reauthorization
window.FB.login(fbLoginCallback, {
config_id: window.chatwootConfig?.whatsappConfigurationId,
response_type: 'code',
override_default_response_type: true,
auth_type: 'reauthorize',
extras: {
setup: {},
featureType: '', // Leave empty for default flow
sessionInfoVersion: '3',
},
});
};
// Lifecycle
const setupMessageListener = () => {
window.addEventListener('message', handleSignupMessage);
};
const cleanupMessageListener = () => {
window.removeEventListener('message', handleSignupMessage);
};
// Initialize
const initialize = () => {
loadFacebookSdk();
setupMessageListener();
};
return {
// State
fbSdkLoaded,
isProcessing,
processingMessage,
isAuthenticating,
// Computed
showLoader,
// Methods
launchReauthorization,
initialize,
cleanupMessageListener,
};
}
@@ -38,11 +38,13 @@ export const DEFAULT_ACTIONS = [
export const MESSAGE_CONDITION_VALUES = [
{
id: 'incoming',
name: 'Incoming Message',
name: 'Incoming',
i18nKey: 'INCOMING',
},
{
id: 'outgoing',
name: 'Outgoing Message',
name: 'Outgoing',
i18nKey: 'OUTGOING',
},
];
@@ -50,21 +52,26 @@ export const PRIORITY_CONDITION_VALUES = [
{
id: 'nil',
name: 'None',
i18nKey: 'NONE',
},
{
id: 'low',
name: 'Low',
i18nKey: 'LOW',
},
{
id: 'medium',
name: 'Medium',
i18nKey: 'MEDIUM',
},
{
id: 'high',
name: 'High',
i18nKey: 'HIGH',
},
{
id: 'urgent',
name: 'Urgent',
i18nKey: 'URGENT',
},
];
@@ -8,8 +8,6 @@ import {
DEFAULT_CONVERSATION_OPENED_CONDITION,
DEFAULT_OTHER_CONDITION,
DEFAULT_ACTIONS,
MESSAGE_CONDITION_VALUES,
PRIORITY_CONDITION_VALUES,
} from 'dashboard/constants/automation';
import filterQueryGenerator from './filterQueryGenerator';
import actionQueryGenerator from './actionQueryGenerator';
@@ -103,6 +101,7 @@ export const getActionOptions = ({
slaPolicies,
type,
addNoneToListFn,
priorityOptions,
}) => {
const actionsMap = {
assign_agent: addNoneToListFn ? addNoneToListFn(agents) : agents,
@@ -110,7 +109,7 @@ export const getActionOptions = ({
send_email_to_team: teams,
add_label: generateConditionOptions(labels, 'title'),
remove_label: generateConditionOptions(labels, 'title'),
change_priority: PRIORITY_CONDITION_VALUES,
change_priority: priorityOptions,
add_sla: slaPolicies,
};
return actionsMap[type];
@@ -128,6 +127,8 @@ export const getConditionOptions = ({
statusFilterOptions,
teams,
type,
priorityOptions,
messageTypeOptions,
}) => {
if (isCustomAttributeCheckbox(customAttributes, type)) {
return booleanFilterOptions;
@@ -147,8 +148,8 @@ export const getConditionOptions = ({
browser_language: languages,
conversation_language: languages,
country_code: countries,
message_type: MESSAGE_CONDITION_VALUES,
priority: PRIORITY_CONDITION_VALUES,
message_type: messageTypeOptions,
priority: priorityOptions,
};
return conditionFilterMaps[type];
@@ -2,6 +2,11 @@ import allLanguages from 'dashboard/components/widgets/conversation/advancedFilt
import allCountries from 'shared/constants/countries.js';
import {
MESSAGE_CONDITION_VALUES,
PRIORITY_CONDITION_VALUES,
} from 'dashboard/constants/automation';
export const customAttributes = [
{
id: 1,
@@ -468,7 +473,7 @@ export const inboxes = [
welcome_title: '',
welcome_tagline: '',
web_widget_script:
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.defer = true;\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
website_token: 'yZ7USzaEs7hrwUAHLGwjbxJ1',
selected_feature_flags: ['attachments', 'emoji_picker', 'end_conversation'],
reply_time: 'in_a_few_minutes',
@@ -636,16 +641,16 @@ export const statusFilterOptions = [
];
export const languages = allLanguages;
export const countries = allCountries;
export const MESSAGE_CONDITION_VALUES = [
{
id: 'incoming',
name: 'Incoming Message',
},
{
id: 'outgoing',
name: 'Outgoing Message',
},
];
export const messageTypeOptions = MESSAGE_CONDITION_VALUES.map(item => ({
id: item.id,
name: `AUTOMATION.MESSAGE_TYPES.${item.i18nKey}`,
}));
export const priorityOptions = PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: `AUTOMATION.PRIORITY_TYPES.${item.i18nKey}`,
}));
export const automationToSubmit = {
name: 'Fayaz',
@@ -5,6 +5,18 @@
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
"NUMBER": "Number",
"LINK": "Link",
"DATE": "Date",
"LIST": "List",
"CHECKBOX": "Checkbox"
},
"ADD": {
"TITLE": "Add Custom Attribute",
"SUBMIT": "Create",
@@ -150,6 +150,17 @@
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "None",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
"MESSAGE_CONTAINS": "Message Contains",
@@ -5,9 +5,6 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
},
"CLOSE": "Close",
"FILE_SIZE": {
"MB": "MB"
}
"CLOSE": "Close"
}
}
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Press enter to select",
"ENTER_TO_REMOVE": "Press enter to remove",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "Select one",
"SELECT": "Select"
}
@@ -222,10 +222,17 @@
"DESC": "Start supporting your customers via WhatsApp.",
"PROVIDERS": {
"LABEL": "API Provider",
"WHATSAPP_EMBEDDED": "WhatsApp Business",
"TWILIO": "Twilio",
"WHATSAPP_CLOUD": "WhatsApp Cloud",
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
"TWILIO_DESC": "Connect via Twilio credentials",
"360_DIALOG": "360Dialog"
},
"SELECT_PROVIDER": {
"TITLE": "Select your API provider",
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
},
"INBOX_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Please enter an inbox name",
@@ -264,6 +271,28 @@
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
"SECURE_AUTH": "Secure OAuth based authentication",
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account",
"LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
"WAITING_FOR_AUTH": "Waiting for authentication...",
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
@@ -571,7 +600,24 @@
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
"WHATSAPP_RECONFIGURE_TITLE": "Reconfigure WhatsApp",
"WHATSAPP_RECONFIGURE_SUBHEADER": "Update your WhatsApp Business account configuration using the embedded signup flow.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure with WhatsApp",
"WHATSAPP_RECONFIGURE_ERROR": "Failed to initiate WhatsApp reconfiguration. Please try again.",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings",
"WHATSAPP_REAUTHORIZE": {
"TITLE": "Reauthorize WhatsApp Connection",
"DESCRIPTION": "Your WhatsApp Business account needs to be reauthorized to continue receiving messages. Click the button below to reconnect your account.",
"AUTH_PROCESSING": "Processing WhatsApp reauthorization...",
"WAITING_FOR_BUSINESS_INFO": "Waiting for business information...",
"WAITING_FOR_AUTH": "Waiting for authentication...",
"PROCESSING": "Updating your WhatsApp configuration...",
"SUCCESS": "WhatsApp successfully reauthorized!",
"ERROR": "Failed to reauthorize WhatsApp. Please try again.",
"CANCELLED": "WhatsApp reauthorization was cancelled.",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please try again.",
"INVALID_BUSINESS_DATA": "Invalid business data received. Please try again."
}
},
"HELP_CENTER": {
"LABEL": "Help Center",
@@ -591,6 +637,12 @@
"MESSAGE_SUCCESS": "Reconnection successful",
"MESSAGE_ERROR": "There was an error, please try again"
},
"WHATSAPP_REAUTHORIZE": {
"TITLE": "Reauthorize WhatsApp",
"SUBTITLE": "Your WhatsApp connection has expired, please reconnect your WhatsApp Business account to continue services",
"MESSAGE_SUCCESS": "WhatsApp reconnection successful",
"MESSAGE_ERROR": "There was an error reconnecting WhatsApp, please try again"
},
"PRE_CHAT_FORM": {
"DESCRIPTION": "Pre chat forms enable you to capture user information before they start conversation with you.",
"SET_FIELDS": "Pre chat form fields",
@@ -501,26 +501,9 @@
},
"FORM_DESCRIPTION": "Enter the URL of the document to add it as a knowledge source and choose the assistant to associate it with.",
"CREATE": {
"TITLE": "Create New Document",
"DESCRIPTION": "Add a new document from a website URL or by uploading a PDF file",
"TITLE": "Add a document",
"SUCCESS_MESSAGE": "The document has been successfully created",
"ERROR_MESSAGE": "There was an error creating the document, please try again.",
"FORM": {
"SOURCE_TYPE": {
"LABEL": "Choose Source Type",
"WEBSITE_URL": "Website URL",
"PDF_UPLOAD": "PDF Upload"
},
"PDF_UPLOAD": {
"LABEL": "Upload PDF Document"
},
"ASSISTANT": {
"LABEL": "Select Assistant"
},
"FILE_UPLOAD": {
"MAX_SIZE": "Maximum file size: {size}MB"
}
}
"ERROR_MESSAGE": "There was an error creating the document, please try again."
},
"FORM": {
"URL": {
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "Change Priority",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
},
"PRIORITY_TYPES": {
"NONE": "None",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
}
}
}
@@ -58,11 +58,6 @@ const handleCreateDialogClose = () => {
showCreateDialog.value = false;
};
const handleCreateDialogSuccess = () => {
// Document is already added to the store optimistically, no need to refetch
// The store action 'uploadPdf' or 'create' already adds the document via commit(mutationTypes.ADD)
};
const handleAction = ({ action, id }) => {
selectedDocument.value = documents.value.find(
captainDocument => id === captainDocument.id
@@ -176,7 +171,6 @@ onMounted(() => {
v-if="showCreateDialog"
ref="createDocumentDialog"
@close="handleCreateDialogClose"
@success="handleCreateDialogSuccess"
/>
<DeleteDialog
v-if="selectedDocument"
@@ -40,8 +40,6 @@ export default {
regexPattern: null,
regexCue: null,
regexEnabled: false,
models: ATTRIBUTE_MODELS,
types: ATTRIBUTE_TYPES,
values: [],
options: [],
show: true,
@@ -53,6 +51,18 @@ export default {
...mapGetters({
uiFlags: 'getUIFlags',
}),
models() {
return ATTRIBUTE_MODELS.map(item => ({
...item,
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_MODELS.${item.key}`),
}));
},
types() {
return ATTRIBUTE_TYPES.map(item => ({
...item,
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`),
}));
},
isMultiselectInvalid() {
return this.isTouched && this.values.length === 0;
},
@@ -113,7 +113,11 @@ const tableHeaders = computed(() => {
<td
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ attribute.attribute_display_type }}
{{
$t(
`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${attribute.attribute_display_type?.toUpperCase()}`
)
}}
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 attribute-key overflow-hidden whitespace-nowrap text-ellipsis"
@@ -32,7 +32,6 @@ export default {
regexPattern: null,
regexCue: null,
regexEnabled: false,
types: ATTRIBUTE_TYPES,
show: true,
attributeKey: '',
values: [],
@@ -59,6 +58,12 @@ export default {
},
},
computed: {
types() {
return ATTRIBUTE_TYPES.map(item => ({
...item,
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`),
}));
},
setAttributeListValue() {
return this.selectedAttribute.attribute_values.map(values => ({
name: values,
@@ -84,9 +89,9 @@ export default {
selectedAttributeType() {
return this.types.find(
item =>
item.option.toLowerCase() ===
item.key.toLowerCase() ===
this.selectedAttribute.attribute_display_type
).id;
)?.id;
},
keyErrorMessage() {
if (!this.v$.attributeKey.isKey) {
@@ -1,37 +1,13 @@
export const ATTRIBUTE_MODELS = [
{
id: 0,
option: 'Conversation',
},
{
id: 1,
option: 'Contact',
},
{ id: 0, key: 'CONVERSATION' },
{ id: 1, key: 'CONTACT' },
];
export const ATTRIBUTE_TYPES = [
{
id: 0,
option: 'Text',
},
{
id: 1,
option: 'Number',
},
{
id: 4,
option: 'Link',
},
{
id: 5,
option: 'Date',
},
{
id: 6,
option: 'List',
},
{
id: 7,
option: 'Checkbox',
},
{ id: 0, key: 'TEXT' },
{ id: 1, key: 'NUMBER' },
{ id: 4, key: 'LINK' },
{ id: 5, key: 'DATE' },
{ id: 6, key: 'LIST' },
{ id: 7, key: 'CHECKBOX' },
];
@@ -47,6 +47,13 @@ export default {
this.currentInbox.provider === 'whatsapp_cloud'
);
},
// If the inbox is a whatsapp cloud inbox and the source is not embedded signup, then show the webhook details
shouldShowWhatsAppWebhookDetails() {
return (
this.isWhatsAppCloudInbox &&
this.currentInbox.provider_config?.source !== 'embedded_signup'
);
},
message() {
if (this.isATwilioInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
@@ -66,7 +73,7 @@ export default {
)}`;
}
if (this.isWhatsAppCloudInbox) {
if (this.isWhatsAppCloudInbox && this.shouldShowWhatsAppWebhookDetails) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.SUBTITLE'
)}`;
@@ -113,8 +120,11 @@ export default {
:script="currentInbox.callback_webhook_url"
/>
</div>
<div v-if="isWhatsAppCloudInbox" class="w-[50%] max-w-[50%] ml-[25%]">
<p class="mt-8 font-medium text-n-slate-11">
<div
v-if="shouldShowWhatsAppWebhookDetails"
class="w-[50%] max-w-[50%] ml-[25%]"
>
<p class="mt-8 font-medium text-slate-700 dark:text-slate-200">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.WEBHOOK_URL') }}
</p>
<woot-code lang="html" :script="currentInbox.callback_webhook_url" />
@@ -11,6 +11,7 @@ import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import GoogleReauthorize from './channels/google/Reauthorize.vue';
import WhatsappReauthorize from './channels/whatsapp/Reauthorize.vue';
import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
@@ -45,6 +46,7 @@ export default {
NextButton,
InstagramReauthorize,
DuplicateInboxBanner,
WhatsappReauthorize,
Editor,
},
mixins: [inboxMixin],
@@ -231,6 +233,11 @@ export default {
facebookUnauthorized() {
return this.isAFacebookInbox && this.inbox.reauthorization_required;
},
whatsappUnauthorized() {
return (
this.isAWhatsAppCloudChannel && this.inbox.reauthorization_required
);
},
googleUnauthorized() {
const isLegacyInbox = ['imap.gmail.com', 'imap.google.com'].includes(
this.inbox.imap_address
@@ -410,6 +417,7 @@ export default {
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
<WhatsappReauthorize v-if="whatsappUnauthorized" :inbox="inbox" />
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
@@ -1,48 +1,140 @@
<script>
import PageHeader from '../../SettingsSubPageHeader.vue';
<script setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import Twilio from './Twilio.vue';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import whatsappIcon from 'dashboard/assets/images/whatsapp.png';
import twilioIcon from 'dashboard/assets/images/twilio.png';
export default {
components: {
PageHeader,
Twilio,
ThreeSixtyDialogWhatsapp,
CloudWhatsapp,
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
TWILIO: 'twilio',
WHATSAPP_CLOUD: 'whatsapp_cloud',
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
THREE_SIXTY_DIALOG: '360dialog',
};
const hasWhatsappAppId = computed(() => {
return (
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
});
const selectedProvider = computed(() => route.query.provider);
const showProviderSelection = computed(() => !selectedProvider.value);
const showConfiguration = computed(() => Boolean(selectedProvider.value));
const availableProviders = computed(() => [
{
value: PROVIDER_TYPES.WHATSAPP,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
icon: whatsappIcon,
},
data() {
return {
provider: 'whatsapp_cloud',
};
{
value: PROVIDER_TYPES.TWILIO,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
icon: twilioIcon,
},
]);
const selectProvider = providerValue => {
router.push({
name: route.name,
params: route.params,
query: { provider: providerValue },
});
};
const shouldShowEmbeddedSignup = provider => {
return (
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
);
};
const shouldShowCloudWhatsapp = provider => {
return provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value;
};
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
>
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.WHATSAPP.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.WHATSAPP.DESC')"
/>
<div class="flex-shrink-0 flex-grow-0">
<label>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }}
<select v-model="provider">
<option value="whatsapp_cloud">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD') }}
</option>
<option value="twilio">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO') }}
</option>
</select>
</label>
<!-- Provider Selection View -->
<div v-if="showProviderSelection">
<div class="mb-10 text-left">
<h1 class="mb-2 text-lg font-medium text-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE') }}
</h1>
<p class="text-sm leading-relaxed text-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.DESCRIPTION') }}
</p>
</div>
<!-- Dynamic Provider Cards -->
<div class="flex justify-start gap-6">
<div
v-for="provider in availableProviders"
:key="provider.value"
class="gap-6 px-5 py-6 transition-all duration-200 border cursor-pointer w-96 border-n-weak rounded-2xl hover:bg-n-slate-3"
@click="selectProvider(provider.value)"
>
<!-- Provider Icon -->
<div class="flex justify-start mb-5">
<div
class="flex items-center justify-center size-10 bg-n-alpha-2 rounded-full"
>
<img
:src="provider.icon"
:alt="provider.label"
class="object-contain size-[26px]"
/>
</div>
</div>
<!-- Card Content -->
<div class="text-start">
<h3 class="mb-1.5 text-sm font-medium text-slate-12">
{{ provider.label }}
</h3>
<p class="text-sm text-slate-11">
{{ provider.description }}
</p>
</div>
</div>
</div>
</div>
<Twilio v-if="provider === 'twilio'" type="whatsapp" />
<ThreeSixtyDialogWhatsapp v-else-if="provider === '360dialog'" />
<CloudWhatsapp v-else />
<!-- Configuration View -->
<div v-else-if="showConfiguration">
<div class="py-5 px-6 border bg-n-solid-2 border-n-weak rounded-2xl">
<!-- Provider Configuration Forms -->
<WhatsappEmbeddedSignup
v-if="shouldShowEmbeddedSignup(selectedProvider)"
/>
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<Twilio
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
type="whatsapp"
/>
<ThreeSixtyDialogWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
/>
<CloudWhatsapp v-else />
</div>
</div>
</div>
</template>
@@ -0,0 +1,85 @@
<script setup>
import { onMounted, onBeforeUnmount } from 'vue';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import whatsappIcon from 'dashboard/assets/images/whatsapp.png';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
const {
fbSdkLoaded,
processingMessage,
isAuthenticating,
benefits,
showLoader,
launchEmbeddedSignup,
initialize,
cleanupMessageListener,
} = useWhatsappEmbeddedSignup();
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
cleanupMessageListener();
});
</script>
<template>
<div class="h-full">
<LoadingState v-if="showLoader" :message="processingMessage" />
<div v-else>
<div class="flex flex-col items-start mb-6 text-start">
<div class="flex justify-start mb-6">
<div
class="flex items-center justify-center w-12 h-12 bg-n-alpha-2 rounded-full"
>
<img
:src="whatsappIcon"
:alt="$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD')"
class="object-contain w-8 h-8"
draggable="false"
/>
</div>
</div>
<h3 class="mb-2 text-base font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.TITLE') }}
</h3>
<p class="text-sm leading-[24px] text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.DESC') }}
</p>
</div>
<div class="flex flex-col gap-2 mb-6">
<div
v-for="benefit in benefits"
:key="benefit.key"
class="flex items-center gap-2 text-sm text-n-slate-11"
>
<Icon icon="i-lucide-check" class="text-n-slate-11 size-4" />
{{ benefit.text }}
</div>
</div>
<div class="flex mt-4">
<NextButton
:disabled="!fbSdkLoaded || isAuthenticating"
:is-loading="isAuthenticating"
faded
slate
class="w-full"
@click="launchEmbeddedSignup"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUBMIT_BUTTON') }}
</NextButton>
</div>
<p v-if="!fbSdkLoaded" class="mt-3 text-xs text-start text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LOADING_SDK') }}
</p>
</div>
</div>
</template>
@@ -0,0 +1,38 @@
<script setup>
import { onMounted, onBeforeUnmount } from 'vue';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { useWhatsappReauthorization } from 'dashboard/composables/useWhatsappReauthorization';
const props = defineProps({
inbox: {
type: Object,
required: true,
},
});
const {
processingMessage,
showLoader,
launchReauthorization,
initialize,
cleanupMessageListener,
} = useWhatsappReauthorization(props.inbox.id);
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
cleanupMessageListener();
});
</script>
<template>
<div>
<InboxReconnectionRequired
class="mx-8 mt-5"
@reauthorize="launchReauthorization"
/>
<LoadingState v-if="showLoader" :message="processingMessage" />
</div>
</template>
@@ -7,6 +7,7 @@ import SmtpSettings from '../SmtpSettings.vue';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useWhatsappReauthorization } from 'dashboard/composables/useWhatsappReauthorization';
export default {
components: {
@@ -34,6 +35,20 @@ export default {
validations: {
whatsAppInboxAPIKey: { required },
},
computed: {
isWhatsAppCloudChannel() {
return this.inbox.provider === 'whatsapp_cloud';
},
hasWhatsappAppId() {
return (
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
},
shouldShowReconfigureButton() {
return this.isWhatsAppCloudChannel && this.hasWhatsappAppId;
},
},
watch: {
inbox() {
this.setDefaults();
@@ -83,6 +98,18 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
reconfigureWhatsApp() {
// Initialize the WhatsApp reauthorization flow
const { launchReauthorization, initialize } = useWhatsappReauthorization(
this.inbox.id
);
// Initialize and launch
initialize();
launchReauthorization();
// Note: cleanup will happen when component unmounts or user navigates away
},
},
};
</script>
@@ -208,7 +235,21 @@ export default {
>
<woot-code :script="inbox.provider_config.api_key" />
</SettingsSection>
<!-- Show reconfigure button for WhatsApp Cloud with embedded signup and app ID configured -->
<SettingsSection
v-if="shouldShowReconfigureButton"
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_SUBHEADER')
"
>
<NextButton class="mt-2" @click="reconfigureWhatsApp">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
</NextButton>
</SettingsSection>
<!-- Show API key update for non-embedded signup WhatsApp or when app ID is missing -->
<SettingsSection
v-else
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
@@ -1,25 +1,7 @@
import CaptainDocumentAPI from 'dashboard/api/captain/document';
import { createStore } from './storeFactory';
const actions = mutationTypes => ({
async uploadPdf({ commit }, formData) {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
try {
const response = await CaptainDocumentAPI.uploadPdf(formData);
const { data } = response;
// Use UPSERT like the create action for consistency
commit(mutationTypes.UPSERT, data);
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
return Promise.resolve(data);
} catch (error) {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
return Promise.reject(error);
}
},
});
export default createStore({
name: 'CaptainDocument',
API: CaptainDocumentAPI,
actions,
});
+7
View File
@@ -72,6 +72,13 @@ const runSDK = ({ baseUrl, websiteToken }) => {
widgetStyle: getWidgetStyle(chatwootSettings.widgetStyle) || 'standard',
resetTriggered: false,
darkMode: getDarkMode(chatwootSettings.darkMode),
welcomeTitle: chatwootSettings.welcomeTitle || '',
welcomeDescription: chatwootSettings.welcomeDescription || '',
availableMessage: chatwootSettings.availableMessage || '',
unavailableMessage: chatwootSettings.unavailableMessage || '',
enableFileUpload: chatwootSettings.enableFileUpload ?? true,
enableEmojiPicker: chatwootSettings.enableEmojiPicker ?? true,
enableEndConversation: chatwootSettings.enableEndConversation ?? true,
toggle(state) {
IFrameHelper.events.toggleBubble(state);
+7
View File
@@ -166,6 +166,13 @@ export const IFrameHelper = {
darkMode: window.$chatwoot.darkMode,
showUnreadMessagesDialog: window.$chatwoot.showUnreadMessagesDialog,
campaignsSnoozedTill,
welcomeTitle: window.$chatwoot.welcomeTitle,
welcomeDescription: window.$chatwoot.welcomeDescription,
availableMessage: window.$chatwoot.availableMessage,
unavailableMessage: window.$chatwoot.unavailableMessage,
enableFileUpload: window.$chatwoot.enableFileUpload,
enableEmojiPicker: window.$chatwoot.enableEmojiPicker,
enableEndConversation: window.$chatwoot.enableEndConversation,
});
IFrameHelper.onLoad({
widgetColor: message.config.channelConfig.widgetColor,
@@ -69,7 +69,7 @@ export default {
},
},
watch: {
value() {
modelValue() {
this.resizeTextarea();
// 🚨 watch triggers every time the value is changed, we cannot set this to focus then
// when this runs, it sets the cursor to the end of the body, ignoring the signature
@@ -24,7 +24,10 @@ export default {
return { isUploading: false };
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
...mapGetters({
globalConfig: 'globalConfig/get',
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
}),
fileUploadSizeLimit() {
return MAXIMUM_FILE_UPLOAD_SIZE;
},
@@ -40,6 +43,9 @@ export default {
},
methods: {
handleClipboardPaste(e) {
// If file picker is not enabled, do not allow paste
if (!this.shouldShowFilePicker) return;
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
// items is a DataTransferItemList object which does not have forEach method
const itemsArray = Array.from(items);
@@ -47,11 +47,11 @@ const containerClasses = computed(() => [
</div>
<h2
v-dompurify-html="introHeading"
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12"
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12 line-clamp-4"
/>
<p
v-dompurify-html="formatMessage(introBody)"
class="text-lg leading-normal text-n-slate-11 [&_a]:underline"
class="text-lg leading-normal text-n-slate-11 [&_a]:underline line-clamp-6"
/>
</header>
</template>
@@ -41,9 +41,15 @@ export default {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
isWidgetOpen: 'appConfig/getIsWidgetOpen',
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
shouldShowEmojiPicker: 'appConfig/getShouldShowEmojiPicker',
}),
showAttachment() {
return this.hasAttachmentsEnabled && this.userInput.length === 0;
return (
this.shouldShowFilePicker &&
this.hasAttachmentsEnabled &&
this.userInput.length === 0
);
},
showSendButton() {
return this.userInput.length > 0;
@@ -143,7 +149,7 @@ export default {
:on-attach="onSendAttachment"
/>
<button
v-if="hasEmojiPickerEnabled"
v-if="shouldShowEmojiPicker && hasEmojiPickerEnabled"
class="flex items-center justify-center min-h-8 min-w-8"
:aria-label="$t('EMOJI.ARIA_LABEL')"
@click="toggleEmojiPicker"
@@ -158,7 +164,7 @@ export default {
/>
</button>
<EmojiInput
v-if="showEmojiPicker"
v-if="shouldShowEmojiPicker && showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:on-click="emojiOnClick"
@keydown.esc="hideEmojiPicker"
@@ -23,6 +23,7 @@ export default {
computed: {
...mapGetters({
conversationAttributes: 'conversationAttributes/getConversationParams',
canUserEndConversation: 'appConfig/getCanUserEndConversation',
}),
canLeaveConversation() {
return [
@@ -82,6 +83,7 @@ export default {
<button
v-if="
canLeaveConversation &&
canUserEndConversation &&
hasEndConversationEnabled &&
showEndConversationButton
"
@@ -29,6 +29,8 @@ export default {
computed: {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
availableMessage: 'appConfig/getAvailableMessage',
unavailableMessage: 'appConfig/getUnavailableMessage',
}),
textColor() {
return getContrastingTextColor(this.widgetColor);
@@ -40,6 +42,11 @@ export default {
id: agent.id,
}));
},
headerMessage() {
return this.isOnline
? this.availableMessage || this.$t('TEAM_AVAILABILITY.ONLINE')
: this.unavailableMessage || this.$t('TEAM_AVAILABILITY.OFFLINE');
},
isOnline() {
const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0;
@@ -71,12 +78,8 @@ export default {
>
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-1">
<div class="font-medium text-n-slate-12">
{{
isOnline
? $t('TEAM_AVAILABILITY.ONLINE')
: $t('TEAM_AVAILABILITY.OFFLINE')
}}
<div class="font-medium text-n-slate-12 line-clamp-2">
{{ headerMessage }}
</div>
<div class="text-n-slate-11">
{{ replyWaitMessage }}
@@ -119,8 +119,10 @@ export default {
>
<ChatHeaderExpanded
v-if="!isHeaderCollapsed"
:intro-heading="channelConfig.welcomeTitle"
:intro-body="channelConfig.welcomeTagline"
:intro-heading="appConfig.welcomeTitle || channelConfig.welcomeTitle"
:intro-body="
appConfig.welcomeDescription || channelConfig.welcomeTagline
"
:avatar-url="channelConfig.avatarUrl"
:show-popout-button="appConfig.showPopoutButton"
/>
@@ -21,6 +21,13 @@ const state = {
widgetStyle: 'standard',
darkMode: 'light',
isUpdatingRoute: false,
welcomeTitle: '',
welcomeDescription: '',
availableMessage: '',
unavailableMessage: '',
enableFileUpload: true,
enableEmojiPicker: true,
enableEndConversation: true,
};
export const getters = {
@@ -34,6 +41,13 @@ export const getters = {
darkMode: $state => $state.darkMode,
getShowUnreadMessagesDialog: $state => $state.showUnreadMessagesDialog,
getIsUpdatingRoute: _state => _state.isUpdatingRoute,
getWelcomeHeading: $state => $state.welcomeTitle,
getWelcomeTagline: $state => $state.welcomeDescription,
getAvailableMessage: $state => $state.availableMessage,
getUnavailableMessage: $state => $state.unavailableMessage,
getShouldShowFilePicker: $state => $state.enableFileUpload,
getShouldShowEmojiPicker: $state => $state.enableEmojiPicker,
getCanUserEndConversation: $state => $state.enableEndConversation,
};
export const actions = {
@@ -46,6 +60,13 @@ export const actions = {
showUnreadMessagesDialog,
widgetStyle = 'rounded',
darkMode = 'light',
welcomeTitle = '',
welcomeDescription = '',
availableMessage = '',
unavailableMessage = '',
enableFileUpload = true,
enableEmojiPicker = true,
enableEndConversation = true,
}
) {
commit(SET_WIDGET_APP_CONFIG, {
@@ -55,6 +76,13 @@ export const actions = {
showUnreadMessagesDialog: !!showUnreadMessagesDialog,
widgetStyle,
darkMode,
welcomeTitle,
welcomeDescription,
availableMessage,
unavailableMessage,
enableFileUpload,
enableEmojiPicker,
enableEndConversation,
});
},
toggleWidgetOpen({ commit }, isWidgetOpen) {
@@ -90,6 +118,13 @@ export const mutations = {
$state.darkMode = data.darkMode;
$state.locale = data.locale || $state.locale;
$state.showUnreadMessagesDialog = data.showUnreadMessagesDialog;
$state.welcomeTitle = data.welcomeTitle;
$state.welcomeDescription = data.welcomeDescription;
$state.availableMessage = data.availableMessage;
$state.unavailableMessage = data.unavailableMessage;
$state.enableFileUpload = data.enableFileUpload;
$state.enableEmojiPicker = data.enableEmojiPicker;
$state.enableEndConversation = data.enableEndConversation;
},
[TOGGLE_WIDGET_OPEN]($state, isWidgetOpen) {
$state.isWidgetOpen = isWidgetOpen;
@@ -19,6 +19,48 @@ describe('#getters', () => {
expect(getters.getShowUnreadMessagesDialog(state)).toEqual(true);
});
});
describe('#getAvailableMessage', () => {
it('returns correct value', () => {
const state = { availableMessage: 'We reply quickly' };
expect(getters.getAvailableMessage(state)).toEqual('We reply quickly');
});
});
describe('#getWelcomeHeading', () => {
it('returns correct value', () => {
const state = { welcomeTitle: 'Hello!' };
expect(getters.getWelcomeHeading(state)).toEqual('Hello!');
});
});
describe('#getWelcomeTagline', () => {
it('returns correct value', () => {
const state = { welcomeDescription: 'Welcome to our site' };
expect(getters.getWelcomeTagline(state)).toEqual('Welcome to our site');
});
});
describe('#getShouldShowFilePicker', () => {
it('returns correct value', () => {
const state = { enableFileUpload: true };
expect(getters.getShouldShowFilePicker(state)).toEqual(true);
});
});
describe('#getShouldShowEmojiPicker', () => {
it('returns correct value', () => {
const state = { enableEmojiPicker: true };
expect(getters.getShouldShowEmojiPicker(state)).toEqual(true);
});
});
describe('#getCanUserEndConversation', () => {
it('returns correct value', () => {
const state = { enableEndConversation: true };
expect(getters.getCanUserEndConversation(state)).toEqual(true);
});
});
describe('#getUnavailableMessage', () => {
it('returns correct value', () => {
const state = { unavailableMessage: 'We are offline' };
expect(getters.getUnavailableMessage(state)).toEqual('We are offline');
});
});
describe('#getIsUpdatingRoute', () => {
it('returns correct value', () => {
const state = { isUpdatingRoute: true };
+1
View File
@@ -5,6 +5,7 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
channel = find_channel_from_whatsapp_business_payload(params)
if channel_is_inactive?(channel)
Rails.logger.info("Channel is inactive: #{channel.inspect}")
Rails.logger.warn("Inactive WhatsApp channel: #{channel&.phone_number || "unknown - #{params[:phone_number]}"}")
return
end
-1
View File
@@ -66,7 +66,6 @@ class Channel::WebWidget < ApplicationRecord
var BASE_URL=\"#{ENV.fetch('FRONTEND_URL', '')}\";
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+\"/packs/js/sdk.js\";
g.defer = true;
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
+1 -1
View File
@@ -191,7 +191,7 @@ class Inbox < ApplicationRecord
end
def apply_sanitization_rules(name)
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~]/, '') # Remove forbidden chars
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
.gsub(/\s+/, ' ') # Normalize spaces
+97
View File
@@ -0,0 +1,97 @@
# Handles WhatsApp API communication
module Whatsapp::ApiService
extend ActiveSupport::Concern
def whatsapp_api_version
@whatsapp_api_version ||= GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
end
def exchange_code_for_token
response = Faraday.get(
"https://graph.facebook.com/#{whatsapp_api_version}/oauth/access_token",
{
client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
code: @code
}
)
raise "Token exchange failed: #{response.body}" unless response.success?
data = JSON.parse(response.body)
raise "No access token in response: #{data}" unless data['access_token']
data['access_token']
end
def fetch_phone_info_via_waba(waba_id, phone_number_id, access_token)
response = Faraday.get(
"https://graph.facebook.com/#{whatsapp_api_version}/#{waba_id}/phone_numbers",
{ access_token: access_token }
)
raise "WABA phone numbers fetch failed: #{response.body}" unless response.success?
data = JSON.parse(response.body)
phone_numbers = data['data']
phone_data = phone_numbers.find { |phone| phone['id'] == phone_number_id } || phone_numbers.first
raise "No phone numbers found for WABA #{waba_id}" if phone_data.nil?
display_phone_number = sanitize_phone_number(phone_data['display_phone_number'])
{
phone_number_id: phone_data['id'],
phone_number: "+#{display_phone_number}",
verified: phone_data['code_verification_status'] == 'VERIFIED',
business_name: phone_data['verified_name'] || phone_data['display_phone_number']
}
end
def register_phone_number(phone_number_id, access_token)
HTTParty.post(
"https://graph.facebook.com/#{whatsapp_api_version}/#{phone_number_id}/register",
{
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { messaging_product: 'whatsapp', pin: '212834' }.to_json
}
)
end
def override_waba_webhook(waba_id, channel, access_token)
callback_url = "#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
verify_token = channel.provider_config['webhook_verify_token']
response = HTTParty.post(
"https://graph.facebook.com/#{whatsapp_api_version}/#{waba_id}/subscribed_apps",
{
headers: {
'Authorization' => "Bearer #{access_token}",
'Content-Type' => 'application/json'
},
body: {
override_callback_uri: callback_url,
verify_token: verify_token
}.to_json
}
)
return if response.success?
Rails.logger.error("[WHATSAPP] Webhook override failed: #{response.body}")
raise "Webhook override failed: #{response.body}"
end
private
def sanitize_phone_number(phone_number)
return phone_number if phone_number.blank?
phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
end
def build_app_access_token
app_id = GlobalConfigService.load('WHATSAPP_APP_ID', '')
app_secret = GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
"#{app_id}|#{app_secret}"
end
end
@@ -0,0 +1,135 @@
class Whatsapp::EmbeddedSignupService
include Rails.application.routes.url_helpers
include Whatsapp::ApiService
include Whatsapp::TokenValidator
def initialize(params)
@account = params[:account]
@code = params[:code]
@business_id = params[:business_id]
@waba_id = params[:waba_id]
@phone_number_id = params[:phone_number_id]
@inbox_id = params[:inbox_id]
end
def perform
# Validate required parameters
unless @code.present? && @business_id.present? && @waba_id.present? && @phone_number_id.present?
raise ArgumentError, 'Code, business_id, waba_id, and phone_number_id are all required'
end
GlobalConfig.clear_cache
# Exchange code for user access token
access_token = exchange_code_for_token
# Use the provided business info directly (more efficient)
phone_info = fetch_phone_info_via_waba(@waba_id, @phone_number_id, access_token)
# Validate that the token has access to the provided WABA (security check)
validate_token_waba_access(access_token, @waba_id)
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
create_or_update_channel(waba_info, phone_info, access_token)
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Signup failed: #{e.message}")
raise e
end
def perform_reauthorization
validate_reauthorization_params!
channel = find_and_validate_channel
GlobalConfig.clear_cache
access_token = exchange_code_for_token
phone_info = fetch_phone_info_via_waba(@waba_id, @phone_number_id, access_token)
validate_token_waba_access(access_token, @waba_id)
update_channel_for_reauthorization(channel, phone_info, access_token)
register_phone_number(phone_info[:phone_number_id], access_token)
override_waba_webhook(@waba_id, channel, access_token)
channel
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Reauthorization failed: #{e.message}")
raise e
end
private
def validate_reauthorization_params!
return if @code.present? && @business_id.present? && @waba_id.present? && @phone_number_id.present? && @inbox_id.present?
raise ArgumentError, 'Code, business_id, waba_id, phone_number_id, and inbox_id are all required for reauthorization'
end
def find_and_validate_channel
inbox = @account.inboxes.find_by(id: @inbox_id)
raise ActiveRecord::RecordNotFound, 'Inbox not found' unless inbox
raise ArgumentError, 'Inbox is not a WhatsApp channel' unless inbox.channel_type == 'Channel::Whatsapp'
channel = inbox.channel
raise ArgumentError, 'Channel is not WhatsApp Cloud provider' unless channel.provider == 'whatsapp_cloud'
channel
end
def create_or_update_channel(waba_info, phone_info, access_token)
existing_channel = find_existing_channel(phone_info[:phone_number])
channel_attributes = build_channel_attributes(waba_info, phone_info, access_token)
if existing_channel
Rails.logger.error("Channel already exists: #{existing_channel.inspect}")
raise "Channel already exists: #{existing_channel.phone_number}"
else
channel = create_new_channel(channel_attributes, phone_info)
register_phone_number(phone_info[:phone_number_id], access_token)
override_waba_webhook(waba_info[:waba_id], channel, access_token)
channel
end
end
def find_existing_channel(phone_number)
Channel::Whatsapp.find_by(account: @account, phone_number: phone_number)
end
def build_channel_attributes(waba_info, phone_info, access_token)
{
phone_number: phone_info[:phone_number],
provider: 'whatsapp_cloud',
provider_config: {
api_key: access_token,
phone_number_id: phone_info[:phone_number_id],
business_account_id: waba_info[:waba_id],
source: 'embedded_signup'
}
}
end
def create_new_channel(attributes, phone_info)
channel = Channel::Whatsapp.create!(account: @account, **attributes)
create_inbox_for_channel(channel, phone_info)
channel.reload
channel
end
def create_inbox_for_channel(channel, phone_info)
Inbox.create!(
account: @account,
name: "#{phone_info[:business_name]} WhatsApp",
channel: channel
)
end
def update_channel_for_reauthorization(channel, phone_info, access_token)
# Update channel with new access token and configuration
channel.update!(
provider_config: channel.provider_config.merge(
'api_key' => access_token,
'phone_number_id' => phone_info[:phone_number_id],
'business_account_id' => @waba_id,
'reauthorized_at' => Time.current.iso8601
)
)
end
end
+43
View File
@@ -0,0 +1,43 @@
# Handles WhatsApp token validation
module Whatsapp::TokenValidator
extend ActiveSupport::Concern
def validate_token_waba_access(access_token, waba_id)
token_debug_data = fetch_token_debug_data(access_token)
waba_scope = extract_waba_scope(token_debug_data)
verify_waba_authorization(waba_scope, waba_id)
end
private
def fetch_token_debug_data(access_token)
response = Faraday.get(
"https://graph.facebook.com/#{whatsapp_api_version}/debug_token",
{
input_token: access_token,
access_token: build_app_access_token
}
)
raise "Token validation failed: #{response.body}" unless response.success?
JSON.parse(response.body)
end
def extract_waba_scope(token_data)
granular_scopes = token_data.dig('data', 'granular_scopes')
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
raise 'No WABA scope found in token' unless waba_scope
waba_scope
end
def verify_waba_authorization(waba_scope, waba_id)
authorized_waba_ids = waba_scope['target_ids'] || []
return if authorized_waba_ids.include?(waba_id)
raise "Token does not have access to WABA #{waba_id}. Authorized WABAs: #{authorized_waba_ids}"
end
end
@@ -116,4 +116,5 @@ json.provider resource.channel.try(:provider)
if resource.whatsapp?
json.message_templates resource.channel.try(:message_templates)
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
json.reauthorization_required resource.channel.try(:reauthorization_required?)
end
+3
View File
@@ -39,6 +39,9 @@
googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>',
googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>',
fbApiVersion: '<%= @global_config['FACEBOOK_API_VERSION'] %>',
whatsappAppId: '<%= @global_config['WHATSAPP_APP_ID'] %>',
whatsappConfigurationId: '<%= @global_config['WHATSAPP_CONFIGURATION_ID'] %>',
whatsappApiVersion: '<%= @global_config['WHATSAPP_API_VERSION'] %>',
signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>',
isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>',
<% if @global_config['IS_ENTERPRISE'] %>
@@ -29,7 +29,6 @@ window.chatwootSettings = {
var BASE_URL = '';
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src= BASE_URL + "/packs/js/sdk.js";
g.defer = true;
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
@@ -0,0 +1 @@
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
-1
View File
@@ -30,7 +30,6 @@ window.chatwootSettings = {
var BASE_URL = '';
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src= BASE_URL + "/packs/js/sdk.js";
g.defer = true;
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
+20
View File
@@ -126,6 +126,26 @@
type: boolean
# ------- End of Facebook Channel Related Config ------- #
# ------- WhatsApp Channel Related Config ------- #
- name: WHATSAPP_APP_ID
display_title: 'WhatsApp App ID'
description: 'The Facebook App ID for WhatsApp Business API integration'
locked: false
- name: WHATSAPP_CONFIGURATION_ID
display_title: 'WhatsApp Configuration ID'
description: 'The Configuration ID for WhatsApp Embedded Signup flow (required for embedded signup)'
locked: false
- name: WHATSAPP_APP_SECRET
display_title: 'WhatsApp App Secret'
description: 'The App Secret for WhatsApp Embedded Signup flow (required for embedded signup)'
locked: false
- name: WHATSAPP_API_VERSION
display_title: 'WhatsApp API Version'
description: 'Configure this if you want to use a different WhatsApp API version. Make sure its prefixed with `v`'
value: 'v22.0'
locked: false
# ------- End of WhatsApp Channel Related Config ------- #
# MARK: Microsoft Email Channel Config
- name: AZURE_APP_ID
display_title: 'Azure App ID'
+8 -5
View File
@@ -63,11 +63,7 @@ Rails.application.routes.draw do
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
end
resources :documents, only: [:index, :show, :create, :destroy] do
collection do
post :upload_pdf
end
end
resources :documents, only: [:index, :show, :create, :destroy]
end
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
delete :avatar, on: :member
@@ -486,6 +482,13 @@ Rails.application.routes.draw do
get 'webhooks/instagram', to: 'webhooks/instagram#verify'
post 'webhooks/instagram', to: 'webhooks/instagram#events'
namespace :whatsapp do
get 'signup', to: 'embedded#new'
get 'signup/callback', to: 'embedded#callback'
post 'embedded_signup', to: 'embedded#embedded_signup'
post 'reauthorize', to: 'embedded#reauthorize'
end
namespace :twitter do
resource :callback, only: [:show]
end
@@ -1,11 +0,0 @@
class AddSourceMetadataToCaptainDocuments < ActiveRecord::Migration[7.1]
def change
add_column :captain_documents, :source_type, :string, default: 'url'
add_column :captain_documents, :content_type, :string
add_column :captain_documents, :file_size, :integer
add_column :captain_documents, :processed_at, :datetime
add_index :captain_documents, :source_type
add_index :captain_documents, :content_type
end
end
+1 -9
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_07_02_075600) do
ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -289,17 +289,9 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_02_075600) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "status", default: 0, null: false
t.integer "document_type", default: 0, null: false
t.string "source_type", default: "url"
t.string "content_type"
t.integer "file_size"
t.datetime "processed_at"
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"
t.index ["content_type"], name: "index_captain_documents_on_content_type"
t.index ["document_type"], name: "index_captain_documents_on_document_type"
t.index ["source_type"], name: "index_captain_documents_on_source_type"
t.index ["status"], name: "index_captain_documents_on_status"
end
@@ -25,8 +25,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
def playground
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
additional_message: params[:message_content],
message_history: message_history
params[:message_content],
message_history
)
render json: response
@@ -5,13 +5,8 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
before_action :set_current_page, only: [:index]
before_action :set_documents, except: [:create]
before_action :set_document, only: [:show, :destroy]
before_action :set_assistant, only: [:create, :upload_pdf]
before_action :set_assistant, only: [:create]
RESULTS_PER_PAGE = 25
# Fixed PDF size limit
MAX_PDF_SIZE = 25.megabytes
ALLOWED_PDF_CONTENT_TYPES = ['application/pdf'].freeze
PDF_MAGIC_NUMBERS = ['%PDF'].freeze
def index
base_query = @documents
@@ -32,19 +27,6 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
render_could_not_create_error(e.message)
end
def upload_pdf
validation_errors = validate_upload_prerequisites
return render_could_not_create_error(validation_errors) if validation_errors
process_pdf_upload
rescue Captain::Document::LimitExceededError => e
handle_limit_exceeded_error(e)
rescue ActiveStorage::FileNotFoundError => e
handle_file_not_found_error(e)
rescue StandardError => e
handle_general_upload_error(e)
end
def destroy
@document.destroy
head :no_content
@@ -61,8 +43,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
end
def set_assistant
assistant_id = action_name == 'upload_pdf' ? pdf_params[:assistant_id] : document_params[:assistant_id]
@assistant = Current.account.captain_assistants.find_by(id: assistant_id)
@assistant = Current.account.captain_assistants.find_by(id: document_params[:assistant_id])
end
def set_current_page
@@ -76,130 +57,4 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
def document_params
params.require(:document).permit(:name, :external_link, :assistant_id)
end
def pdf_params
params.permit(:pdf_document, :assistant_id)
end
def pdf_file_present?
pdf_params[:pdf_document].present?
end
def validate_pdf_file
file = pdf_params[:pdf_document]
file_object_error = validate_file_object(file)
return file_object_error if file_object_error
file_type_error = validate_file_type(file)
return file_type_error if file_type_error
file_size_error = validate_file_size(file)
return file_size_error if file_size_error
{ valid: true }
end
def validate_file_object(file)
return nil if file.respond_to?(:content_type) && file.respond_to?(:size)
{ valid: false, error: 'Invalid file object' }
end
def validate_file_type(file)
return nil if ALLOWED_PDF_CONTENT_TYPES.include?(file.content_type)
{ valid: false, error: 'Invalid file type. Only PDF files are allowed.' }
end
def validate_file_size(file)
return nil if file.size <= MAX_PDF_SIZE
{ valid: false, error: "File size too large. Maximum size is #{MAX_PDF_SIZE / 1.megabyte}MB." }
end
def create_pdf_blob
file = pdf_params[:pdf_document]
ActiveStorage::Blob.create_and_upload!(
io: file.tempfile,
filename: sanitize_filename(file.original_filename),
content_type: file.content_type,
metadata: {
uploaded_by: Current.user&.id,
assistant_id: @assistant.id,
account_id: Current.account.id,
original_filename: file.original_filename
}
)
rescue StandardError => e
Rails.logger.error "Failed to create PDF blob: #{e.message}"
raise ActiveStorage::FileNotFoundError, 'Failed to upload PDF file'
end
def generate_pdf_url(blob)
Rails.application.routes.url_helpers.rails_blob_url(
blob,
host: ENV.fetch('FRONTEND_URL') { Rails.application.config.action_mailer.default_url_options[:host] }
)
end
def sanitize_filename(filename)
return 'document' if filename.blank?
base_name = File.basename(filename, '.pdf')
sanitized = base_name.gsub(/[^\w\s.-]/, '').strip.squeeze(' ')
(sanitized.presence || 'document')
end
def process_pdf_upload
ActiveRecord::Base.transaction do
blob = create_pdf_blob
pdf_url = generate_pdf_url(blob)
@document = @assistant.documents.build(
name: sanitize_filename(pdf_params[:pdf_document].original_filename),
external_link: pdf_url,
source_type: 'pdf_upload',
content_type: pdf_params[:pdf_document].content_type,
file_size: pdf_params[:pdf_document].size
)
@document.save!
log_pdf_upload_success
# Use the same response structure as create action for consistency
render :create
end
end
def log_pdf_upload_success
Rails.logger.info "PDF uploaded successfully - Document ID: #{@document.id}, Assistant ID: #{@assistant.id}, Account ID: #{Current.account.id}"
end
def validate_upload_prerequisites
return 'Missing Assistant' if @assistant.nil?
return 'No PDF file provided' unless pdf_file_present?
validation_result = validate_pdf_file
return validation_result[:error] unless validation_result[:valid]
nil
end
def handle_limit_exceeded_error(error)
Rails.logger.warn "Document limit exceeded for assistant #{@assistant.id}: #{error.message}"
render_could_not_create_error(error.message)
end
def handle_file_not_found_error(error)
Rails.logger.error "PDF file not found during upload: #{error.message}"
render_could_not_create_error('PDF file could not be processed. Please try again.')
end
def handle_general_upload_error(error)
Rails.logger.error "PDF upload failed for assistant #{@assistant&.id}: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render_could_not_create_error('PDF upload failed. Please try again.')
end
end
@@ -103,6 +103,12 @@ slack:
enabled: true
icon: 'icon-slack'
config_key: 'slack'
whatsapp_embedded:
name: 'WhatsApp Embedded'
description: 'Configuration for setting up WhatsApp Embedded Integration'
enabled: true
icon: 'icon-whatsapp-line'
config_key: 'whatsapp_embedded'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify Integration'
@@ -1,7 +1,6 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
MAX_MESSAGE_LENGTH = 10_000
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
retry_on ActiveStorage::FileNotFoundError, attempts: 3
def perform(conversation, assistant)
@conversation = conversation
@@ -14,7 +13,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
generate_and_process_response
end
rescue StandardError => e
raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
raise e if e.is_a?(ActiveStorage::FileNotFoundError)
handle_error(e)
ensure
@@ -27,7 +26,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def generate_and_process_response
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
message_history: collect_previous_messages
@conversation.messages.incoming.last.content,
collect_previous_messages
)
return process_action('handoff') if handoff_requested?
@@ -43,19 +43,39 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.map do |message|
{
content: prepare_multimodal_message_content(message),
role: determine_role(message)
}
{
content: message_content(message),
role: determine_role(message)
}
end
end
def message_content(message)
return message.content if message.content.present?
return 'User has shared a message without content' unless message.attachments.any?
audio_transcriptions = extract_audio_transcriptions(message.attachments)
return audio_transcriptions if audio_transcriptions.present?
'User has shared an attachment'
end
def extract_audio_transcriptions(attachments)
audio_attachments = attachments.where(file_type: :audio)
return '' if audio_attachments.blank?
transcriptions = ''
audio_attachments.each do |attachment|
result = Messages::AudioTranscriptionService.new(attachment).perform
transcriptions += result[:transcriptions] if result[:success]
end
transcriptions
end
def determine_role(message)
message.message_type == 'incoming' ? 'user' : 'assistant'
end
return 'system' if message.content.blank?
def prepare_multimodal_message_content(message)
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
message.message_type == 'incoming' ? 'user' : 'system'
end
def handoff_requested?
@@ -2,63 +2,17 @@ class Captain::Documents::CrawlJob < ApplicationJob
queue_as :low
def perform(document)
if pdf_document?(document)
perform_pdf_extraction(document)
elsif InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
if InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
perform_firecrawl_crawl(document)
else
perform_simple_crawl(document)
end
end
def pdf_document?(document)
return false if document.nil?
pdf_by_metadata?(document) || pdf_by_url?(document)
end
private
include Captain::FirecrawlHelper
def pdf_by_metadata?(document)
pdf_by_source_type?(document) || pdf_by_content_type?(document)
rescue StandardError
false
end
def pdf_by_source_type?(document)
document.respond_to?(:source_type) && document.source_type == 'pdf_upload'
end
def pdf_by_content_type?(document)
document.respond_to?(:content_type) && document.content_type&.include?('application/pdf')
end
def pdf_by_url?(document)
return false if document.external_link.blank?
url = document.external_link.downcase
url.end_with?('.pdf') ||
url.include?('/rails/active_storage/blobs/') ||
(url.include?('blob') && url.include?('pdf'))
end
def perform_pdf_extraction(document)
document.update(status: 'in_progress')
pdf_extraction_service = Captain::Tools::PdfExtractionService.new(document.external_link)
result = pdf_extraction_service.perform
if result[:success] && result[:content].present?
process_pdf_content_chunks(document, result[:content])
else
handle_pdf_extraction_failure(document, result)
end
rescue StandardError => e
handle_pdf_extraction_error(document, e)
end
def perform_simple_crawl(document)
page_links = Captain::Tools::SimplePageCrawlService.new(document.external_link).page_links
@@ -94,42 +48,4 @@ class Captain::Documents::CrawlJob < ApplicationJob
"#{webhook_url}?assistant_id=#{document.assistant_id}&token=#{generate_firecrawl_token(document.assistant_id, document.account_id)}"
end
def process_pdf_content_chunks(document, content_chunks)
Rails.logger.info "PDF extraction successful for document #{document.id}: #{content_chunks.length} chunks will be processed"
content_chunks.each_with_index do |content_chunk, index|
log_chunk_queueing(document, content_chunk, index, content_chunks.length)
queue_pdf_chunk_job(document, content_chunk)
end
document.update(status: 'in_progress', processed_at: nil)
Rails.logger.info "All #{content_chunks.length} chunks queued for processing for document #{document.id}"
end
def log_chunk_queueing(document, content_chunk, index, total_chunks)
page_num = content_chunk[:page_number]
content_length = content_chunk[:content].length
Rails.logger.info "Queueing chunk #{index + 1}/#{total_chunks} for document #{document.id} " \
"(Page #{page_num}, #{content_length} chars)"
end
def queue_pdf_chunk_job(document, content_chunk)
Captain::Tools::PdfExtractionParserJob.perform_later(
assistant_id: document.assistant_id,
pdf_content: content_chunk,
document_id: document.id
)
end
def handle_pdf_extraction_failure(document, result)
error_message = result[:errors]&.join(', ') || 'Failed to extract text from PDF'
Rails.logger.error "PDF extraction failed for document #{document.id}: #{error_message}"
document.update(status: 'available')
end
def handle_pdf_extraction_error(document, error)
Rails.logger.error "PDF extraction failed for document #{document.id}: #{error.message}"
document.update(status: 'available')
end
end
@@ -1,16 +1,10 @@
class Captain::Documents::ResponseBuilderJob < ApplicationJob
queue_as :low
def perform(document, full_content = nil)
# Use full content for FAQ generation if provided (for PDFs), otherwise use document.content
content_for_faqs = full_content || document.content
# Skip processing if no content available
return if content_for_faqs.blank?
def perform(document)
reset_previous_responses(document)
faqs = Captain::Llm::FaqGeneratorService.new(content_for_faqs).generate
faqs = Captain::Llm::FaqGeneratorService.new(document.content).generate
faqs.each do |faq|
create_response(faq, document)
end
@@ -1,226 +0,0 @@
require 'securerandom'
class Captain::Tools::PdfExtractionParserJob < ApplicationJob
queue_as :low
retry_on StandardError, wait: :exponentially_longer, attempts: 3
def perform(assistant_id:, pdf_content:, document_id: nil)
validate_inputs!(assistant_id, pdf_content, document_id)
assistant = load_assistant(assistant_id)
content_data = extract_content_data(pdf_content)
process_document(assistant, content_data, document_id)
rescue ActiveRecord::RecordNotFound => e
handle_record_not_found_error(e, document_id)
rescue StandardError => e
handle_processing_error(e, assistant_id, document_id)
end
private
def validate_inputs!(assistant_id, pdf_content, _document_id)
raise ArgumentError, 'Assistant ID is required' if assistant_id.blank?
raise ArgumentError, 'PDF content is required' if pdf_content.blank?
end
def extract_content_data(pdf_content)
{
content: pdf_content[:content] || '',
page_number: pdf_content[:page_number] || 1,
chunk_index: pdf_content[:chunk_index] || 1,
total_chunks: pdf_content[:total_chunks] || 1
}
end
def load_assistant(assistant_id)
Captain::Assistant.find(assistant_id)
end
def processing_should_skip?(assistant)
exceeded = limit_exceeded?(assistant.account)
Rails.logger.info "Document limit exceeded for account #{assistant.account.id}" if exceeded
exceeded
end
def load_document(document_id)
return nil if document_id.blank?
Captain::Document.find(document_id)
end
def log_chunk_processing(document_id, content_data)
Rails.logger.info "Processing PDF chunk for document #{document_id}: " \
"page #{content_data[:page_number]}, " \
"chunk #{content_data[:chunk_index]}/#{content_data[:total_chunks]}"
end
def update_document_content(document, content_data)
document.update!(
content: content_data[:content],
status: 'available',
processed_at: Time.current
)
# Trigger FAQ generation for this chunk
Captain::Documents::ResponseBuilderJob.perform_later(document)
rescue StandardError => e
Rails.logger.error "Failed to parse PDF content for document #{document.id}: #{e.message}"
raise "Failed to parse PDF data: #{e.message}"
end
def create_new_document(assistant, content_data, document_id = nil)
document_params = build_document_params(assistant, content_data, document_id)
new_document = Captain::Document.create!(document_params)
# Trigger FAQ generation for this chunk
Captain::Documents::ResponseBuilderJob.perform_later(new_document)
new_document
rescue Captain::Document::LimitExceededError
Rails.logger.info "Document limit exceeded for account #{assistant.account.id}"
return false
rescue StandardError => e
Rails.logger.error "Failed to parse PDF content for assistant #{assistant.id}: #{e.message}"
raise "Failed to parse PDF data: #{e.message}"
end
def build_document_params(assistant, content_data, document_id)
title = generate_content_title(
content_data[:content],
content_data[:page_number],
content_data[:chunk_index],
content_data[:total_chunks]
)
external_link = generate_external_link(document_id, content_data)
{
assistant: assistant,
account: assistant.account,
content: content_data[:content],
name: title,
external_link: external_link,
status: 'available',
source_type: 'pdf_upload',
content_type: 'application/pdf'
}
end
def generate_external_link(document_id, content_data)
page_number = content_data[:page_number]
chunk_index = content_data[:chunk_index]
if document_id && page_number && chunk_index
"pdf_chunk_#{document_id}_page_#{page_number}_chunk_#{chunk_index}"
elsif page_number && chunk_index
"pdf_chunk_#{SecureRandom.hex(8)}_page_#{page_number}_chunk_#{chunk_index}"
else
"pdf_chunk_#{SecureRandom.hex(8)}"
end
end
def generate_content_title(content, page_number, chunk_index, total_chunks)
return 'PDF Content' if content.blank?
base_title = extract_base_title(content)
add_page_chunk_info(base_title, page_number, chunk_index, total_chunks)
end
def extract_base_title(content)
first_line = content.split("\n").first&.strip || ''
base_title = if first_line.include?('.')
extract_sentence_title(first_line)
else
first_line
end
# Use generic title for long or blank content
base_title.length > 100 || base_title.blank? ? 'PDF Content' : base_title
end
def extract_sentence_title(first_line)
first_sentence = first_line.split('.').first&.strip || ''
# Only add period if the original content was actually a complete sentence
if first_line.split('.').length > 1 && first_line.split('.')[1].strip.present?
"#{first_sentence}."
else
first_sentence
end
end
def add_page_chunk_info(base_title, page_number, chunk_index, total_chunks)
title_with_info = build_title_with_chunk_info(base_title, page_number, chunk_index, total_chunks)
truncate_title(title_with_info)
end
def build_title_with_chunk_info(base_title, page_number, chunk_index, total_chunks)
return "#{base_title} (Page #{page_number}, Part #{chunk_index}/#{total_chunks})" if total_chunks > 1
return "#{base_title} (Page #{page_number})" if page_number && page_number > 1
base_title
end
def truncate_title(title)
title.length > 255 ? "#{title[0, 252]}..." : title
end
def limit_exceeded?(account)
limits = account.usage_limits.dig(:captain, :documents)
return false unless limits
limits[:current_available].to_i <= 0
end
def handle_record_not_found_error(error, _document_id)
Rails.logger.error "Record not found: #{error.message}"
raise "Failed to parse PDF data: #{error.message}"
end
def handle_processing_error(error, assistant_id, document_id)
Rails.logger.error "Failed to parse PDF content for assistant #{assistant_id}: #{error.message}"
update_document_error_status(document_id, error) if document_id.present?
raise "Failed to parse PDF data: #{error.message}"
end
def update_document_error_status(document_id, error)
return if should_skip_document_update?(error)
document = Captain::Document.find_by(id: document_id)
document&.update(status: 'in_progress')
rescue StandardError => e
Rails.logger.error "Failed to update document status: #{e.message}"
end
def should_skip_document_update?(error)
error.message.include?('Database error')
end
def process_document(assistant, content_data, document_id)
return create_new_document_with_limit_check(assistant, content_data, document_id) if document_id.blank?
process_existing_document(assistant, content_data, document_id)
end
def process_existing_document(assistant, content_data, document_id)
existing_document = Captain::Document.find_by(id: document_id)
if existing_document
log_chunk_processing(document_id, content_data)
update_document_content(existing_document, content_data)
else
create_new_document_with_limit_check(assistant, content_data, document_id)
end
end
def create_new_document_with_limit_check(assistant, content_data, document_id)
result = create_new_document(assistant, content_data, document_id)
return false if result == false # Limits exceeded
result
end
end
+1 -46
View File
@@ -4,13 +4,8 @@
#
# id :bigint not null, primary key
# content :text
# content_type :string
# document_type :integer default(0), not null
# external_link :string not null
# file_size :integer
# name :string
# processed_at :datetime
# source_type :string default("url")
# status :integer default("in_progress"), not null
# created_at :datetime not null
# updated_at :datetime not null
@@ -22,9 +17,6 @@
# index_captain_documents_on_account_id (account_id)
# index_captain_documents_on_assistant_id (assistant_id)
# index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE
# index_captain_documents_on_content_type (content_type)
# index_captain_documents_on_document_type (document_type)
# index_captain_documents_on_source_type (source_type)
# index_captain_documents_on_status (status)
#
class Captain::Document < ApplicationRecord
@@ -37,10 +29,8 @@ class Captain::Document < ApplicationRecord
validates :external_link, presence: true
validates :external_link, uniqueness: { scope: :assistant_id }
validates :content, length: { maximum: 400_000 }
validates :source_type, inclusion: { in: %w[url pdf_upload] }
validates :content, length: { maximum: 200_000 }
before_validation :ensure_account_id
before_validation :set_default_source_type
enum status: {
in_progress: 0,
@@ -67,8 +57,6 @@ class Captain::Document < ApplicationRecord
def enqueue_response_builder_job
return if status != 'available'
# Skip auto-enqueue for PDFs as they handle FAQ generation manually with full content
return if pdf_document?
Captain::Documents::ResponseBuilderJob.perform_later(self)
end
@@ -85,37 +73,4 @@ class Captain::Document < ApplicationRecord
limits = account.usage_limits[:captain][:documents]
raise LimitExceededError, 'Document limit exceeded' unless limits[:current_available].positive?
end
def set_default_source_type
return if source_type.present?
# Determine type without calling pdf_document? to avoid circular dependency
self.source_type = if content_type&.include?('application/pdf') || pdf_url_format?
'pdf_upload'
else
'url'
end
end
# Public method used by CrawlJob to determine if document is a PDF
def pdf_document?
pdf_upload? || pdf_content_type? || pdf_url_format?
end
def pdf_upload?
source_type == 'pdf_upload'
end
def pdf_content_type?
content_type&.include?('application/pdf')
end
def pdf_url_format?
return false if external_link.blank?
url = external_link.downcase
url.end_with?('.pdf') ||
url.include?('/rails/active_storage/blobs/') ||
(url.include?('blob') && url.include?('pdf'))
end
end
@@ -22,8 +22,4 @@ class Captain::AssistantPolicy < ApplicationPolicy
def playground?
true
end
def upload_pdf?
@account_user.administrator?
end
end
@@ -12,16 +12,9 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
register_tools
end
# additional_message: A single message (String) from the user that should be appended to the chat.
# It can be an empty String or nil when you only want to supply historical messages.
# message_history: An Array of already formatted messages that provide the previous context.
# role: The role for the additional_message (defaults to `user`).
#
# NOTE: Parameters are provided as keyword arguments to improve clarity and avoid relying on
# positional ordering.
def generate_response(additional_message: nil, message_history: [], role: 'user')
@messages += message_history
@messages << { role: role, content: additional_message } if additional_message.present?
def generate_response(input, previous_messages = [], role = 'user')
@messages += previous_messages
@messages << { role: role, content: input } if input.present?
request_chat_completion
end
@@ -1,60 +0,0 @@
class Captain::OpenAiMessageBuilderService
pattr_initialize [:message!]
def generate_content
parts = []
parts << text_part(@message.content) if @message.content.present?
parts.concat(attachment_parts(@message.attachments)) if @message.attachments.any?
return 'Message without content' if parts.blank?
return parts.first[:text] if parts.one? && parts.first[:type] == 'text'
parts
end
private
def text_part(text)
{ type: 'text', text: text }
end
def image_part(image_url)
{ type: 'image_url', image_url: { url: image_url } }
end
def attachment_parts(attachments)
image_attachments = attachments.where(file_type: :image)
image_content = image_parts(image_attachments)
transcription = extract_audio_transcriptions(attachments)
transcription_part = text_part(transcription) if transcription.present?
attachment_part = text_part('User has shared an attachment') if attachments.where.not(file_type: %i[image audio]).exists?
[image_content, transcription_part, attachment_part].flatten.compact
end
def image_parts(image_attachments)
image_attachments.each_with_object([]) do |attachment, parts|
url = get_attachment_url(attachment)
parts << image_part(url) if url.present?
end
end
def get_attachment_url(attachment)
return attachment.download_url if attachment.download_url.present?
return attachment.external_url if attachment.external_url.present?
attachment.file.attached? ? attachment.file_url : nil
end
def extract_audio_transcriptions(attachments)
audio_attachments = attachments.where(file_type: :audio)
return '' if audio_attachments.blank?
audio_attachments.map do |attachment|
result = Messages::AudioTranscriptionService.new(attachment).perform
result[:success] ? result[:transcriptions] : ''
end.join
end
end
@@ -1,131 +0,0 @@
module Captain::Tools::PdfContentChunkingConcern
extend ActiveSupport::Concern
private
def chunk_content(page_contents, max_chunk_size: self.class::MAX_CHUNK_SIZE)
return [] if page_contents.blank?
all_chunks = []
global_chunk_index = 0
page_contents.each do |page_content|
page_chunks = split_content_into_chunks(page_content[:content], max_chunk_size)
page_total_chunks = page_chunks.length
page_chunks.each_with_index do |chunk_content, page_chunk_index|
global_chunk_index += 1
all_chunks << build_chunk(chunk_content, page_content[:page_number], page_chunk_index + 1, page_total_chunks)
end
end
all_chunks
end
def split_content_into_chunks(content, max_size)
return [content] if content.length <= max_size
paragraphs = content.split(/\n\s*\n/)
process_paragraphs_into_chunks(paragraphs, max_size, content)
end
def process_paragraphs_into_chunks(paragraphs, max_size, original_content)
chunks = []
current_chunk = ''
paragraphs.each do |paragraph|
chunks, current_chunk = process_single_paragraph(chunks, current_chunk, paragraph, max_size)
end
finalize_chunks(chunks, current_chunk, original_content)
end
def process_single_paragraph(chunks, current_chunk, paragraph, max_size)
return handle_oversized_paragraph(chunks, current_chunk, paragraph, max_size) if paragraph.length > max_size
return handle_paragraph_overflow(chunks, current_chunk, paragraph) if paragraph_causes_overflow?(current_chunk, paragraph, max_size)
combined = combine_paragraph_content(current_chunk, paragraph)
[chunks, combined]
end
def finalize_chunks(chunks, current_chunk, original_content)
add_chunk_if_present(chunks, current_chunk)
chunks.presence || [original_content]
end
def split_paragraph_into_chunks(paragraph, max_size)
sentences = paragraph.split(/(?<=[.!?])\s+/)
process_sentences_into_chunks(sentences, max_size)
end
def process_sentences_into_chunks(sentences, max_size)
chunks = []
current_chunk = ''
sentences.each do |sentence|
chunks, current_chunk = process_single_sentence(chunks, current_chunk, sentence, max_size)
end
add_chunk_if_present(chunks, current_chunk)
chunks
end
def process_single_sentence(chunks, current_chunk, sentence, max_size)
return handle_oversized_sentence(chunks, current_chunk, sentence, max_size) if sentence.length > max_size
return handle_sentence_overflow(chunks, current_chunk, sentence) if sentence_causes_overflow?(current_chunk, sentence, max_size)
combined = combine_sentence_content(current_chunk, sentence)
[chunks, combined]
end
def handle_oversized_paragraph(chunks, current_chunk, paragraph, max_size)
add_chunk_if_present(chunks, current_chunk)
chunks.concat(split_paragraph_into_chunks(paragraph, max_size))
[chunks, '']
end
def handle_paragraph_overflow(chunks, current_chunk, paragraph)
add_chunk_if_present(chunks, current_chunk)
[chunks, paragraph]
end
def paragraph_causes_overflow?(current_chunk, paragraph, max_size)
("#{current_chunk}\n\n#{paragraph}").length > max_size
end
def combine_paragraph_content(current_chunk, paragraph)
current_chunk.blank? ? paragraph : "#{current_chunk}\n\n#{paragraph}"
end
def handle_oversized_sentence(chunks, current_chunk, sentence, max_size)
add_chunk_if_present(chunks, current_chunk)
chunks << sentence[0, max_size]
[chunks, '']
end
def handle_sentence_overflow(chunks, current_chunk, sentence)
add_chunk_if_present(chunks, current_chunk)
[chunks, sentence]
end
def sentence_causes_overflow?(current_chunk, sentence, max_size)
("#{current_chunk} #{sentence}").length > max_size
end
def combine_sentence_content(current_chunk, sentence)
current_chunk.blank? ? sentence : "#{current_chunk} #{sentence}"
end
def add_chunk_if_present(chunks, chunk)
chunks << chunk.strip if chunk.present?
end
def build_chunk(content, page_number, chunk_index, total_chunks)
{
content: content,
page_number: page_number,
chunk_index: chunk_index,
total_chunks: total_chunks
}
end
end
@@ -1,228 +0,0 @@
require 'pdf-reader'
class Captain::Tools::PdfExtractionService
include ActiveModel::Validations
include Captain::Tools::PdfValidationConcern
include Captain::Tools::PdfContentChunkingConcern
attr_reader :pdf_source, :errors
# Fixed PDF processing limits
MAX_PDF_SIZE = 25.megabytes
MAX_CHUNK_SIZE = 10_000
DOWNLOAD_TIMEOUT = 60 # Increased for larger file downloads
def initialize(pdf_source)
@pdf_source = pdf_source
@errors = []
end
def perform
return failure_response(['Invalid PDF source']) if pdf_source.blank?
validation_result = validate_pdf_source
return validation_result unless validation_result[:success]
extract_pdf_with_error_handling
end
def extract_pdf_with_error_handling
process_pdf_extraction
rescue PDF::Reader::MalformedPDFError => e
handle_malformed_pdf_error(e)
rescue PDF::Reader::UnsupportedFeatureError => e
handle_unsupported_feature_error(e)
rescue Down::TimeoutError => e
handle_timeout_error(e)
rescue StandardError => e
handle_general_error(e)
end
def extract_text
case determine_source_type
when :url
active_storage_blob_url? ? extract_from_active_storage_blob : extract_from_url
when :uploaded_file
extract_from_uploaded_file
else
extract_from_file_path
end
end
private
def determine_source_type
return :url if pdf_source.is_a?(String) && pdf_source.start_with?('http')
return :uploaded_file if pdf_source.respond_to?(:tempfile)
:file_path
end
def extract_from_url
temp_file = Down.download(
pdf_source,
max_size: MAX_PDF_SIZE,
open_timeout: DOWNLOAD_TIMEOUT,
read_timeout: DOWNLOAD_TIMEOUT
)
begin
result = extract_from_file(temp_file.path)
ensure
temp_file.close
temp_file.unlink
end
result
end
def extract_from_uploaded_file
extract_from_file(pdf_source.tempfile.path)
end
def extract_from_file_path
extract_from_file(pdf_source)
end
def active_storage_blob_url?
pdf_source.include?('/rails/active_storage/blobs/') ||
(pdf_source.include?('blob') && pdf_source.include?('pdf'))
end
def extract_from_active_storage_blob
blob = find_blob_from_url
return extract_blob_content(blob) if blob
Rails.logger.warn 'ActiveStorage blob not found, falling back to URL download'
extract_from_url
end
def extract_blob_content(blob)
blob.open { |file| extract_from_file(file.path) }
end
def find_blob_from_url
blob_key = extract_blob_key_from_url
return nil if blob_key.blank?
ActiveStorage::Blob.find_by(key: blob_key)
rescue StandardError => e
Rails.logger.error "Error finding blob with key '#{blob_key}': #{e.message}"
nil
end
def extract_blob_key_from_url
blob_key = if pdf_source.include?('/rails/active_storage/blobs/')
extract_key_from_rails_path
elsif pdf_source.include?('/blobs/')
extract_key_from_blob_path
end
blob_key&.split('?')&.first # Remove query parameters
end
def extract_key_from_rails_path
parts = pdf_source.split('/rails/active_storage/blobs/').last.split('/')
parts.length > 1 && parts[0] != 'redirect' ? parts[0] : parts[1]
end
def extract_key_from_blob_path
pdf_source.split('/blobs/').last.split('/').first
end
def extract_from_file(file_path)
text_content = []
PDF::Reader.open(file_path) do |reader|
reader.pages.each_with_index do |page, index|
page_content = extract_page_content(page, index)
text_content << page_content if page_content
end
end
text_content
end
def extract_page_content(page, index)
page_text = page.text
return nil if page_text.blank?
cleaned_text = clean_text(page_text)
return nil if cleaned_text.blank?
{ page_number: index + 1, content: cleaned_text }
rescue StandardError => e
Rails.logger.warn "Failed to extract text from page #{index + 1}: #{e.message}"
nil
end
def clean_text(text)
# Remove form feeds and normalize whitespace
cleaned = text.tr("\f", "\n")
.gsub("\r\n", "\n")
.tr("\r", "\n")
.gsub(/\s+/, ' ')
.gsub(/\n\s*\n\s*\n+/, "\n\n")
.strip
# Remove common PDF artifacts
cleaned = cleaned.gsub(/^\d+\s*$/, '') # Remove standalone page numbers
.gsub(/^[\s\-_=]+$/, '') # Remove separator lines
.strip
(cleaned.presence)
end
def pdf_source_type
case pdf_source
when String
pdf_source.start_with?('http') ? 'URL' : 'file_path'
else
'uploaded_file'
end
end
def log_extraction_success(chunked_content)
total_chars = chunked_content.sum { |chunk| chunk[:content].length }
Rails.logger.info "PDF extraction completed: #{chunked_content.length} chunks, #{total_chars} characters"
end
def process_pdf_extraction
content = extract_text
return failure_response(['No text content found in PDF']) if content.blank?
chunked_content = chunk_content(content)
log_extraction_success(chunked_content)
success_response(chunked_content)
end
def success_response(content)
{ success: true, content: content }
end
def failure_response(errors)
{ success: false, errors: errors }
end
def handle_malformed_pdf_error(error)
Rails.logger.error "Malformed PDF (#{pdf_source_type}): #{error.message}"
failure_response(['Invalid or corrupted PDF format'])
end
def handle_unsupported_feature_error(error)
Rails.logger.error "Unsupported PDF feature (#{pdf_source_type}): #{error.message}"
failure_response(['PDF contains unsupported features'])
end
def handle_timeout_error(error)
Rails.logger.error "PDF download timeout (#{pdf_source_type}): #{error.message}"
failure_response(['PDF download timed out'])
end
def handle_general_error(error)
Rails.logger.error "PDF extraction error (#{pdf_source_type}): #{error.message}"
Rails.logger.error error.backtrace.join("\n")
failure_response(['Failed to process PDF'])
end
end
@@ -1,94 +0,0 @@
module Captain::Tools::PdfValidationConcern
extend ActiveSupport::Concern
private
def validate_pdf_source
case determine_source_type
when :url then validate_url
when :uploaded_file then validate_uploaded_file
else validate_file_path
end
{ success: true }
rescue StandardError => e
Rails.logger.error "PDF validation failed: #{e.message}"
{ success: false, errors: [e.message] }
end
def validate_url
uri = parse_url
validate_url_format(uri)
validate_url_length
validate_url_scheme(uri)
end
def parse_url
URI.parse(pdf_source)
rescue URI::InvalidURIError
raise StandardError, 'Malformed URL'
end
def validate_url_format(uri)
raise StandardError, 'Invalid URL format' unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
def validate_url_length
raise StandardError, 'URL too long' if pdf_source.length > 2048
end
def validate_url_scheme(uri)
raise StandardError, 'Invalid URL scheme' unless %w[http https].include?(uri.scheme)
end
def validate_uploaded_file
validate_file_object
validate_file_size
validate_file_type
validate_file_not_empty
end
def validate_file_object
raise StandardError, 'File object is invalid' unless pdf_source.respond_to?(:size) && pdf_source.respond_to?(:content_type)
end
def validate_file_size
raise StandardError, "File too large (max #{self.class::MAX_PDF_SIZE / 1.megabyte}MB)" if pdf_source.size > self.class::MAX_PDF_SIZE
end
def validate_file_type
raise StandardError, 'Invalid file type' unless pdf_source.content_type == 'application/pdf'
end
def validate_file_not_empty
raise StandardError, 'Empty file' if pdf_source.respond_to?(:empty?) && pdf_source.empty?
end
def validate_file_path
validate_path_presence
validate_path_existence
validate_path_file_size
validate_path_not_empty
validate_path_readable
end
def validate_path_presence
raise StandardError, 'File path is blank' if pdf_source.blank?
end
def validate_path_existence
raise StandardError, 'File does not exist' unless File.exist?(pdf_source)
end
def validate_path_file_size
raise StandardError, "File too large (max #{self.class::MAX_PDF_SIZE / 1.megabyte}MB)" if File.size(pdf_source) > self.class::MAX_PDF_SIZE
end
def validate_path_not_empty
raise StandardError, 'Empty file' if File.empty?(pdf_source)
end
def validate_path_readable
raise StandardError, 'File is not readable' unless File.readable?(pdf_source)
end
end
@@ -4,29 +4,10 @@ module Enterprise::MessageTemplates::HookExecutionService
return unless should_process_captain_response?
return perform_handoff unless inbox.captain_active?
schedule_captain_response
end
private
def schedule_captain_response
job_args = [conversation, conversation.inbox.captain_assistant]
if message.attachments.blank?
Captain::Conversation::ResponseBuilderJob.perform_later(*job_args)
else
wait_time = calculate_attachment_wait_time
Captain::Conversation::ResponseBuilderJob.set(wait: wait_time).perform_later(*job_args)
end
end
def calculate_attachment_wait_time
attachment_count = message.attachments.size
base_wait = 1.second
# Wait longer for more attachments or larger files
additional_wait = [attachment_count * 1, 4].min.seconds
base_wait + additional_wait
Captain::Conversation::ResponseBuilderJob.perform_later(
conversation,
conversation.inbox.captain_assistant
)
end
def should_process_captain_response?
@@ -1,4 +1 @@
json.partial! 'api/v1/models/captain/document', formats: [:json], resource: @document
# Include message for PDF uploads
json.message 'PDF uploaded successfully. Processing will begin shortly.' if @document.source_type == 'pdf_upload'
@@ -0,0 +1,100 @@
require 'rails_helper'
RSpec.describe 'WhatsApp Embedded API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
describe 'GET /whatsapp/signup' do
before do
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return('test_app_id')
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_CONFIGURATION_ID', '').and_return('test_config_id')
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return('test_app_secret')
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', '').and_return('v22.0')
end
context 'when user is authenticated' do
it 'returns configuration for embedded signup' do
get '/whatsapp/signup',
headers: admin.create_new_auth_token,
params: { account_id: account.id }
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).to include(
'status' => 'ready',
'app_id' => 'test_app_id',
'config_id' => 'test_config_id',
'app_secret' => 'test_app_secret'
)
end
end
context 'when user is not authenticated' do
it 'returns unauthorized' do
get '/whatsapp/signup', params: { account_id: account.id }
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST /whatsapp/embedded_signup' do
let(:params) do
{
account_id: account.id,
code: 'auth_code_123',
business_id: '123456789',
waba_id: '987654321',
phone_number_id: '555444333'
}
end
context 'when user is authenticated' do
context 'with missing authorization code' do
it 'returns bad request error' do
params_without_code = params.except(:code)
post '/whatsapp/embedded_signup',
headers: admin.create_new_auth_token,
params: params_without_code
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to eq('Missing authorization code')
end
end
context 'with missing business parameters' do
it 'returns bad request when business_id is missing' do
params_without_business = params.except(:business_id)
post '/whatsapp/embedded_signup',
headers: admin.create_new_auth_token,
params: params_without_business
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to include('Missing required parameters')
end
it 'returns bad request when waba_id is missing' do
params_without_waba = params.except(:waba_id)
post '/whatsapp/embedded_signup',
headers: admin.create_new_auth_token,
params: params_without_waba
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to include('Missing required parameters')
end
end
end
context 'when user is not authenticated' do
it 'returns unauthorized' do
post '/whatsapp/embedded_signup', params: params
expect(response).to have_http_status(:unauthorized)
end
end
end
end
@@ -211,8 +211,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success)
expect(chat_service).to have_received(:generate_response).with(
additional_message: valid_params[:message_content],
message_history: valid_params[:message_history]
valid_params[:message_content],
valid_params[:message_history]
)
expect(json_response[:content]).to eq('Assistant response')
end
@@ -232,8 +232,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success)
expect(chat_service).to have_received(:generate_response).with(
additional_message: params_without_history[:message_content],
message_history: []
params_without_history[:message_content],
[]
)
end
end
@@ -220,16 +220,9 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
context 'with limits exceeded' do
before do
# Create documents first (when there are no limits)
create_list(:captain_document, 5, assistant: assistant, account: account)
account.update_document_usage
# Now set up the limits configuration
config = InstallationConfig.find_or_create_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS')
config.update!(value: captain_limits.to_json)
account.reload # Reload to ensure changes are reflected
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
post "/api/v1/accounts/#{account.id}/captain/documents",
params: valid_attributes,
headers: admin.create_new_auth_token
@@ -295,69 +288,4 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
end
end
end
describe 'POST /api/v1/accounts/:account_id/captain/documents/upload_pdf' do
let(:pdf_file) { Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/files/sample.pdf'), 'application/pdf') }
let(:valid_pdf_params) do
{
pdf_document: pdf_file,
assistant_id: assistant.id
}
end
context 'when it is an un-authenticated user' do
before do
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
params: valid_pdf_params
end
it 'returns unauthorized status' do
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
params: valid_pdf_params,
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin' do
context 'with valid PDF file' do
it 'uploads PDF and creates document' do
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
params: valid_pdf_params,
headers: admin.create_new_auth_token
end.to change(Captain::Document, :count).by(1)
end
it 'returns success status with document data' do
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
params: valid_pdf_params,
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(json_response[:id]).to be_present
expect(json_response[:message]).to eq('PDF uploaded successfully. Processing will begin shortly.')
end
end
context 'without PDF file' do
before do
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
params: { assistant_id: assistant.id },
headers: admin.create_new_auth_token
end
it 'returns unprocessable entity status' do
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
end
end
@@ -30,142 +30,5 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
account.reload
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
end
context 'when message contains an image' do
let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') }
let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') }
before do
image_attachment
end
it 'includes image URL directly in the message content for OpenAI vision analysis' do
# Expect the generate_response to receive multimodal content with image URL
expect(mock_llm_chat_service).to receive(:generate_response) do |**kwargs|
history = kwargs[:message_history]
last_entry = history.last
expect(last_entry[:content]).to be_an(Array)
expect(last_entry[:content].any? { |part| part[:type] == 'text' && part[:text] == 'Can you help with this error?' }).to be true
expect(last_entry[:content].any? do |part|
part[:type] == 'image_url' && part[:image_url][:url] == 'https://example.com/error.jpg'
end).to be true
{ 'response' => 'I can see the error in your image. It appears to be a database connection issue.' }
end
described_class.perform_now(conversation, assistant)
end
end
end
describe 'retry mechanisms for image processing' do
let(:conversation) { create(:conversation, inbox: inbox, account: account) }
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
let(:mock_message_builder) { instance_double(Captain::OpenAiMessageBuilderService) }
before do
create(:message, conversation: conversation, content: 'Hello with image', message_type: :incoming)
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
allow(Captain::OpenAiMessageBuilderService).to receive(:new).with(message: anything).and_return(mock_message_builder)
allow(mock_message_builder).to receive(:generate_content).and_return('Hello with image')
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Test response' })
end
context 'when ActiveStorage::FileNotFoundError occurs' do
it 'handles file errors and triggers handoff' do
allow(mock_message_builder).to receive(:generate_content)
.and_raise(ActiveStorage::FileNotFoundError, 'Image file not found')
# For retryable errors, the job should handle them and proceed with handoff
described_class.perform_now(conversation, assistant)
# Verify handoff occurred due to repeated failures
expect(conversation.reload.status).to eq('open')
end
it 'succeeds when no error occurs' do
# Don't raise any error, should succeed normally
allow(mock_message_builder).to receive(:generate_content)
.and_return('Image content processed successfully')
described_class.perform_now(conversation, assistant)
expect(conversation.messages.outgoing.count).to eq(1)
expect(conversation.messages.outgoing.last.content).to eq('Test response')
end
end
context 'when Faraday::BadRequestError occurs' do
it 'handles API errors and triggers handoff' do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_raise(Faraday::BadRequestError, 'Bad request to image service')
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
end
it 'succeeds when no error occurs' do
# Don't raise any error, should succeed normally
allow(mock_llm_chat_service).to receive(:generate_response)
.and_return({ 'response' => 'Response after retry' })
described_class.perform_now(conversation, assistant)
expect(conversation.messages.outgoing.last.content).to eq('Response after retry')
end
end
context 'when image processing fails permanently' do
before do
allow(mock_message_builder).to receive(:generate_content)
.and_raise(ActiveStorage::FileNotFoundError, 'Image permanently unavailable')
end
it 'triggers handoff after max retries' do
# Since perform_now re-raises retryable errors, simulate the final failure after retries
allow(mock_message_builder).to receive(:generate_content)
.and_raise(StandardError, 'Max retries exceeded')
expect(ChatwootExceptionTracker).to receive(:new).and_call_original
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
end
end
context 'when non-retryable error occurs' do
let(:standard_error) { StandardError.new('Generic error') }
before do
allow(mock_llm_chat_service).to receive(:generate_response).and_raise(standard_error)
end
it 'handles error and triggers handoff' do
expect(ChatwootExceptionTracker).to receive(:new)
.with(standard_error, account: account)
.and_call_original
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
end
it 'ensures Current.executed_by is reset' do
expect(Current).to receive(:executed_by=).with(assistant)
expect(Current).to receive(:executed_by=).with(nil)
described_class.perform_now(conversation, assistant)
end
end
end
describe 'job configuration' do
it 'has retry_on configuration for retryable errors' do
expect(described_class).to respond_to(:retry_on)
end
it 'defines MAX_MESSAGE_LENGTH constant' do
expect(described_class::MAX_MESSAGE_LENGTH).to eq(10_000)
end
end
end
@@ -104,131 +104,6 @@ RSpec.describe Captain::Documents::CrawlJob, type: :job do
expect(simple_crawler).to receive(:page_links)
described_class.perform_now(document)
end
context 'when document is a PDF' do
let(:pdf_document) { create(:captain_document, external_link: 'https://example.com/document.pdf') }
let(:pdf_service) { instance_double(Captain::Tools::PdfExtractionService) }
let(:pdf_content) do
[
{ content: 'PDF page 1 content', page_number: 1, chunk_index: 1, total_chunks: 1 },
{ content: 'PDF page 2 content', page_number: 2, chunk_index: 1, total_chunks: 1 }
]
end
before do
allow(Captain::Tools::PdfExtractionService)
.to receive(:new)
.with(pdf_document.external_link)
.and_return(pdf_service)
end
it 'processes PDF using PdfExtractionService when extraction succeeds' do
allow(pdf_service).to receive(:perform).and_return({
success: true,
content: pdf_content
})
expect(pdf_service).to receive(:perform)
described_class.perform_now(pdf_document)
end
it 'enqueues PdfExtractionParserJob for each content chunk when extraction succeeds' do
allow(pdf_service).to receive(:perform).and_return({
success: true,
content: pdf_content
})
pdf_content.each do |chunk|
expect(Captain::Tools::PdfExtractionParserJob)
.to receive(:perform_later)
.with(
assistant_id: pdf_document.assistant_id,
pdf_content: chunk,
document_id: pdf_document.id
)
end
described_class.perform_now(pdf_document)
end
it 'updates document status to processing when extraction succeeds' do
allow(pdf_service).to receive(:perform).and_return({
success: true,
content: pdf_content
})
allow(Captain::Tools::PdfExtractionParserJob).to receive(:perform_later)
expect(pdf_document).to receive(:update).with(status: 'processing').twice
described_class.perform_now(pdf_document)
end
it 'updates document status to failed with error message when extraction fails' do
allow(pdf_service).to receive(:perform).and_return({
success: false,
errors: ['Invalid PDF format']
})
expect(pdf_document).to receive(:update).with(status: 'processing').once
expect(pdf_document).to receive(:update).with(
status: 'failed',
error_message: 'Invalid PDF format'
).once
described_class.perform_now(pdf_document)
end
it 'logs the error when extraction fails' do
allow(pdf_service).to receive(:perform).and_return({
success: false,
errors: ['Invalid PDF format']
})
expect(Rails.logger).to receive(:error).with(/PDF extraction failed/)
described_class.perform_now(pdf_document)
end
it 'handles exceptions gracefully during PDF extraction' do
allow(pdf_service).to receive(:perform).and_raise(StandardError, 'Network error')
expect(pdf_document).to receive(:update).with(status: 'processing').once
expect(pdf_document).to receive(:update).with(
status: 'failed',
error_message: 'Network error'
).once
described_class.perform_now(pdf_document)
end
it 'logs exceptions during PDF extraction' do
allow(pdf_service).to receive(:perform).and_raise(StandardError, 'Network error')
expect(Rails.logger).to receive(:error).with(/PDF extraction failed/)
described_class.perform_now(pdf_document)
end
end
end
describe '#pdf_document?' do
let(:job) { described_class.new }
it 'detects PDF by file extension' do
pdf_doc = build(:captain_document, external_link: 'https://example.com/file.pdf')
expect(job.send(:pdf_document?, pdf_doc)).to be true
end
it 'detects PDF by content type' do
pdf_doc = build(:captain_document, content_type: 'application/pdf')
expect(job.send(:pdf_document?, pdf_doc)).to be true
end
it 'detects PDF by source type' do
pdf_doc = build(:captain_document, source_type: 'pdf_upload')
expect(job.send(:pdf_document?, pdf_doc)).to be true
end
it 'returns false for non-PDF documents' do
web_doc = build(:captain_document, external_link: 'https://example.com/page.html')
expect(job.send(:pdf_document?, web_doc)).to be false
end
it 'is case insensitive for file extensions' do
pdf_doc = build(:captain_document, external_link: 'https://example.com/file.PDF')
expect(job.send(:pdf_document?, pdf_doc)).to be true
end
end
end
end
@@ -1,243 +0,0 @@
require 'rails_helper'
RSpec.describe Captain::Tools::PdfExtractionParserJob, type: :job do
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:pdf_content) do
{
content: 'This is sample PDF content extracted from a document. It contains useful information for FAQ generation.',
page_number: 1,
chunk_index: 1,
total_chunks: 1
}
end
let(:captain_limits) do
{
startups: { documents: 5, responses: 100 }
}.with_indifferent_access
end
before do
# Mock usage limits
allow(account).to receive(:usage_limits).and_return(
captain: {
documents: {
current_available: 3
}
}
)
end
describe '#perform' do
context 'when limits are not exceeded' do
it 'processes PDF content successfully' do
expect do
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content
)
end.to change(Captain::Document, :count).by(1)
end
it 'creates captain documents with correct attributes' do
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content
)
document = Captain::Document.last
expect(document.assistant_id).to eq(assistant.id)
expect(document.content).to include('sample PDF content')
expect(document.name).to include('This is sample PDF content extracted from a document')
expect(document.status).to eq('available')
end
it 'generates appropriate title for single chunk' do
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content
)
document = Captain::Document.last
expect(document.name).to eq('This is sample PDF content extracted from a document.')
end
it 'generates appropriate title for multiple chunks' do
multi_chunk_content = pdf_content.merge(
chunk_index: 2,
total_chunks: 3,
page_number: 2
)
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: multi_chunk_content
)
document = Captain::Document.last
expect(document.name).to include('Page 2, Part 2/3')
end
it 'creates document with unique external link' do
document_id = 123
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content,
document_id: document_id
)
document = Captain::Document.last
expect(document.external_link).to eq("pdf_chunk_#{document_id}_page_1_chunk_1")
end
end
context 'when limits are exceeded' do
before do
# Stub the Captain::Document.create! method to raise limit error
# This is more specific than allow_any_instance_of
allow(Captain::Document).to receive(:create!).and_raise(Captain::Document::LimitExceededError, 'Document limit exceeded')
end
it 'does not create documents when limit exceeded' do
expect do
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content
)
end.not_to(change(Captain::Document, :count))
end
it 'logs limit exceeded message' do
expect(Rails.logger).to receive(:info).with(/Document limit exceeded/)
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content
)
end
end
context 'when document_id is provided' do
let(:document) { create(:captain_document, assistant: assistant, status: 'in_progress') }
it 'updates document status to available' do
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content,
document_id: document.id
)
document.reload
expect(document.status).to eq('available')
end
end
context 'when an error occurs' do
let(:document) { create(:captain_document, assistant: assistant, status: 'in_progress') }
before do
allow(Captain::Document).to receive(:create!).and_raise(StandardError, 'Database error')
# Mock the specific document instance that will be found by the job
allow(Captain::Document).to receive(:find_by).with(id: document.id).and_return(document)
allow(document).to receive(:update!).and_raise(StandardError, 'Database error')
end
it 'raises an error with descriptive message' do
expect do
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content,
document_id: document.id
)
end.to raise_error(/Failed to parse PDF data/)
end
it 'raises error and updates main document if provided' do
expect do
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content,
document_id: document.id
)
end.to raise_error(/Failed to parse PDF data/)
# The main document should remain in its original state when an error occurs
document.reload
expect(document.status).to eq('in_progress')
end
it 'logs the error' do
expect(Rails.logger).to receive(:error).with(/Failed to parse PDF content/).at_least(:once)
begin
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content
)
rescue StandardError
# Expected to raise error
end
end
end
end
describe 'private methods' do
let(:job) { described_class.new }
describe '#generate_content_title' do
it 'uses first line as title when appropriate' do
content = "Introduction to Machine Learning\n\nThis document covers the basics..."
title = job.send(:generate_content_title, content, 1, 1, 1)
expect(title).to eq('Introduction to Machine Learning')
end
it 'uses generic title for long first lines' do
content = "#{('A' * 200)}\n\nThis is the rest of the content..."
title = job.send(:generate_content_title, content, 1, 1, 1)
expect(title).to eq('PDF Content')
end
it 'adds page information for multiple pages' do
content = 'Sample content'
title = job.send(:generate_content_title, content, 3, 1, 1)
expect(title).to eq('Sample content (Page 3)')
end
it 'adds chunk information for multiple chunks' do
content = 'Sample content'
title = job.send(:generate_content_title, content, 2, 2, 4)
expect(title).to eq('Sample content (Page 2, Part 2/4)')
end
it 'truncates long titles to database limit' do
long_content = 'A' * 300
title = job.send(:generate_content_title, long_content, 1, 1, 1)
expect(title.length).to be <= 255
end
end
describe '#limit_exceeded?' do
it 'returns true when limit is zero' do
allow(account).to receive(:usage_limits).and_return(
captain: { documents: { current_available: 0 } }
)
expect(job.send(:limit_exceeded?, account)).to be true
end
it 'returns true when limit is negative' do
allow(account).to receive(:usage_limits).and_return(
captain: { documents: { current_available: -1 } }
)
expect(job.send(:limit_exceeded?, account)).to be true
end
it 'returns false when limit is positive' do
allow(account).to receive(:usage_limits).and_return(
captain: { documents: { current_available: 5 } }
)
expect(job.send(:limit_exceeded?, account)).to be false
end
end
end
end
@@ -1,310 +0,0 @@
require 'rails_helper'
RSpec.describe Captain::OpenAiMessageBuilderService do
subject(:service) { described_class.new(message: message) }
let(:message) { create(:message, content: 'Hello world') }
describe '#generate_content' do
context 'when message has only text content' do
it 'returns the text content directly' do
expect(service.generate_content).to eq('Hello world')
end
end
context 'when message has no content and no attachments' do
let(:message) { create(:message, content: nil) }
it 'returns default message' do
expect(service.generate_content).to eq('Message without content')
end
end
context 'when message has text content and attachments' do
before do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
attachment.save!
end
it 'returns an array of content parts' do
result = service.generate_content
expect(result).to be_an(Array)
expect(result).to include({ type: 'text', text: 'Hello world' })
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
end
end
context 'when message has only non-text attachments' do
let(:message) { create(:message, content: nil) }
before do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
attachment.save!
end
it 'returns an array of content parts without text' do
result = service.generate_content
expect(result).to be_an(Array)
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
expect(result).not_to include(hash_including(type: 'text', text: 'Hello world'))
end
end
end
describe '#attachment_parts' do
let(:message) { create(:message, content: nil) }
let(:attachments) { message.attachments }
context 'with image attachments' do
before do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
attachment.save!
end
it 'includes image parts' do
result = service.send(:attachment_parts, attachments)
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
end
end
context 'with audio attachments' do
let(:audio_attachment) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
attachment.save!
attachment
end
before do
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio transcription text' })
)
end
it 'includes transcription text part' do
audio_attachment # trigger creation
result = service.send(:attachment_parts, attachments)
expect(result).to include({ type: 'text', text: 'Audio transcription text' })
end
end
context 'with other file types' do
before do
attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
attachment.save!
end
it 'includes generic attachment message' do
result = service.send(:attachment_parts, attachments)
expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
end
end
context 'with mixed attachment types' do
let(:image_attachment) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
attachment.save!
attachment
end
let(:audio_attachment) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
attachment.save!
attachment
end
let(:document_attachment) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
attachment.save!
attachment
end
before do
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio text' })
)
end
it 'includes all relevant parts' do
image_attachment # trigger creation
audio_attachment # trigger creation
document_attachment # trigger creation
result = service.send(:attachment_parts, attachments)
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
expect(result).to include({ type: 'text', text: 'Audio text' })
expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
end
end
end
describe '#image_parts' do
let(:message) { create(:message, content: nil) }
context 'with valid image attachments' do
let(:image1) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image1.jpg')
attachment.save!
attachment
end
let(:image2) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image2.jpg')
attachment.save!
attachment
end
it 'returns image parts for all valid images' do
image1 # trigger creation
image2 # trigger creation
image_attachments = message.attachments.where(file_type: :image)
result = service.send(:image_parts, image_attachments)
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image1.jpg' } })
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image2.jpg' } })
end
end
context 'with image attachments without URLs' do
let(:image_attachment) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: nil)
attachment.save!
attachment
end
before do
allow(image_attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
end
it 'skips images without valid URLs' do
image_attachment # trigger creation
image_attachments = message.attachments.where(file_type: :image)
result = service.send(:image_parts, image_attachments)
expect(result).to be_empty
end
end
end
describe '#get_attachment_url' do
let(:attachment) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image)
attachment.save!
attachment
end
context 'when attachment has external_url' do
before { attachment.update(external_url: 'https://example.com/image.jpg') }
it 'returns external_url' do
expect(service.send(:get_attachment_url, attachment)).to eq('https://example.com/image.jpg')
end
end
context 'when attachment has attached file' do
before do
attachment.update(external_url: nil)
allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: true))
allow(attachment).to receive(:file_url).and_return('https://local.com/file.jpg')
allow(attachment).to receive(:download_url).and_return('')
end
it 'returns file_url' do
expect(service.send(:get_attachment_url, attachment)).to eq('https://local.com/file.jpg')
end
end
context 'when attachment has no URL or file' do
before do
attachment.update(external_url: nil)
allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
end
it 'returns nil' do
expect(service.send(:get_attachment_url, attachment)).to be_nil
end
end
end
describe '#extract_audio_transcriptions' do
let(:message) { create(:message, content: nil) }
context 'with no audio attachments' do
it 'returns empty string' do
result = service.send(:extract_audio_transcriptions, message.attachments)
expect(result).to eq('')
end
end
context 'with successful audio transcriptions' do
let(:audio1) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
attachment.save!
attachment
end
let(:audio2) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
attachment.save!
attachment
end
before do
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio1).and_return(
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'First audio text. ' })
)
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio2).and_return(
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Second audio text.' })
)
end
it 'concatenates all successful transcriptions' do
audio1 # trigger creation
audio2 # trigger creation
attachments = message.attachments
result = service.send(:extract_audio_transcriptions, attachments)
expect(result).to eq('First audio text. Second audio text.')
end
end
context 'with failed audio transcriptions' do
let(:audio_attachment) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
attachment.save!
attachment
end
before do
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
instance_double(Messages::AudioTranscriptionService, perform: { success: false, transcriptions: nil })
)
end
it 'returns empty string for failed transcriptions' do
audio_attachment # trigger creation
attachments = message.attachments
result = service.send(:extract_audio_transcriptions, attachments)
expect(result).to eq('')
end
end
end
describe 'private helper methods' do
describe '#text_part' do
it 'returns correct text part format' do
result = service.send(:text_part, 'Hello world')
expect(result).to eq({ type: 'text', text: 'Hello world' })
end
end
describe '#image_part' do
it 'returns correct image part format' do
result = service.send(:image_part, 'https://example.com/image.jpg')
expect(result).to eq({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
end
end
end
end
@@ -1,247 +0,0 @@
require 'rails_helper'
RSpec.describe Captain::Tools::PdfExtractionService do
let(:service) { described_class.new(pdf_source) }
let(:sample_pdf_path) { Rails.root.join('spec/fixtures/files/sample.pdf') }
describe '#initialize' do
context 'with valid PDF path' do
let(:pdf_source) { sample_pdf_path.to_s }
it 'initializes with the PDF source' do
expect(service.pdf_source).to eq(pdf_source)
end
end
context 'with URL' do
let(:pdf_source) { 'https://example.com/sample.pdf' }
it 'initializes with the PDF URL' do
expect(service.pdf_source).to eq(pdf_source)
end
end
end
describe '#perform' do
context 'with blank PDF source' do
let(:pdf_source) { nil }
it 'returns error for blank source' do
result = service.perform
expect(result[:success]).to be false
expect(result[:errors]).to include('Invalid PDF source')
end
end
context 'with valid PDF file path' do
let(:pdf_source) { sample_pdf_path.to_s }
before do
# Ensure the sample PDF exists
skip 'Sample PDF file not available for testing' unless File.exist?(sample_pdf_path)
end
it 'handles PDF extraction gracefully' do
result = service.perform
expect(result).to have_key(:success)
expect(result).to have_key(:content).or have_key(:errors)
if result[:success]
expect(result[:content]).to be_an(Array)
else
expect(result[:errors]).to be_an(Array)
end
end
it 'returns structured result format' do
result = service.perform
if result[:success] && result[:content].present?
first_chunk = result[:content].first
expect(first_chunk).to have_key(:content)
expect(first_chunk).to have_key(:page_number)
expect(first_chunk).to have_key(:chunk_index)
expect(first_chunk).to have_key(:total_chunks)
else
expect(result[:errors]).to be_present
end
end
end
context 'with malformed PDF' do
let(:pdf_source) { Rails.root.join('spec/fixtures/files/sample.txt').to_s }
it 'handles malformed PDF gracefully' do
result = service.perform
expect(result[:success]).to be false
expect(result[:errors]).to be_an(Array)
end
end
context 'with HTTP URL' do
let(:pdf_source) { 'https://example.com/sample.pdf' }
it 'attempts to download and process PDF from URL' do
temp_file = instance_double(Tempfile, path: sample_pdf_path.to_s, close: nil, unlink: nil)
allow(Down).to receive(:download).and_return(temp_file)
allow(File).to receive(:exist?).and_return(true)
# Mock PDF reader
mock_page = instance_double(PDF::Reader::Page, text: 'Sample PDF content')
mock_pages = [mock_page]
mock_reader = instance_double(PDF::Reader, pages: mock_pages)
allow(PDF::Reader).to receive(:open).and_yield(mock_reader)
result = service.perform
expect(result[:success]).to be true
end
end
end
describe '#extract_text' do
let(:pdf_source) { sample_pdf_path.to_s }
before do
skip 'Sample PDF file not available for testing' unless File.exist?(sample_pdf_path)
end
it 'handles text extraction gracefully' do
expect { service.extract_text }.not_to raise_error(NoMethodError)
# Should either succeed or raise a PDF::Reader error that gets caught
begin
extracted_content = service.extract_text
expect(extracted_content).to be_an(Array)
if extracted_content.any?
page_content = extracted_content.first
expect(page_content).to have_key(:page_number)
expect(page_content).to have_key(:content)
end
rescue PDF::Reader::MalformedPDFError
# This is expected for malformed PDFs
# Test passes if we reach this point
end
end
end
describe 'private methods' do
let(:pdf_source) { sample_pdf_path.to_s }
describe '#clean_text' do
it 'cleans text content properly' do
dirty_text = " \f Some text with \r\n line breaks \n\n\n and extra spaces "
cleaned = service.send(:clean_text, dirty_text)
expect(cleaned).not_to include("\f")
expect(cleaned).not_to include("\r")
expect(cleaned).to include('Some text with')
expect(cleaned.strip).to eq(cleaned)
end
it 'returns nil for blank text' do
cleaned = service.send(:clean_text, " \n\n ")
expect(cleaned).to be_nil
end
end
describe '#chunk_content' do
let(:page_contents) do
[
{ page_number: 1, content: 'Short content' },
{ page_number: 2, content: 'A' * 3000 } # Long content that needs chunking
]
end
it 'chunks content appropriately' do
chunks = service.send(:chunk_content, page_contents, max_chunk_size: 1000)
expect(chunks.length).to be >= 2 # Should have at least 2 chunks
# Check first chunk (short content)
first_chunk = chunks.first
expect(first_chunk[:page_number]).to eq(1)
expect(first_chunk[:total_chunks]).to eq(1)
# Check that long content was processed
long_content_chunks = chunks.select { |c| c[:page_number] == 2 }
expect(long_content_chunks.length).to be >= 1
# Verify long content was split appropriately
total_long_content_length = long_content_chunks.sum { |c| c[:content].length }
expect(total_long_content_length).to be > 0
end
end
describe '#split_content_into_chunks' do
it 'splits content by paragraphs first' do
content = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph."
chunks = service.send(:split_content_into_chunks, content, 50)
expect(chunks.length).to be >= 2
expect(chunks.join(' ')).to include('First paragraph')
expect(chunks.join(' ')).to include('Second paragraph')
end
it 'splits by sentences when paragraphs are too large' do
long_paragraph = "#{('A' * 100)}. #{('B' * 100)}. #{('C' * 100)}."
chunks = service.send(:split_content_into_chunks, long_paragraph, 150)
expect(chunks.length).to be > 1
end
end
end
describe 'file validation' do
context 'with uploaded file object' do
let(:uploaded_file) do
temp_file = instance_double(Tempfile, path: sample_pdf_path.to_s)
instance_double(
ActionDispatch::Http::UploadedFile,
tempfile: temp_file,
content_type: 'application/pdf',
size: 1024
)
end
let(:pdf_source) { uploaded_file }
it 'validates uploaded file properties' do
expect { service.send(:validate_uploaded_file) }.not_to raise_error
end
end
context 'with oversized file' do
let(:uploaded_file) do
temp_file = instance_double(Tempfile, path: sample_pdf_path.to_s)
instance_double(
ActionDispatch::Http::UploadedFile,
tempfile: temp_file,
content_type: 'application/pdf',
size: 30.megabytes
)
end
let(:pdf_source) { uploaded_file }
it 'raises error for oversized file' do
expect { service.send(:validate_uploaded_file) }.to raise_error(/File too large/)
end
end
context 'with invalid content type' do
let(:uploaded_file) do
temp_file = instance_double(Tempfile, path: sample_pdf_path.to_s)
instance_double(
ActionDispatch::Http::UploadedFile,
tempfile: temp_file,
content_type: 'text/plain',
size: 1024
)
end
let(:pdf_source) { uploaded_file }
it 'raises error for invalid content type' do
expect { service.send(:validate_uploaded_file) }.to raise_error('Invalid file type')
end
end
end
end
-53
View File
@@ -1,53 +0,0 @@
%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
/MediaBox [0 0 612 792]
/Contents 4 0 R
>>
endobj
4 0 obj
<<
/Length 44
>>
stream
BT
/F1 12 Tf
72 720 Td
(Sample PDF for testing) Tj
ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000202 00000 n
trailer
<<
/Size 5
/Root 1 0 R
>>
startxref
294
%%EOF
-2
View File
@@ -1,2 +0,0 @@
This is a sample text file for testing purposes.
It contains some basic text content that can be used in tests.
-62
View File
@@ -1,62 +0,0 @@
%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
/MediaBox [0 0 612 792]
/Contents 4 0 R
/Resources <<
/Font <<
/F1 5 0 R
>>
>>
>>
endobj
4 0 obj
<<
/Length 54
>>
stream
BT
/F1 12 Tf
72 720 Td
(Hello World from PDF\!) Tj
ET
endstream
endobj
5 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
endobj
xref
0 6
0000000000 65535 f
0000000010 00000 n
0000000060 00000 n
0000000117 00000 n
0000000254 00000 n
0000000350 00000 n
trailer
<<
/Size 6
/Root 1 0 R
>>
startxref
421
%%EOF
+1 -1
View File
@@ -262,7 +262,7 @@ RSpec.describe Inbox do
describe '#sanitized_name' do
context 'when inbox name contains forbidden characters' do
it 'removes forbidden and spam-trigger characters' do
inbox = FactoryBot.build(:inbox, name: 'Test/Name\\With<Bad>@Characters"And\'Quotes!#$%')
inbox = FactoryBot.build(:inbox, name: 'Test/Name\\With<Bad>@Characters"And\';:Quotes!#$%')
expect(inbox.sanitized_name).to eq('Test/NameWithBadCharactersAnd\'Quotes')
end
end
@@ -0,0 +1,700 @@
require 'rails_helper'
describe Whatsapp::EmbeddedSignupService do
let(:account) { create(:account) }
let(:code) { 'test_authorization_code' }
let(:business_id) { 'test_business_id' }
let(:waba_id) { 'test_waba_id' }
let(:phone_number_id) { 'test_phone_number_id' }
let(:access_token) { 'test_access_token' }
let(:app_id) { 'test_app_id' }
let(:app_secret) { 'test_app_secret' }
let(:api_version) { 'v22.0' }
let(:service) do
described_class.new(
account: account,
code: code,
business_id: business_id,
waba_id: waba_id,
phone_number_id: phone_number_id
)
end
before do
# Mock global configuration
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return(app_id)
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return(app_secret)
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', 'v22.0').and_return(api_version)
allow(GlobalConfig).to receive(:clear_cache)
# Mock environment variables - allow any calls to ENV.fetch
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com')
allow(ENV).to receive(:fetch).with('DISABLE_ENTERPRISE', false).and_return(true)
# Mock ChatwootApp enterprise checks
allow(ChatwootApp).to receive(:enterprise?).and_return(false)
# NOTE: Specific HTTP request stubs are defined in individual test contexts
end
describe '#perform' do
context 'when all parameters are valid' do
before do
# Stub the token exchange
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub the phone numbers fetch
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: '1234567890',
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub the token validation
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: [waba_id]
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub the provider validation request (WhatsApp Cloud)
stub_request(:get, "https://graph.facebook.com/v14.0/#{waba_id}/message_templates")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: { data: [] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub the phone number registration
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub the webhook subscription
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'successfully creates a new WhatsApp channel' do
expect { service.perform }.not_to raise_error
channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
expect(channel).not_to be_nil
expect(channel.provider).to eq('whatsapp_cloud')
expect(channel.provider_config['api_key']).to eq(access_token)
expect(channel.provider_config['phone_number_id']).to eq(phone_number_id)
expect(channel.provider_config['business_account_id']).to eq(waba_id)
expect(channel.provider_config['source']).to eq('embedded_signup')
end
it 'creates an inbox for the channel' do
service.perform
channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
inbox = Inbox.find_by(account: account, channel: channel)
expect(inbox).not_to be_nil
expect(inbox.name).to eq('Test Business WhatsApp')
end
it 'registers the phone number' do
service.perform
expect(WebMock).to have_requested(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
.with(
body: {
messaging_product: 'whatsapp',
pin: '212834'
}.to_json
)
end
it 'sets up webhook subscription' do
service.perform
channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
callback_url = "https://app.chatwoot.com/webhooks/whatsapp/#{channel.phone_number}"
expect(WebMock).to have_requested(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
body: hash_including(
override_callback_uri: callback_url,
verify_token: channel.provider_config['webhook_verify_token']
)
)
end
end
context 'when required parameters are missing' do
it 'raises an error when code is missing' do
service = described_class.new(
account: account,
code: '',
business_id: business_id,
waba_id: waba_id,
phone_number_id: phone_number_id
)
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
end
it 'raises an error when business_id is missing' do
service = described_class.new(
account: account,
code: code,
business_id: '',
waba_id: waba_id,
phone_number_id: phone_number_id
)
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
end
it 'raises an error when waba_id is missing' do
service = described_class.new(
account: account,
code: code,
business_id: business_id,
waba_id: '',
phone_number_id: phone_number_id
)
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
end
it 'raises an error when phone_number_id is missing' do
service = described_class.new(
account: account,
code: code,
business_id: business_id,
waba_id: waba_id,
phone_number_id: ''
)
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
end
end
context 'when channel already exists' do
before do
# Stub all the required requests for successful flow
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: '1234567890',
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: [waba_id]
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub 360Dialog provider validation (for existing channel creation)
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, 'https://waba.360dialog.io/v1/configs/templates')
.to_return(
status: 200,
body: { templates: [] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Create existing channel
create(:channel_whatsapp, account: account, phone_number: '+1234567890')
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Channel already exists/)
end
end
context 'when token exchange fails' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(status: 400, body: { error: 'Invalid code' }.to_json)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Token exchange failed/)
end
end
context 'when token has no access to WABA' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: '1234567890',
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: ['different_waba_id']
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Token does not have access to WABA/)
end
end
context 'when phone numbers fetch fails' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(status: 400, body: { error: 'Phone numbers fetch failed' }.to_json)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/WABA phone numbers fetch failed/)
end
end
context 'when webhook override fails' do
before do
# Stub all the successful requests
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: '1234567890',
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: [waba_id]
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/v14.0/#{waba_id}/message_templates")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: { data: [] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub the failing webhook request
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.to_return(status: 400, body: { error: 'Webhook failed' }.to_json)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Webhook override failed/)
end
end
end
describe 'private methods' do
describe '#exchange_code_for_token' do
context 'when token exchange is successful' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns the access token' do
result = service.send(:exchange_code_for_token)
expect(result).to eq(access_token)
end
end
context 'when response has no access token' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { some_other_field: 'value' }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error' do
expect { service.send(:exchange_code_for_token) }.to raise_error(/No access token in response/)
end
end
end
describe '#fetch_phone_info_via_waba' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: '1234567890',
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns formatted phone info' do
result = service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
expect(result).to eq({
phone_number_id: phone_number_id,
phone_number: '+1234567890',
verified: true,
business_name: 'Test Business'
})
end
context 'when specific phone number is not found' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: 'different_phone_id',
display_phone_number: '9876543210',
verified_name: 'Different Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'uses the first available phone number' do
result = service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
expect(result[:phone_number_id]).to eq('different_phone_id')
expect(result[:phone_number]).to eq('+9876543210')
end
end
context 'when no phone numbers are available' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: { data: [] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error' do
expect do
service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
end.to raise_error(/No phone numbers found for WABA/)
end
end
end
describe '#validate_token_waba_access' do
context 'when token has access to WABA' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: [waba_id]
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'validates successfully when token has access' do
expect { service.send(:validate_token_waba_access, access_token, waba_id) }.not_to raise_error
end
end
context 'when token validation fails' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(status: 400, body: { error: 'Invalid token' }.to_json)
end
it 'raises an error' do
expect do
service.send(:validate_token_waba_access, access_token, waba_id)
end.to raise_error(/Token validation failed/)
end
end
context 'when token does not have access to WABA' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: ['different_waba_id']
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error when WABA ID is not in target_ids' do
expect do
service.send(:validate_token_waba_access, access_token, waba_id)
end.to raise_error(/Token does not have access to WABA/)
end
end
context 'when no WABA scope is found' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'some_other_scope',
target_ids: ['some_id']
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error' do
expect do
service.send(:validate_token_waba_access, access_token, waba_id)
end.to raise_error(/No WABA scope found in token/)
end
end
end
end
end