Compare commits

..
Author SHA1 Message Date
aakashb95 01c84be757 add improve 2025-12-15 16:15:33 +05:30
aakashb95 5ac32803dc remove redundant checks 2025-12-15 15:11:10 +05:30
Aakash BakhleandGitHub 18e0cfb327 Merge branch 'feature/cw-6117' into feature/cw-6118 2025-12-15 15:07:54 +05:30
Vishnu NarayananandGitHub a70d331813 Merge branch 'develop' into feature/cw-6117 2025-12-12 18:54:13 +05:30
aakashb95 3b4c5bd916 remove redundant logic 2025-12-12 16:43:11 +05:30
aakashb95 491e7d0c6c Merge branch 'feature/cw-6117' into feature/cw-6118 2025-12-12 16:36:58 +05:30
aakashb95 e81b812ac2 ui changes 2025-12-12 16:34:52 +05:30
aakashb95 baec4523d5 add improve prompts 2025-12-12 16:27:43 +05:30
Aakash BakhleandGitHub 65136c90d8 Merge branch 'develop' into feature/cw-6117 2025-12-12 14:45:57 +05:30
aakashb95 24125e2d9a move agent instruction for tone to file 2025-12-12 14:39:58 +05:30
aakashb95 52e3642127 fix rubocop 2025-12-12 14:39:46 +05:30
aakashb95 6e13eb965b fix specs 2025-12-12 14:28:22 +05:30
aakashb95 7d78f67d3b fix ent override 2025-12-12 14:15:04 +05:30
aakashb95 a7420f8fae update specs 2025-12-12 14:00:47 +05:30
aakashb95 847ce98006 add confident tone 2025-12-12 14:00:40 +05:30
aakashb95 8b9da2e409 include keys for tones 2025-12-11 22:49:57 +05:30
aakashb95 d540d885f2 base prompt to improve editor replies 2025-12-11 22:49:46 +05:30
30 changed files with 337 additions and 1022 deletions
@@ -1,11 +0,0 @@
class Api::V1::Accounts::Captain::ConfigController < Api::V1::Accounts::BaseController
before_action :current_account
def show
render json: {
providers: Llm::ConfigService.providers,
models: Llm::ConfigService.models,
features: Llm::ConfigService.all_features_config
}
end
end
@@ -92,10 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
permitted = params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
permitted[:captain_models] = params[:captain_models].to_unsafe_h if params[:captain_models].present?
permitted[:captain_features] = params[:captain_features].to_unsafe_h if params[:captain_features].present?
permitted
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
end
def check_signup_enabled
@@ -102,8 +102,8 @@ const createNonDraftMessageAIAssistActions = (t, replyMode) => {
const createDraftMessageAIAssistActions = t => {
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
key: 'rephrase',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CONFIDENT'),
key: 'confident',
icon: ICON_AI_ASSIST,
},
{
@@ -112,13 +112,13 @@ const createDraftMessageAIAssistActions = t => {
icon: ICON_AI_GRAMMAR,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
key: 'expand',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.PROFESSIONAL'),
key: 'professional',
icon: ICON_AI_EXPAND,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
key: 'shorten',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CASUAL'),
key: 'casual',
icon: ICON_AI_SHORTEN,
},
{
@@ -132,8 +132,8 @@ const createDraftMessageAIAssistActions = t => {
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
key: 'simplify',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.STRAIGHTFORWARD'),
key: 'straightforward',
icon: ICON_AI_ASSIST,
},
];
@@ -145,7 +145,11 @@
"EXPAND": "Expand",
"MAKE_FRIENDLY": "Change message tone to friendly",
"MAKE_FORMAL": "Use formal tone",
"SIMPLIFY": "Simplify"
"SIMPLIFY": "Simplify",
"CONFIDENT": "Use confident tone",
"PROFESSIONAL": "Use professional tone",
"CASUAL": "Use casual tone",
"STRAIGHTFORWARD": "Use straightforward tone"
},
"ASSISTANCE_MODAL": {
"DRAFT_TITLE": "Draft content",
@@ -8,4 +8,8 @@ export const OPEN_AI_OPTIONS = {
SIMPLIFY: 'simplify',
REPLY_SUGGESTION: 'reply_suggestion',
SUMMARIZE: 'summarize',
CASUAL: 'casual',
PROFESSIONAL: 'professional',
STRAIGHTFORWARD: 'straightforward',
CONFIDENT: 'confident',
};
-40
View File
@@ -53,11 +53,9 @@ class Account < ApplicationRecord
validates_with JsonSchemaValidator,
schema: SETTINGS_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.settings }
validate :validate_captain_models
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
@@ -161,46 +159,8 @@ class Account < ApplicationRecord
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
end
def captain_preferences
{
models: captain_models_with_defaults,
features: captain_features_with_defaults
}.with_indifferent_access
end
private
def captain_models_with_defaults
stored_models = captain_models || {}
Llm::ConfigService.feature_keys.each_with_object({}) do |feature_key, result|
stored_value = stored_models[feature_key]
result[feature_key] = if stored_value.present? && Llm::ConfigService.valid_model_for_feature?(feature_key, stored_value)
stored_value
else
Llm::ConfigService.default_model_for_feature(feature_key)
end
end
end
def captain_features_with_defaults
stored_features = captain_features || {}
Llm::ConfigService.feature_keys.index_with do |feature_key|
stored_features[feature_key] == true
end
end
def validate_captain_models
return if captain_models.blank?
captain_models.each do |feature_key, model_name|
next if model_name.blank?
next if Llm::ConfigService.valid_model_for_feature?(feature_key, model_name)
allowed_models = Llm::ConfigService.models_for_feature(feature_key)
errors.add(:captain_models, "'#{model_name}' is not a valid model for #{feature_key}. Allowed: #{allowed_models.join(', ')}")
end
end
def notify_creation
Rails.configuration.dispatcher.dispatch(ACCOUNT_CREATED, Time.zone.now, account: self)
end
@@ -1,14 +1,6 @@
class MessageTemplates::Template::OutOfOffice
pattr_initialize [:conversation!]
def self.perform_if_applicable(conversation)
inbox = conversation.inbox
return unless inbox.out_of_office?
return if inbox.out_of_office_message.blank?
new(conversation: conversation).perform
end
def perform
ActiveRecord::Base.transaction do
conversation.messages.create!(out_of_office_message_params)
@@ -25,4 +25,3 @@ json.name @account.name
json.support_email @account.support_email
json.status @account.status
json.cache_keys @account.cache_keys
json.captain_preferences @account.captain_preferences
+177 -177
View File
@@ -15,58 +15,58 @@
# ------- Branding Related Config ------- #
- name: INSTALLATION_NAME
value: "Chatwoot"
display_title: "Installation Name"
description: "The installation wide name that would be used in the dashboard, title etc."
value: 'Chatwoot'
display_title: 'Installation Name'
description: 'The installation wide name that would be used in the dashboard, title etc.'
- name: LOGO_THUMBNAIL
value: "/brand-assets/logo_thumbnail.svg"
display_title: "Logo Thumbnail"
description: "The thumbnail that would be used for favicon (512px X 512px)"
value: '/brand-assets/logo_thumbnail.svg'
display_title: 'Logo Thumbnail'
description: 'The thumbnail that would be used for favicon (512px X 512px)'
- name: LOGO
value: "/brand-assets/logo.svg"
display_title: "Logo"
description: "The logo that would be used on the dashboard, login page etc."
value: '/brand-assets/logo.svg'
display_title: 'Logo'
description: 'The logo that would be used on the dashboard, login page etc.'
- name: LOGO_DARK
value: "/brand-assets/logo_dark.svg"
display_title: "Logo Dark Mode"
description: "The logo that would be used on the dashboard, login page etc. for dark mode"
value: '/brand-assets/logo_dark.svg'
display_title: 'Logo Dark Mode'
description: 'The logo that would be used on the dashboard, login page etc. for dark mode'
- name: BRAND_URL
value: "https://www.chatwoot.com"
display_title: "Brand URL"
description: "The URL that would be used in emails under the section “Powered By”"
value: 'https://www.chatwoot.com'
display_title: 'Brand URL'
description: 'The URL that would be used in emails under the section “Powered By”'
- name: WIDGET_BRAND_URL
value: "https://www.chatwoot.com"
display_title: "Widget Brand URL"
description: "The URL that would be used in the widget under the section “Powered By”"
value: 'https://www.chatwoot.com'
display_title: 'Widget Brand URL'
description: 'The URL that would be used in the widget under the section “Powered By”'
- name: BRAND_NAME
value: "Chatwoot"
display_title: "Brand Name"
description: "The name that would be used in emails and the widget"
value: 'Chatwoot'
display_title: 'Brand Name'
description: 'The name that would be used in emails and the widget'
- name: TERMS_URL
value: "https://www.chatwoot.com/terms-of-service"
display_title: "Terms URL"
description: "The terms of service URL displayed in Signup Page"
value: 'https://www.chatwoot.com/terms-of-service'
display_title: 'Terms URL'
description: 'The terms of service URL displayed in Signup Page'
- name: PRIVACY_URL
value: "https://www.chatwoot.com/privacy-policy"
display_title: "Privacy URL"
description: "The privacy policy URL displayed in the app"
value: 'https://www.chatwoot.com/privacy-policy'
display_title: 'Privacy URL'
description: 'The privacy policy URL displayed in the app'
- name: DISPLAY_MANIFEST
value: true
display_title: "Chatwoot Metadata"
description: "Display default Chatwoot metadata like favicons and upgrade warnings"
display_title: 'Chatwoot Metadata'
description: 'Display default Chatwoot metadata like favicons and upgrade warnings'
type: boolean
# ------- End of Branding Related Config ------- #
# ------- Signup & Account Related Config ------- #
- name: ENABLE_ACCOUNT_SIGNUP
display_title: "Enable Account Signup"
display_title: 'Enable Account Signup'
value: false
description: "Allow users to signup for new accounts"
description: 'Allow users to signup for new accounts'
locked: false
type: boolean
- name: CREATE_NEW_ACCOUNT_FROM_DASHBOARD
value: false
description: "Allow users to create new accounts from the dashboard"
description: 'Allow users to create new accounts from the dashboard'
locked: false
- name: HCAPTCHA_SITE_KEY
value:
@@ -76,127 +76,127 @@
locked: false
- name: INSTALLATION_EVENTS_WEBHOOK_URL
value:
display_title: "System events Webhook URL"
description: "The URL to which the system events like new accounts created will be sent"
display_title: 'System events Webhook URL'
description: 'The URL to which the system events like new accounts created will be sent'
locked: false
- name: WEBHOOK_TIMEOUT
value: 5
display_title: "Webhook request timeout (seconds)"
description: "Maximum time Chatwoot waits for a webhook response before failing the request"
display_title: 'Webhook request timeout (seconds)'
description: 'Maximum time Chatwoot waits for a webhook response before failing the request'
locked: false
- name: DIRECT_UPLOADS_ENABLED
type: boolean
value: false
description: "Enable direct uploads to cloud storage"
description: 'Enable direct uploads to cloud storage'
locked: false
- name: MAXIMUM_FILE_UPLOAD_SIZE
value: 40
display_title: "Attachment size limit (MB)"
description: "Maximum attachment size in MB allowed for uploads"
display_title: 'Attachment size limit (MB)'
description: 'Maximum attachment size in MB allowed for uploads'
locked: false
# ------- End of Account Related Config ------- #
# ------- Email Related Config ------- #
- name: MAILER_INBOUND_EMAIL_DOMAIN
display_title: "Inbound Email Domain"
display_title: 'Inbound Email Domain'
value:
description: "The domain name to be used for generating conversation continuity emails (reply+id@domain.com)"
description: 'The domain name to be used for generating conversation continuity emails (reply+id@domain.com)'
locked: false
- name: MAILER_SUPPORT_EMAIL
display_title: "Support Email"
display_title: 'Support Email'
value:
description: "The support email address for your installation"
description: 'The support email address for your installation'
locked: false
# ------- End of Email Related Config ------- #
# ------- Facebook Channel Related Config ------- #
- name: FB_APP_ID
display_title: "Facebook App ID"
display_title: 'Facebook App ID'
locked: false
- name: FB_VERIFY_TOKEN
display_title: "Facebook Verify Token"
description: "The verify token used for Facebook Messenger Webhook"
display_title: 'Facebook Verify Token'
description: 'The verify token used for Facebook Messenger Webhook'
locked: false
type: secret
- name: FB_APP_SECRET
display_title: "Facebook App Secret"
display_title: 'Facebook App Secret'
locked: false
type: secret
- name: IG_VERIFY_TOKEN
display_title: "Instagram Verify Token"
description: "The verify token used for Instagram Webhook"
display_title: 'Instagram Verify Token'
description: 'The verify token used for Instagram Webhook'
locked: false
type: secret
- name: FACEBOOK_API_VERSION
display_title: "Facebook API Version"
description: "Configure this if you want to use a different Facebook API version. Make sure its prefixed with `v`"
value: "v18.0"
display_title: 'Facebook API Version'
description: 'Configure this if you want to use a different Facebook API version. Make sure its prefixed with `v`'
value: 'v18.0'
locked: false
- name: ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT
display_title: "Enable human agent"
display_title: 'Enable human agent'
value: false
locked: false
description: "Enable human agent for messenger channel for longer message back period. Needs additional app approval: https://developers.facebook.com/docs/features-reference/human-agent/"
description: 'Enable human agent for messenger channel for longer message back period. Needs additional app approval: https://developers.facebook.com/docs/features-reference/human-agent/'
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"
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)"
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)"
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"
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"
description: "The App ID that will be used to authenticate with customer Microsoft accounts. Find more details on setting up Azure here: https://chwt.app/dev/ms"
display_title: 'Azure App ID'
description: 'The App ID that will be used to authenticate with customer Microsoft accounts. Find more details on setting up Azure here: https://chwt.app/dev/ms'
locked: false
- name: AZURE_APP_SECRET
display_title: "Azure App Secret"
display_title: 'Azure App Secret'
locked: false
type: secret
# End of Microsoft Email Channel Config
# MARK: Captain Config
- name: CAPTAIN_OPEN_AI_API_KEY
display_title: "OpenAI API Key"
description: "The API key used to authenticate requests to OpenAI services for Captain AI."
display_title: 'OpenAI API Key'
description: 'The API key used to authenticate requests to OpenAI services for Captain AI.'
locked: false
type: secret
- name: CAPTAIN_OPEN_AI_MODEL
display_title: "OpenAI Model"
description: "The OpenAI model configured for use in Captain AI. Default: gpt-4.1-mini"
display_title: 'OpenAI Model'
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4o-mini'
locked: false
- name: CAPTAIN_OPEN_AI_ENDPOINT
display_title: "OpenAI API Endpoint (optional)"
description: "The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/"
display_title: 'OpenAI API Endpoint (optional)'
description: 'The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/'
locked: false
- name: CAPTAIN_EMBEDDING_MODEL
display_title: "Embedding Model (optional)"
description: "The embedding model configured for use in Captain AI. Default: text-embedding-3-small"
display_title: 'Embedding Model (optional)'
description: 'The embedding model configured for use in Captain AI. Default: text-embedding-3-small'
locked: false
- name: CAPTAIN_FIRECRAWL_API_KEY
display_title: "FireCrawl API Key (optional)"
description: "The FireCrawl API key for the Captain AI service"
display_title: 'FireCrawl API Key (optional)'
description: 'The FireCrawl API key for the Captain AI service'
locked: false
type: secret
- name: CAPTAIN_CLOUD_PLAN_LIMITS
display_title: "Captain Cloud Plan Limits"
description: "The limits for the Captain AI service for different plans"
display_title: 'Captain Cloud Plan Limits'
description: 'The limits for the Captain AI service for different plans'
value:
type: code
# End of Captain Config
@@ -204,65 +204,65 @@
# ------- Chatwoot Internal Config for Cloud ----#
- name: CHATWOOT_INBOX_TOKEN
value:
display_title: "Inbox Token"
description: "The Chatwoot Inbox Token for Contact Support in Cloud"
display_title: 'Inbox Token'
description: 'The Chatwoot Inbox Token for Contact Support in Cloud'
locked: false
type: secret
- name: CHATWOOT_INBOX_HMAC_KEY
value:
display_title: "Inbox HMAC Key"
description: "The Chatwoot Inbox HMAC Key for Contact Support in Cloud"
display_title: 'Inbox HMAC Key'
description: 'The Chatwoot Inbox HMAC Key for Contact Support in Cloud'
locked: false
type: secret
- name: CHATWOOT_CLOUD_PLANS
display_title: "Cloud Plans"
display_title: 'Cloud Plans'
value:
description: "Config to store stripe plans for cloud"
description: 'Config to store stripe plans for cloud'
- name: CHATWOOT_CLOUD_PLAN_FEATURES
display_title: "Planwise Features List"
display_title: 'Planwise Features List'
value:
description: "Config to features and their associated plans"
description: 'Config to features and their associated plans'
- name: DEPLOYMENT_ENV
value: self-hosted
description: "The deployment environment of the installation, to differentiate between Chatwoot cloud and self-hosted"
description: 'The deployment environment of the installation, to differentiate between Chatwoot cloud and self-hosted'
- name: ANALYTICS_TOKEN
value:
display_title: "Analytics Token"
description: "The PostHog analytics token for Chatwoot cloud"
display_title: 'Analytics Token'
description: 'The PostHog analytics token for Chatwoot cloud'
type: secret
- name: CLEARBIT_API_KEY
value:
display_title: "Clearbit API Key"
description: "This API key is used for onboarding the users, to pre-fill account data."
display_title: 'Clearbit API Key'
description: 'This API key is used for onboarding the users, to pre-fill account data.'
type: secret
- name: DASHBOARD_SCRIPTS
value:
display_title: "Dashboard Scripts"
description: "Scripts are loaded as the last item in the <body> tag"
display_title: 'Dashboard Scripts'
description: 'Scripts are loaded as the last item in the <body> tag'
type: code
- name: BLOCKED_EMAIL_DOMAINS
value:
display_title: "Blocked Email Domains"
description: "Add a domain per line to block them from signing up, accepts Regex"
display_title: 'Blocked Email Domains'
description: 'Add a domain per line to block them from signing up, accepts Regex'
type: code
- name: SKIP_INCOMING_BCC_PROCESSING
value:
display_title: "Skip BCC Processing For"
description: "Comma-separated list of account IDs that should be skipped from incoming BCC processing"
display_title: 'Skip BCC Processing For'
description: 'Comma-separated list of account IDs that should be skipped from incoming BCC processing'
- name: INACTIVE_WHATSAPP_NUMBERS
value: ""
display_title: "Inactive WhatsApp Numbers"
description: "Comma-separated list of WhatsApp numbers that should be rejected with a 422 error"
value: ''
display_title: 'Inactive WhatsApp Numbers'
description: 'Comma-separated list of WhatsApp numbers that should be rejected with a 422 error'
type: code
# ------- End of Chatwoot Internal Config for Cloud ----#
# ------- Chatwoot Internal Config for Self Hosted ----#
- name: INSTALLATION_PRICING_PLAN
value: "community"
description: "The pricing plan for the installation, retrieved from the billing API"
value: 'community'
description: 'The pricing plan for the installation, retrieved from the billing API'
- name: INSTALLATION_PRICING_PLAN_QUANTITY
value: 0
description: "The number of licenses purchased for the installation, retrieved from the billing API"
description: 'The number of licenses purchased for the installation, retrieved from the billing API'
- name: CHATWOOT_SUPPORT_WEBSITE_TOKEN
value:
description: 'The Chatwoot website token, used to identify the Chatwoot inbox and display the "Contact Support" option on the billing page'
@@ -272,7 +272,7 @@
description: 'The Chatwoot script base URL, to display the "Contact Support" option on the billing page'
- name: CHATWOOT_SUPPORT_IDENTIFIER_HASH
value:
description: "The Chatwoot identifier hash, to validate the contact in the live chat window."
description: 'The Chatwoot identifier hash, to validate the contact in the live chat window.'
type: secret
- name: ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL
display_title: Webhook URL to post security analysis
@@ -282,221 +282,221 @@
# ------- Compliance Related Config ----#
- name: CHATWOOT_INSTANCE_ADMIN_EMAIL
display_title: "Instance Admin Email"
display_title: 'Instance Admin Email'
value:
description: "The email of the instance administrator to receive compliance-related notifications"
description: 'The email of the instance administrator to receive compliance-related notifications'
locked: false
# ------- End of Compliance Related Config ----#
## ------ Configs added for enterprise clients ------ ##
- name: API_CHANNEL_NAME
value:
description: "Custom name for the API channel"
description: 'Custom name for the API channel'
- name: API_CHANNEL_THUMBNAIL
value:
description: "Custom thumbnail for the API channel"
description: 'Custom thumbnail for the API channel'
- name: LOGOUT_REDIRECT_LINK
value: /app/login
locked: false
description: "Redirect to a different link after logout"
description: 'Redirect to a different link after logout'
- name: DISABLE_USER_PROFILE_UPDATE
value: false
locked: false
description: "Disable rendering profile update page for users"
description: 'Disable rendering profile update page for users'
## ------ End of Configs added for enterprise clients ------ ##
## ------ Configs added for FCM v1 notifications ------ ##
- name: FIREBASE_PROJECT_ID
display_title: "Firebase Project ID"
display_title: 'Firebase Project ID'
value:
locked: false
description: "Firebase project ID"
description: 'Firebase project ID'
- name: FIREBASE_CREDENTIALS
display_title: "Firebase Credentials"
display_title: 'Firebase Credentials'
value:
locked: false
type: secret
description: "Contents on your firebase credentials json file"
description: 'Contents on your firebase credentials json file'
## ------ End of Configs added for FCM v1 notifications ------ ##
## ------ Configs added for Linear ------ ##
- name: LINEAR_CLIENT_ID
display_title: "Linear Client ID"
display_title: 'Linear Client ID'
value:
locked: false
description: "Linear client ID"
description: 'Linear client ID'
- name: LINEAR_CLIENT_SECRET
display_title: "Linear Client Secret"
display_title: 'Linear Client Secret'
value:
locked: false
description: "Linear client secret"
description: 'Linear client secret'
type: secret
## ------ End of Configs added for Linear ------ ##
## ------ Configs added for Notion ------ ##
- name: NOTION_CLIENT_ID
display_title: "Notion Client ID"
display_title: 'Notion Client ID'
value:
locked: false
description: "Notion client ID"
description: 'Notion client ID'
- name: NOTION_CLIENT_SECRET
display_title: "Notion Client Secret"
display_title: 'Notion Client Secret'
value:
locked: false
description: "Notion client secret"
description: 'Notion client secret'
type: secret
- name: NOTION_VERSION
display_title: "Notion Version"
value: "2022-06-28"
display_title: 'Notion Version'
value: '2022-06-28'
locked: false
description: "Notion version"
description: 'Notion version'
## ------ End of Configs added for Notion ------ ##
## ------ Configs added for Slack ------ ##
- name: SLACK_CLIENT_ID
display_title: "Slack Client ID"
display_title: 'Slack Client ID'
value:
locked: false
description: "Slack client ID"
description: 'Slack client ID'
- name: SLACK_CLIENT_SECRET
display_title: "Slack Client Secret"
display_title: 'Slack Client Secret'
value:
locked: false
description: "Slack client secret"
description: 'Slack client secret'
type: secret
## ------ End of Configs added for Slack ------ ##
# ------- Shopify Integration Config ------- #
- name: SHOPIFY_CLIENT_ID
display_title: "Shopify Client ID"
description: "The Client ID (API Key) from your Shopify Partner account"
display_title: 'Shopify Client ID'
description: 'The Client ID (API Key) from your Shopify Partner account'
locked: false
type: secret
- name: SHOPIFY_CLIENT_SECRET
display_title: "Shopify Client Secret"
description: "The Client Secret (API Secret Key) from your Shopify Partner account"
display_title: 'Shopify Client Secret'
description: 'The Client Secret (API Secret Key) from your Shopify Partner account'
locked: false
type: secret
# ------- End of Shopify Related Config ------- #
# ------- Instagram Channel Related Config ------- #
- name: INSTAGRAM_APP_ID
display_title: "Instagram App ID"
display_title: 'Instagram App ID'
locked: false
- name: INSTAGRAM_APP_SECRET
display_title: "Instagram App Secret"
description: "The App Secret used for Instagram authentication"
display_title: 'Instagram App Secret'
description: 'The App Secret used for Instagram authentication'
locked: false
type: secret
- name: INSTAGRAM_VERIFY_TOKEN
display_title: "Instagram Verify Token"
description: "The verify token used for Instagram Webhook"
display_title: 'Instagram Verify Token'
description: 'The verify token used for Instagram Webhook'
locked: false
type: secret
- name: ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT
display_title: "Enable human agent for instagram channel"
display_title: 'Enable human agent for instagram channel'
value: false
locked: false
description: "Enable human agent for instagram channel for longer message back period. Needs additional app approval: https://developers.facebook.com/docs/features-reference/human-agent/"
description: 'Enable human agent for instagram channel for longer message back period. Needs additional app approval: https://developers.facebook.com/docs/features-reference/human-agent/'
type: boolean
- name: INSTAGRAM_API_VERSION
display_title: "Instagram API Version"
description: "Configure this if you want to use a different Instagram API version. Make sure its prefixed with `v`"
value: "v22.0"
display_title: 'Instagram API Version'
description: 'Configure this if you want to use a different Instagram API version. Make sure its prefixed with `v`'
value: 'v22.0'
locked: true
# ------- End of Instagram Channel Related Config ------- #
# ------- OG Image Related Config ------- #
- name: OG_IMAGE_CDN_URL
display_title: "OG Image CDN URL"
description: "The CDN URL for serving OG images"
value: ""
display_title: 'OG Image CDN URL'
description: 'The CDN URL for serving OG images'
value: ''
locked: false
- name: OG_IMAGE_CLIENT_REF
display_title: "OG Image Client Reference"
description: "Token used to block unauthorized access to OG images"
value: ""
display_title: 'OG Image Client Reference'
description: 'Token used to block unauthorized access to OG images'
value: ''
locked: false
type: secret
# ------- End of OG Image Related Config ------- #
## ------ Configs added for Google OAuth ------ ##
- name: GOOGLE_OAUTH_CLIENT_ID
display_title: "Google OAuth Client ID"
display_title: 'Google OAuth Client ID'
value:
locked: false
description: "Google OAuth Client ID for email authentication"
description: 'Google OAuth Client ID for email authentication'
- name: GOOGLE_OAUTH_CLIENT_SECRET
display_title: "Google OAuth Client Secret"
display_title: 'Google OAuth Client Secret'
value:
locked: false
description: "Google OAuth Client Secret for email authentication"
description: 'Google OAuth Client Secret for email authentication'
type: secret
- name: GOOGLE_OAUTH_REDIRECT_URI
display_title: "Google OAuth Redirect URI"
display_title: 'Google OAuth Redirect URI'
value:
locked: false
description: "The redirect URI configured in your Google OAuth app"
description: 'The redirect URI configured in your Google OAuth app'
- name: ENABLE_GOOGLE_OAUTH_LOGIN
display_title: "Enable Google OAuth login"
display_title: 'Enable Google OAuth login'
value: true
locked: false
description: "Show Google OAuth as a login option when credentials are configured"
description: 'Show Google OAuth as a login option when credentials are configured'
type: boolean
## ------ End of Configs added for Google OAuth ------ ##
## ------ Configs added for SAML SSO ------ ##
- name: ENABLE_SAML_SSO_LOGIN
display_title: "Enable SAML SSO login"
display_title: 'Enable SAML SSO login'
value: true
locked: false
description: "Show SAML SSO as a login option. Cannot be disabled if any users are using SAML authentication."
description: 'Show SAML SSO as a login option. Cannot be disabled if any users are using SAML authentication.'
type: boolean
## ------ End of Configs added for SAML SSO ------ ##
## ------ Configs added for Cloudflare ------ ##
- name: CLOUDFLARE_API_KEY
display_title: "Cloudflare API Key"
display_title: 'Cloudflare API Key'
value:
locked: false
description: "API key for Cloudflare account authentication"
description: 'API key for Cloudflare account authentication'
type: secret
- name: CLOUDFLARE_ZONE_ID
display_title: "Cloudflare Zone ID"
display_title: 'Cloudflare Zone ID'
value:
locked: false
description: "Zone ID for the Cloudflare domain"
description: 'Zone ID for the Cloudflare domain'
## ------ End of Configs added for Cloudflare ------ ##
## ------ Customizations for Customers ------ ##
- name: WIDGET_TOKEN_EXPIRY
display_title: "Widget Token Expiry"
display_title: 'Widget Token Expiry'
value: 180
locked: false
description: "Token expiry in days"
description: 'Token expiry in days'
## ------ End of Customizations for Customers ------ ##
## ----- LLM Observability ---- ##
- name: OTEL_PROVIDER
display_title: "OpenTelemetry Provider"
description: "LLM observability provider (langfuse, langsmith, etc.)"
value: ""
display_title: 'OpenTelemetry Provider'
description: 'LLM observability provider (langfuse, langsmith, etc.)'
value: ''
locked: false
- name: LANGFUSE_PUBLIC_KEY
display_title: "Langfuse Public Key"
description: "Public key for Langfuse authentication"
value: ""
display_title: 'Langfuse Public Key'
description: 'Public key for Langfuse authentication'
value: ''
locked: false
type: secret
- name: LANGFUSE_SECRET_KEY
display_title: "Langfuse Secret Key"
description: "Secret key for Langfuse authentication"
value: ""
display_title: 'Langfuse Secret Key'
description: 'Secret key for Langfuse authentication'
value: ''
locked: false
type: secret
- name: LANGFUSE_BASE_URL
display_title: "Langfuse Base URL"
description: "Langfuse endpoint (US: https://us.cloud.langfuse.com, EU: https://cloud.langfuse.com)"
value: "https://us.cloud.langfuse.com"
display_title: 'Langfuse Base URL'
description: 'Langfuse endpoint (US: https://us.cloud.langfuse.com, EU: https://cloud.langfuse.com)'
value: 'https://us.cloud.langfuse.com'
locked: false
## ---- End of LLM Observability ---- ##
-54
View File
@@ -1,54 +0,0 @@
providers:
openai:
display_name: "OpenAI"
logo: openai.png
models:
gpt-4.1:
provider: openai
display_name: "GPT-4.1"
credit_multiplier: 0.5
gpt-4.1-mini:
provider: openai
display_name: "GPT-4.1 Mini"
credit_multiplier: 0.3
gpt-4.1-nano:
provider: openai
display_name: "GPT-4.1 Nano"
credit_multiplier: 0.1
gpt-5:
provider: openai
display_name: "GPT-5"
credit_multiplier: 1
gpt-5.1:
provider: openai
display_name: "GPT-5.1"
credit_multiplier: 1
gpt-5-mini:
provider: openai
display_name: "GPT-5 Mini"
credit_multiplier: 0.8
gpt-5-nano:
provider: openai
display_name: "GPT-5 Nano"
credit_multiplier: 0.3
gpt-5.2:
provider: openai
display_name: "GPT-5.2"
credit_multiplier: 1
features:
editor:
models:
[gpt-4.1-mini, gpt-4.1-nano, gpt-5-mini, gpt-4.1, gpt-5, gpt-5.1, gpt-5.2]
default: gpt-5-mini
assistant:
models: [gpt-5-mini, gpt-4.1, gpt-5, gpt-5.1, gpt-5.2]
default: gpt-5-mini
copilot:
models: [gpt-5-mini, gpt-4.1, gpt-5, gpt-5.1, gpt-5.2]
default: gpt-5-mini
label_suggestion:
models: [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini]
default: gpt-4.1-nano
# add deprecation_map later
-1
View File
@@ -52,7 +52,6 @@ Rails.application.routes.draw do
post :bulk_create, on: :collection
end
namespace :captain do
resource :config, only: [:show], controller: 'config'
resources :assistants do
member do
post :playground
@@ -87,15 +87,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
I18n.with_locale(@assistant.account.locale) do
create_handoff_message
@conversation.bot_handoff!
send_out_of_office_message_if_applicable
end
end
end
def send_out_of_office_message_if_applicable
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
end
def create_handoff_message
create_outgoing_message(
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
@@ -9,24 +9,6 @@ module Enterprise::MessageTemplates::HookExecutionService
schedule_captain_response
end
def should_send_greeting?
return false if captain_handling_conversation?
super
end
def should_send_out_of_office_message?
return false if captain_handling_conversation?
super
end
def should_send_email_collect?
return false if captain_handling_conversation?
super
end
private
def schedule_captain_response
@@ -64,14 +46,5 @@ module Enterprise::MessageTemplates::HookExecutionService
content: 'Transferring to another agent for further assistance.'
)
conversation.bot_handoff!
send_out_of_office_message_after_handoff
end
def send_out_of_office_message_after_handoff
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
def captain_handling_conversation?
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present?
end
end
@@ -7,7 +7,7 @@
#
# For all other LLM operations, use Llm::BaseAiService with RubyLLM instead.
class Llm::LegacyBaseOpenAiService
DEFAULT_MODEL = 'gpt-4.1-mini'
DEFAULT_MODEL = 'gpt-4o-mini'
attr_reader :client, :model
@@ -36,13 +36,6 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
# Trigger the bot handoff (sets status to open + dispatches events)
conversation.bot_handoff!
# Send out of office message if applicable (since template messages were suppressed while Captain was handling)
send_out_of_office_message_if_applicable(conversation)
end
def send_out_of_office_message_if_applicable(conversation)
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
# TODO: Future enhancement - Add team assignment capability
@@ -1,6 +1,6 @@
module Enterprise::Integrations::OpenaiProcessorService
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion label_suggestion fix_spelling_grammar shorten expand
make_friendly make_formal simplify].freeze
ALLOWED_EVENT_NAMES = %w[summarize reply_suggestion label_suggestion fix_spelling_grammar improve
make_friendly make_formal casual professional confident straightforward].freeze
CACHEABLE_EVENTS = %w[label_suggestion].freeze
def label_suggestion_message
+2 -1
View File
@@ -7,7 +7,8 @@ class Integrations::LlmBaseService
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
TOKEN_LIMIT = 400_000
GPT_MODEL = Llm::Config::DEFAULT_MODEL
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze
ALLOWED_EVENT_NAMES = %w[summarize reply_suggestion fix_spelling_grammar casual professional make_friendly make_formal confident
straightforward improve].freeze
CACHEABLE_EVENTS = %w[].freeze
pattr_initialize [:hook!, :event!]
@@ -0,0 +1,29 @@
You are an AI writing assistant integrated into Chatwoot, an omnichannel customer support platform.
{% if selection -%}
# Task
Improve ONLY the selected portion of the message. Return the COMPLETE message with the improved selection seamlessly integrated. Keep all other parts exactly as they are.
{% endif -%}
{% if tone_instruction -%}
# Tone
Apply the following tone to {% if selection %}the selected portion{% else %}the message{% endif %}:
<tone_instruction>
{{ tone_instruction }}
</tone_instruction>
{% endif -%}
{% unless tone_instruction or selection -%}
# Task
Improve the message for clarity, grammar, spelling, and natural flow while maintaining the original tone and intent.
{% endunless -%}
# Guidelines
- Preserve the core meaning and all important information
- Keep the message concise and appropriate for customer support
- Do not add information that wasn't in the original
- Do not remove critical details or instructions
- Reply in the same language as the original message
Output only the final improved message without any preamble, tags or explanation.
@@ -0,0 +1,19 @@
You are an AI writing assistant integrated into Chatwoot, an omnichannel customer support platform. Your task is to rewrite customer support message to match a specific tone while preserving the original meaning and intent.
Here is the tone to apply to the message you will receive:
<tone_instruction>
%s
</tone_instruction>
Your task is to rewrite the message according to the specified tone instructions.
Important guidelines:
- Preserve the core meaning and all important information from the original message
- Keep the rewritten message concise and appropriate for customer support
- Maintain helpfulness and respect regardless of tone
- Do not add information that wasn't in the original message
- Do not remove critical details or instructions
- Ensure that the reply should be in user language.
Output only the rewritten message without any preamble, tags or explanation.
+67 -18
View File
@@ -1,5 +1,4 @@
class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
AGENT_INSTRUCTION = 'You are a helpful support agent.'.freeze
LANGUAGE_INSTRUCTION = 'Ensure that the reply should be in user language.'.freeze
def reply_suggestion_message
make_api_call(reply_suggestion_body)
@@ -9,39 +8,50 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
make_api_call(summarize_body)
end
def rephrase_message
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please rephrase the following response. " \
"#{LANGUAGE_INSTRUCTION}"))
def confident_message
tone_instruction = determine_tone_instruction('confident')
make_api_call(build_api_call_body(tone_rewrite_prompt(tone_instruction)))
end
def fix_spelling_grammar_message
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please fix the spelling and grammar of the following response. " \
make_api_call(build_api_call_body('Please fix the spelling and grammar of the following response. ' \
"#{LANGUAGE_INSTRUCTION}"))
end
def shorten_message
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please shorten the following response. " \
"#{LANGUAGE_INSTRUCTION}"))
def straightforward_message
tone_instruction = determine_tone_instruction('straightforward')
make_api_call(build_api_call_body(tone_rewrite_prompt(tone_instruction)))
end
def expand_message
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please expand the following response. " \
"#{LANGUAGE_INSTRUCTION}"))
def casual_message
tone_instruction = determine_tone_instruction('casual')
make_api_call(build_api_call_body(tone_rewrite_prompt(tone_instruction)))
end
def make_friendly_message
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more friendly. " \
"#{LANGUAGE_INSTRUCTION}"))
tone_instruction = determine_tone_instruction('friendly')
make_api_call(build_api_call_body(tone_rewrite_prompt(tone_instruction)))
end
def make_formal_message
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more formal. " \
"#{LANGUAGE_INSTRUCTION}"))
tone_instruction = determine_tone_instruction('formal')
make_api_call(build_api_call_body(tone_rewrite_prompt(tone_instruction)))
end
def simplify_message
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please simplify the following response. " \
"#{LANGUAGE_INSTRUCTION}"))
def professional_message
tone_instruction = determine_tone_instruction('professional')
make_api_call(build_api_call_body(tone_rewrite_prompt(tone_instruction)))
end
def improve_message
content = event['data']['content']
selection = event['data']['selection'].presence
tone = event['data']['tone'].presence
system_prompt = improve_prompt(selection, tone)
user_content = selection ? improve_user_content(content, selection) : content
make_api_call(build_api_call_body(system_prompt, user_content))
end
private
@@ -51,6 +61,25 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
Rails.root.join(path, "#{file_name}.txt").read
end
def tone_rewrite_prompt(tone_instruction)
format(prompt_from_file('tone_rewrite'), tone_instruction)
end
def improve_prompt(selection, tone)
tone_instruction = determine_tone_instruction(tone) if tone.present?
render_liquid_prompt('improve', { selection: selection, tone_instruction: tone_instruction })
end
def improve_user_content(content, selection)
"Full message:\n#{content}\n\nSelected portion to improve:\n#{selection}"
end
def render_liquid_prompt(template_name, context = {})
template_path = Rails.root.join('lib/integrations/openai/openai_prompts', "#{template_name}.liquid")
template = Liquid::Template.parse(template_path.read)
template.render(context.deep_stringify_keys)
end
def build_api_call_body(system_content, user_content = event['data']['content'])
{
model: GPT_MODEL,
@@ -133,6 +162,26 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
].concat(conversation_messages(in_array_format: true))
}.to_json
end
def determine_tone_instruction(tone)
case tone
when 'friendly'
'Warm, approachable, and personable. Use conversational language, positive words, and show empathy. ' \
'May include phrases like \"Happy to help!\" or \"I\'d be glad to...\"'
when 'confident'
'Assertive and assured. Use definitive language, avoid hedging words like \"maybe\" or \"I think\". ' \
'Be direct and authoritative while remaining helpful.'
when 'straightforward'
'Clear, direct, and to-the-point. Remove unnecessary words, get straight to the information or solution. No fluff or extra pleasantries.'
when 'casual'
'Relaxed and informal. Use contractions, simpler words, and a conversational style. Friendly but less formal than professional tone.'
when 'professional'
'Formal, polished, and business-appropriate. Use complete sentences, proper grammar, ' \
'and maintain respectful distance. Avoid slang or overly casual language.'
else
determine_tone_instruction('friendly')
end
end
end
Integrations::Openai::ProcessorService.prepend_mod_with('Integrations::OpenaiProcessorService')
+1 -1
View File
@@ -1,7 +1,7 @@
require 'ruby_llm'
module Llm::Config
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
class << self
def initialized?
@initialized ||= false
-80
View File
@@ -1,80 +0,0 @@
# frozen_string_literal: true
module Llm::ConfigService
CONFIG_PATH = Rails.root.join('config/llm.yml').freeze
class << self
def config
@config ||= load_config
end
def reload!
@config = load_config
end
%w[providers models features].each do |key|
define_method(key) do
config[key] || {}
end
end
def models_for_feature(feature_key)
features.dig(feature_key.to_s, 'models') || []
end
def default_model_for_feature(feature_key)
features.dig(feature_key.to_s, 'default')
end
def model_info(model_name)
models[model_name.to_s]
end
def provider_info(provider_name)
providers[provider_name.to_s]
end
def feature_keys
features.keys
end
def valid_model_for_feature?(feature_key, model_name)
allowed_models = models_for_feature(feature_key)
allowed_models.include?(model_name.to_s)
end
def feature_config(feature_key)
feature = features[feature_key.to_s]
return nil unless feature
{
models: feature['models'].filter_map do |model_name|
model = models[model_name]
next nil unless model
{
id: model_name,
display_name: model['display_name'],
provider: model['provider'],
credit_multiplier: model['credit_multiplier']
}
end,
default: feature['default']
}
end
def all_features_config
features.keys.index_with do |feature_key|
feature_config(feature_key)
end
end
private
def load_config
return {} unless File.exist?(CONFIG_PATH)
YAML.load_file(CONFIG_PATH) || {}
end
end
end
@@ -1,50 +0,0 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Captain::Config', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
describe 'GET /api/v1/accounts/{account.id}/captain/config' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/captain/config",
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'returns available AI model options for each feature' do
get "/api/v1/accounts/#{account.id}/captain/config",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
%w[editor assistant copilot label_suggestion].each do |feature|
expect(json_response.dig(:features, feature.to_sym, :models)).to be_present
expect(json_response.dig(:features, feature.to_sym, :default)).to be_present
end
end
end
context 'when it is an admin' do
it 'returns providers, models, and features configuration' do
get "/api/v1/accounts/#{account.id}/captain/config",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response).to have_key(:providers)
expect(json_response).to have_key(:models)
expect(json_response).to have_key(:features)
end
end
end
end
@@ -229,106 +229,4 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
expect(described_class::MAX_MESSAGE_LENGTH).to eq(10_000)
end
end
describe 'out of office message after handoff' do
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) }
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
before do
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
allow(account).to receive(:feature_enabled?).and_return(false)
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
end
context 'when handoff occurs outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed. Please leave your email.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
end
it 'sends out of office message after handoff' do
expect do
described_class.perform_now(conversation, assistant)
end.to change { conversation.messages.template.count }.by(1)
expect(conversation.reload.status).to eq('open')
ooo_message = conversation.messages.template.last
expect(ooo_message.content).to eq('We are currently closed. Please leave your email.')
end
end
context 'when handoff occurs within business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
end
it 'does not send out of office message after handoff' do
expect do
described_class.perform_now(conversation, assistant)
end.not_to(change { conversation.messages.template.count })
expect(conversation.reload.status).to eq('open')
end
end
context 'when handoff occurs due to error outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
allow(mock_llm_chat_service).to receive(:generate_response).and_raise(StandardError, 'API error')
end
it 'sends out of office message after error-triggered handoff' do
expect do
described_class.perform_now(conversation, assistant)
end.to change { conversation.messages.template.count }.by(1)
expect(conversation.reload.status).to eq('open')
ooo_message = conversation.messages.template.last
expect(ooo_message.content).to eq('We are currently closed.')
end
end
context 'when no out of office message is configured' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: nil
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
end
it 'does not send out of office message' do
expect do
described_class.perform_now(conversation, assistant)
end.not_to(change { conversation.messages.template.count })
end
end
end
end
@@ -163,66 +163,4 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
expect(tool.active?).to be true
end
end
describe 'out of office message after handoff' do
context 'when outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed. Please leave your email.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
end
it 'sends out of office message after handoff' do
expect do
tool.perform(tool_context, reason: 'Customer needs help')
end.to change { conversation.messages.template.count }.by(1)
ooo_message = conversation.messages.template.last
expect(ooo_message.content).to eq('We are currently closed. Please leave your email.')
end
end
context 'when within business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
end
it 'does not send out of office message after handoff' do
expect do
tool.perform(tool_context, reason: 'Customer needs help')
end.not_to(change { conversation.messages.template.count })
end
end
context 'when no out of office message is configured' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: nil
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
end
it 'does not send out of office message' do
expect do
tool.perform(tool_context, reason: 'Customer needs help')
end.not_to(change { conversation.messages.template.count })
end
end
end
end
@@ -1,295 +0,0 @@
require 'rails_helper'
RSpec.describe MessageTemplates::HookExecutionService do
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, inbox: inbox, account: account, contact: contact, status: :pending) }
let(:assistant) { create(:captain_assistant, account: account) }
before do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
end
context 'when captain assistant is configured' do
context 'when within business hours' do
before do
inbox.update!(working_hours_enabled: true)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
end
it 'schedules captain response job for incoming messages on pending conversations' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
end
it 'schedules captain response job outside business hours (Captain always responds when configured)' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
end
it 'performs captain handoff when quota is exceeded (OOO template will kick in after handoff)' do
account.update!(
limits: { 'captain_responses' => 100 },
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
)
create(:message, conversation: conversation, message_type: :incoming)
expect(conversation.reload.status).to eq('open')
end
it 'does not send out of office message when Captain is handling' do
out_of_office_service = instance_double(MessageTemplates::Template::OutOfOffice)
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
create(:message, conversation: conversation, message_type: :incoming)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
end
end
context 'when business hours are not enabled' do
before do
inbox.update!(working_hours_enabled: false)
end
it 'schedules captain response job regardless of time' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when captain quota is exceeded within business hours' do
before do
inbox.update!(working_hours_enabled: true)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
account.update!(
limits: { 'captain_responses' => 100 },
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
)
end
it 'performs handoff within business hours when quota exceeded' do
create(:message, conversation: conversation, message_type: :incoming)
expect(conversation.reload.status).to eq('open')
end
end
end
context 'when no captain assistant is configured' do
before do
CaptainInbox.where(inbox: inbox).destroy_all
end
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when conversation is not pending' do
before do
conversation.update!(status: :open)
end
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when message is outgoing' do
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :outgoing)
end
end
context 'when greeting and out of office messages with Captain enabled' do
context 'when conversation is pending (Captain is handling)' do
before do
conversation.update!(status: :pending)
end
it 'does not create greeting message in conversation' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.not_to(change { conversation.reload.messages.template.count })
end
it 'does not create out of office message in conversation' do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.not_to(change { conversation.reload.messages.template.count })
end
end
context 'when conversation is open (transferred to agent)' do
before do
conversation.update!(status: :open)
end
it 'creates greeting message in conversation' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
greeting_message = conversation.reload.messages.template.last
expect(greeting_message.content).to eq('Hello! How can we help you?')
end
it 'creates out of office message when outside business hours' do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
out_of_office_message = conversation.reload.messages.template.last
expect(out_of_office_message.content).to eq('We are currently closed')
end
end
end
context 'when Captain is not configured' do
before do
CaptainInbox.where(inbox: inbox).destroy_all
end
it 'creates greeting message in conversation' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
greeting_message = conversation.reload.messages.template.last
expect(greeting_message.content).to eq('Hello! How can we help you?')
end
it 'creates out of office message when outside business hours' do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
out_of_office_message = conversation.reload.messages.template.last
expect(out_of_office_message.content).to eq('We are currently closed')
end
end
context 'when Captain quota is exceeded and handoff happens' do
before do
account.update!(
limits: { 'captain_responses' => 100 },
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
)
end
context 'when outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed. Please leave your email.',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
end
it 'sends out of office message after handoff due to quota exceeded' do
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.messages.template.count }.by(1)
expect(conversation.reload.status).to eq('open')
ooo_message = conversation.messages.template.last
expect(ooo_message.content).to eq('We are currently closed. Please leave your email.')
end
end
context 'when within business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed.',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
end
it 'does not send out of office message after handoff' do
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.not_to(change { conversation.messages.template.count })
expect(conversation.reload.status).to eq('open')
end
end
end
end
@@ -9,7 +9,7 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
before do
# Create required installation configs
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-api-key')
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-mini')
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
# Mock usage limits for transcription to be available
allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } })
@@ -20,7 +20,7 @@ RSpec.describe Integrations::LlmInstrumentation do
account_id: 123,
conversation_id: 456,
feature_name: 'reply_suggestion',
model: 'gpt-4.1-mini',
model: 'gpt-4o-mini',
messages: [{ 'role' => 'user', 'content' => 'Hello' }],
temperature: 0.7
}
@@ -105,7 +105,7 @@ RSpec.describe Integrations::LlmInstrumentation do
instance.instrument_llm_call(params) { 'result' }
expect(mock_span).to have_received(:set_attribute).with('gen_ai.provider.name', 'openai')
expect(mock_span).to have_received(:set_attribute).with('gen_ai.request.model', 'gpt-4.1-mini')
expect(mock_span).to have_received(:set_attribute).with('gen_ai.request.model', 'gpt-4o-mini')
expect(mock_span).to have_received(:set_attribute).with('gen_ai.request.temperature', 0.7)
end
@@ -53,17 +53,23 @@ RSpec.describe Integrations::Openai::ProcessorService do
it 'sets system instructions' do
service.perform
expect(mock_chat).to have_received(:with_instructions).with(a_string_including('You are a helpful support agent'))
if event_name == 'fix_spelling_grammar'
expect(mock_chat).to have_received(:with_instructions)
.with(a_string_including('Please fix the spelling and grammar'))
else
expect(mock_chat).to have_received(:with_instructions)
.with(a_string_including('You are an AI writing assistant integrated into Chatwoot'))
end
end
end
it_behaves_like 'text transformation operation', 'rephrase'
it_behaves_like 'text transformation operation', 'confident'
it_behaves_like 'text transformation operation', 'fix_spelling_grammar'
it_behaves_like 'text transformation operation', 'shorten'
it_behaves_like 'text transformation operation', 'expand'
it_behaves_like 'text transformation operation', 'casual'
it_behaves_like 'text transformation operation', 'professional'
it_behaves_like 'text transformation operation', 'make_friendly'
it_behaves_like 'text transformation operation', 'make_formal'
it_behaves_like 'text transformation operation', 'simplify'
it_behaves_like 'text transformation operation', 'straightforward'
end
describe 'conversation-based operations' do
@@ -125,7 +131,7 @@ RSpec.describe Integrations::Openai::ProcessorService do
end
describe 'response structure' do
let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } }
let(:event) { { 'name' => 'confident', 'data' => { 'content' => 'test message' } } }
context 'when response includes usage data' do
before do
@@ -166,10 +172,13 @@ RSpec.describe Integrations::Openai::ProcessorService do
end
describe 'endpoint configuration' do
let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } }
let(:event) { { 'name' => 'confident', 'data' => { 'content' => 'test message' } } }
context 'without CAPTAIN_OPEN_AI_ENDPOINT configured' do
before { InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy }
before do
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy
allow(Llm::Config).to receive(:with_api_key).and_call_original
end
it 'uses default OpenAI endpoint' do
expect(Llm::Config).to receive(:with_api_key).with(
@@ -185,6 +194,7 @@ RSpec.describe Integrations::Openai::ProcessorService do
before do
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy
create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/')
allow(Llm::Config).to receive(:with_api_key).and_call_original
end
it 'uses custom endpoint' do
-55
View File
@@ -218,59 +218,4 @@ RSpec.describe Account do
end
end
end
describe 'captain_preferences' do
let(:account) { create(:account) }
describe 'with no saved preferences' do
it 'returns defaults from llm.yml' do
prefs = account.captain_preferences
expect(prefs[:features].values).to all(be false)
Llm::ConfigService.feature_keys.each do |feature|
expect(prefs[:models][feature]).to eq(Llm::ConfigService.default_model_for_feature(feature))
end
end
end
describe 'with saved model preferences' do
it 'returns saved preferences merged with defaults' do
account.update!(captain_models: { 'editor' => 'gpt-4o', 'assistant' => 'gpt-5' })
prefs = account.captain_preferences
expect(prefs[:models]['editor']).to eq('gpt-4o')
expect(prefs[:models]['assistant']).to eq('gpt-5')
expect(prefs[:models]['copilot']).to eq(Llm::ConfigService.default_model_for_feature('copilot'))
end
end
describe 'with saved feature preferences' do
it 'returns saved feature states' do
account.update!(captain_features: { 'editor' => true, 'assistant' => true })
prefs = account.captain_preferences
expect(prefs[:features]['editor']).to be true
expect(prefs[:features]['assistant']).to be true
expect(prefs[:features]['copilot']).to be false
end
end
describe 'validation' do
it 'rejects invalid model for a feature' do
account.captain_models = { 'label_suggestion' => 'gpt-5' }
expect(account).not_to be_valid
expect(account.errors[:captain_models].first).to include('not a valid model for label_suggestion')
end
it 'accepts valid model for a feature' do
account.captain_models = { 'editor' => 'gpt-4o', 'label_suggestion' => 'gpt-4.1-nano' }
expect(account).to be_valid
end
end
end
end