Compare commits

...
Author SHA1 Message Date
Sojan Jose 6af34335a8 test: add spec for profile access token reset 2025-05-23 02:22:16 -07:00
Sojan JoseandGitHub f73c5ef0b8 chore: Add short_description translations for integration apps (#11562)
- Add short_description translations for integration apps (dyte, slack,
dialogflow, google_translate, openai, linear, shopify)
- Remove fallback logic in NewHook.vue since I18n.t() returns
translation key string when missing (making fallback ineffective)
2025-05-23 01:25:13 -07:00
Shivam MishraandGitHub f9fce5e2df style: run rubocop with auto fix (#11563)
- fix rubocop issues
2025-05-23 01:23:33 -07:00
Sojan JoseandGitHub c2d8e2ad77 feat: move Slack config to installation settings (#11548)
- enable Slack Configuration via InstallationConfig
- list Slack integration in super admin settings
2025-05-23 01:07:35 -07:00
PranavandGitHub 03c0a7c62e feat: Add support for more tool, standardize copilot chat service (#11560) 2025-05-23 01:07:07 -07:00
d40a59f7fa feat: automate account deletion (#11406)
- Automate the deletion of accounts that have requested deletion via
account settings.
- Add a Sidekiq job that runs daily to find accounts that have requested
deletion and have passed the 7-day window.
- This job deletes the account and then soft-deletes users if they do
not belong to any other account.
- This job also sends an email to the Chatwoot instance admin for
compliance purposes.
- The Chatwoot instance admin's email is configurable via the
`CHATWOOT_INSTANCE_ADMIN_EMAIL` global config.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-05-23 12:58:13 +05:30
44 changed files with 800 additions and 144 deletions
@@ -38,6 +38,11 @@ class Api::V1::ProfilesController < Api::BaseController
head :ok head :ok
end end
def reset_access_token
@user.access_token.regenerate_token
@user.reload
end
private private
def set_user def set_user
@@ -32,22 +32,17 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end end
def allowed_configs def allowed_configs
@allowed_configs = case @config mapping = {
when 'facebook' 'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT] 'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
when 'shopify' 'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
%w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET] 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
when 'microsoft' 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
%w[AZURE_APP_ID AZURE_APP_SECRET] 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
when 'email' 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
['MAILER_INBOUND_EMAIL_DOMAIN'] }
when 'linear'
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET] @allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
when 'instagram'
%w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
else
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end
end end
end end
+4
View File
@@ -102,4 +102,8 @@ export default {
const urlData = endPoints('resendConfirmation'); const urlData = endPoints('resendConfirmation');
return axios.post(urlData.url); return axios.post(urlData.url);
}, },
resetAccessToken() {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
}; };
@@ -51,6 +51,9 @@ const endPoints = {
resendConfirmation: { resendConfirmation: {
url: '/api/v1/profile/resend_confirmation', url: '/api/v1/profile/resend_confirmation',
}, },
resetAccessToken: {
url: '/api/v1/profile/reset_access_token',
},
}; };
export default page => { export default page => {
@@ -76,7 +76,10 @@
"ACCESS_TOKEN": { "ACCESS_TOKEN": {
"TITLE": "Access Token", "TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration", "NOTE": "This token can be used if you are building an API based integration",
"COPY": "Copy" "COPY": "Copy",
"RESET": "Reset",
"RESET_SUCCESS": "Access token regenerated",
"RESET_ERROR": "Could not reset token"
}, },
"AUDIO_NOTIFICATIONS_SECTION": { "AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Alerts", "TITLE": "Audio Alerts",
@@ -117,7 +117,7 @@ export default {
<div class="flex flex-col h-auto overflow-auto integration-hooks"> <div class="flex flex-col h-auto overflow-auto integration-hooks">
<woot-modal-header <woot-modal-header
:header-title="integration.name" :header-title="integration.name"
:header-content="integration.short_description || integration.description" :header-content="integration.short_description"
/> />
<FormKit <FormKit
v-model="values" v-model="values"
@@ -7,7 +7,7 @@ const props = defineProps({
default: '', default: '',
}, },
}); });
const emit = defineEmits(['onCopy']); const emit = defineEmits(['onCopy', 'onReset']);
const inputType = ref('password'); const inputType = ref('password');
const toggleMasked = () => { const toggleMasked = () => {
inputType.value = inputType.value === 'password' ? 'text' : 'password'; inputType.value = inputType.value === 'password' ? 'text' : 'password';
@@ -20,6 +20,10 @@ const maskIcon = computed(() => {
const onClick = () => { const onClick = () => {
emit('onCopy', props.value); emit('onCopy', props.value);
}; };
const onReset = () => {
emit('onReset');
};
</script> </script>
<template> <template>
@@ -56,5 +60,15 @@ const onClick = () => {
> >
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY') }} {{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY') }}
</FormButton> </FormButton>
<FormButton
type="button"
size="large"
icon="key"
variant="outline"
color-scheme="primary"
@click="onReset"
>
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET') }}
</FormButton>
</div> </div>
</template> </template>
@@ -181,6 +181,14 @@ export default {
await copyTextToClipboard(value); await copyTextToClipboard(value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL')); useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
}, },
async resetAccessToken() {
const success = await this.$store.dispatch('resetAccessToken');
if (success) {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_SUCCESS'));
} else {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_ERROR'));
}
},
}, },
}; };
</script> </script>
@@ -281,7 +289,11 @@ export default {
) )
" "
> >
<AccessToken :value="currentUser.access_token" @on-copy="onCopyToken" /> <AccessToken
:value="currentUser.access_token"
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
/>
</FormSection> </FormSection>
</div> </div>
</template> </template>
@@ -213,6 +213,16 @@ export const actions = {
} }
}, },
resetAccessToken: async ({ commit }) => {
try {
const response = await authAPI.resetAccessToken();
commit(types.SET_CURRENT_USER, response.data);
return true;
} catch (error) {
return false;
}
},
resendConfirmation: async () => { resendConfirmation: async () => {
try { try {
await authAPI.resendConfirmation(); await authAPI.resendConfirmation();
+27
View File
@@ -0,0 +1,27 @@
class Internal::DeleteAccountsJob < ApplicationJob
queue_as :scheduled_jobs
def perform
delete_expired_accounts
end
private
def delete_expired_accounts
accounts_pending_deletion.each do |account|
AccountDeletionService.new(account: account).perform
end
end
def accounts_pending_deletion
Account.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL")
.select { |account| deletion_period_expired?(account) }
end
def deletion_period_expired?(account)
deletion_time = account.custom_attributes['marked_for_deletion_at']
return false if deletion_time.blank?
DateTime.parse(deletion_time) <= Time.current
end
end
@@ -0,0 +1,52 @@
class AdministratorNotifications::AccountComplianceMailer < AdministratorNotifications::BaseMailer
def account_deleted(account)
return if instance_admin_email.blank?
subject = subject_for(account)
meta = build_meta(account)
send_notification(subject, to: instance_admin_email, meta: meta)
end
private
def build_meta(account)
deleted_users = params[:soft_deleted_users] || []
user_info_list = deleted_users.map do |user|
{
'user_id' => user[:id].to_s,
'user_email' => user[:original_email].to_s
}
end
{
'instance_url' => instance_url,
'account_id' => account.id,
'account_name' => account.name,
'deleted_at' => format_time(Time.current.iso8601),
'deletion_reason' => account.custom_attributes['marked_for_deletion_reason'] || 'not specified',
'marked_for_deletion_at' => format_time(account.custom_attributes['marked_for_deletion_at']),
'soft_deleted_users' => user_info_list,
'deleted_user_count' => user_info_list.size
}
end
def format_time(time_string)
return 'not specified' if time_string.blank?
Time.zone.parse(time_string).strftime('%B %d, %Y %H:%M:%S %Z')
end
def subject_for(account)
"Account Deletion Notice for #{account.id} - #{account.name}"
end
def instance_admin_email
GlobalConfig.get('CHATWOOT_INSTANCE_ADMIN_EMAIL')['CHATWOOT_INSTANCE_ADMIN_EMAIL']
end
def instance_url
ENV.fetch('FRONTEND_URL', 'not available')
end
end
+3 -2
View File
@@ -39,7 +39,8 @@ class Integrations::App
def action def action
case params[:id] case params[:id]
when 'slack' when 'slack'
"#{params[:action]}&client_id=#{ENV.fetch('SLACK_CLIENT_ID', nil)}&redirect_uri=#{self.class.slack_integration_url}" client_id = GlobalConfigService.load('SLACK_CLIENT_ID', nil)
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
when 'linear' when 'linear'
build_linear_action build_linear_action
else else
@@ -50,7 +51,7 @@ class Integrations::App
def active?(account) def active?(account)
case params[:id] case params[:id]
when 'slack' when 'slack'
ENV['SLACK_CLIENT_SECRET'].present? GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
when 'linear' when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present? GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify' when 'shopify'
+50
View File
@@ -0,0 +1,50 @@
class AccountDeletionService
attr_reader :account, :soft_deleted_users
def initialize(account:)
@account = account
@soft_deleted_users = []
end
def perform
Rails.logger.info("Deleting account #{account.id} - #{account.name} that was marked for deletion")
soft_delete_orphaned_users
send_compliance_notification
DeleteObjectJob.perform_later(account)
end
private
def send_compliance_notification
AdministratorNotifications::AccountComplianceMailer.with(
account: account,
soft_deleted_users: soft_deleted_users
).account_deleted(account).deliver_later
end
def soft_delete_orphaned_users
account.users.each do |user|
# Find all account_users for this user excluding the current account
other_accounts = user.account_users.where.not(account_id: account.id).count
# If user has no other accounts, soft delete them
next unless other_accounts.zero?
# Soft delete user by appending -deleted.com to email
original_email = user.email
user.email = "#{original_email}-deleted.com"
user.skip_reconfirmation!
user.save!
user_info = {
id: user.id.to_s,
original_email: original_email
}
soft_deleted_users << user_info
Rails.logger.info("Soft deleted user #{user.id} with email #{original_email}")
end
end
end
@@ -0,0 +1 @@
json.partial! 'api/v1/models/user', formats: [:json], resource: @user
@@ -0,0 +1,30 @@
<p>Hello,</p>
<p>This is a notification to inform you that an account has been permanently deleted from your Chatwoot instance.</p>
<p>
<strong>Chatwoot Installation:</strong> {{ meta.instance_url }}<br>
<strong>Account ID:</strong> {{ meta.account_id }}<br>
<strong>Account Name:</strong> {{ meta.account_name }}<br>
<strong>Deleted At:</strong> {{ meta.deleted_at }}<br>
<strong>Marked for Deletion at:</strong> {{ meta.marked_for_deletion_at }}<br>
<strong>Deletion Reason:</strong> {{ meta.deletion_reason }}
</p>
{% if meta.deleted_user_count > 0 %}
<p>
<strong>Deleted Users ({{ meta.deleted_user_count }}):</strong><br>
{% for user in meta.soft_deleted_users %}
User ID: {{ user.user_id }}, Email: {{ user.user_email }}{% unless forloop.last %}<br>{% endunless %}
{% endfor %}
</p>
{% else %}
<p>
<strong>Deleted Users:</strong> None
</p>
{% endif %}
<p>This email serves as a record for compliance purposes.</p>
<p>Thank you,<br>
Chatwoot System</p>
@@ -159,4 +159,7 @@
<symbol id="icon-shopify" viewBox="0 0 32 32"> <symbol id="icon-shopify" viewBox="0 0 32 32">
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/> <path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol> </symbol>
<symbol id="icon-slack" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
</symbol>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

-3
View File
@@ -16,16 +16,13 @@ development:
<<: *default <<: *default
database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_dev') %>" database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_dev') %>"
username: "<%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %>" username: "<%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %>"
password: "<%= ENV.fetch('POSTGRES_PASSWORD', '') %>"
test: test:
<<: *default <<: *default
database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_test') %>" database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_test') %>"
username: "<%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %>" username: "<%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %>"
password: "<%= ENV.fetch('POSTGRES_PASSWORD', '') %>"
production: production:
<<: *default <<: *default
database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_production') %>" database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_production') %>"
username: "<%= ENV.fetch('POSTGRES_USERNAME', 'chatwoot_prod') %>" username: "<%= ENV.fetch('POSTGRES_USERNAME', 'chatwoot_prod') %>"
password: "<%= ENV.fetch('POSTGRES_PASSWORD', 'chatwoot_prod') %>"
+22
View File
@@ -235,6 +235,14 @@
description: Used to notify Chatwoot about account abuses, potential threads (Should be a Discord Webhook URL) description: Used to notify Chatwoot about account abuses, potential threads (Should be a Discord Webhook URL)
# ------- End of Chatwoot Internal Config for Self Hosted ----# # ------- End of Chatwoot Internal Config for Self Hosted ----#
# ------- Compliance Related Config ----#
- name: CHATWOOT_INSTANCE_ADMIN_EMAIL
display_title: 'Instance Admin Email'
value:
description: 'The email of the instance administrator to receive compliance-related notifications'
locked: false
# ------- End of Compliance Related Config ----#
## ------ Configs added for enterprise clients ------ ## ## ------ Configs added for enterprise clients ------ ##
- name: API_CHANNEL_NAME - name: API_CHANNEL_NAME
value: value:
@@ -280,6 +288,20 @@
type: secret type: secret
## ------ End of Configs added for Linear ------ ## ## ------ End of Configs added for Linear ------ ##
## ------ Configs added for Slack ------ ##
- name: SLACK_CLIENT_ID
display_title: 'Slack Client ID'
value:
locked: false
description: 'Slack client ID'
- name: SLACK_CLIENT_SECRET
display_title: 'Slack Client Secret'
value:
locked: false
description: 'Slack client secret'
type: secret
## ------ End of Configs added for Slack ------ ##
# ------- Shopify Integration Config ------- # # ------- Shopify Integration Config ------- #
- name: SHOPIFY_CLIENT_ID - name: SHOPIFY_CLIENT_ID
display_title: 'Shopify Client ID' display_title: 'Shopify Client ID'
+8
View File
@@ -213,33 +213,41 @@ en:
online: online:
delete: '%{contact_name} is Online, please try again later' delete: '%{contact_name} is Online, please try again later'
integration_apps: integration_apps:
# Note: webhooks and dashboard_apps don't need short_description as they use different modal components
dashboard_apps: dashboard_apps:
name: 'Dashboard Apps' name: 'Dashboard Apps'
description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.' description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.'
dyte: dyte:
name: 'Dyte' name: 'Dyte'
short_description: 'Start video/voice calls with customers directly from Chatwoot.'
description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.' description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.'
meeting_name: '%{agent_name} has started a meeting' meeting_name: '%{agent_name} has started a meeting'
slack: slack:
name: 'Slack' name: 'Slack'
short_description: 'Receive notifications and respond to conversations directly in Slack.'
description: "Integrate Chatwoot with Slack to keep your team in sync. This integration allows you to receive notifications for new conversations and respond to them directly within Slack's interface." description: "Integrate Chatwoot with Slack to keep your team in sync. This integration allows you to receive notifications for new conversations and respond to them directly within Slack's interface."
webhooks: webhooks:
name: 'Webhooks' name: 'Webhooks'
description: 'Webhook events provide real-time updates about activities in your Chatwoot account. You can subscribe to your preferred events, and Chatwoot will send you HTTP callbacks with the updates.' description: 'Webhook events provide real-time updates about activities in your Chatwoot account. You can subscribe to your preferred events, and Chatwoot will send you HTTP callbacks with the updates.'
dialogflow: dialogflow:
name: 'Dialogflow' name: 'Dialogflow'
short_description: 'Build chatbots to handle initial queries before transferring to agents.'
description: 'Build chatbots with Dialogflow and easily integrate them into your inbox. These bots can handle initial queries before transferring them to a customer service agent.' description: 'Build chatbots with Dialogflow and easily integrate them into your inbox. These bots can handle initial queries before transferring them to a customer service agent.'
google_translate: google_translate:
name: 'Google Translate' name: 'Google Translate'
short_description: 'Automatically translate customer messages for agents.'
description: "Integrate Google Translate to help agents easily translate customer messages. This integration automatically detects the language and converts it to the agent's or admin's preferred language." description: "Integrate Google Translate to help agents easily translate customer messages. This integration automatically detects the language and converts it to the agent's or admin's preferred language."
openai: openai:
name: 'OpenAI' name: 'OpenAI'
short_description: 'AI-powered reply suggestions, summarization, and message enhancement.'
description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification.' description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification.'
linear: linear:
name: 'Linear' name: 'Linear'
short_description: 'Create and link Linear issues directly from conversations.'
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.' description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
shopify: shopify:
name: 'Shopify' name: 'Shopify'
short_description: 'Access order details and customer data from your Shopify store.'
description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.'
leadsquared: leadsquared:
name: 'LeadSquared' name: 'LeadSquared'
+1
View File
@@ -295,6 +295,7 @@ Rails.application.routes.draw do
post :auto_offline post :auto_offline
put :set_active_account put :set_active_account
post :resend_confirmation post :resend_confirmation
post :reset_access_token
end end
end end
+7
View File
@@ -39,3 +39,10 @@ process_stale_contacts_job:
cron: '30 04 * * *' cron: '30 04 * * *'
class: 'Internal::ProcessStaleContactsJob' class: 'Internal::ProcessStaleContactsJob'
queue: housekeeping queue: housekeeping
# executed daily at 0100 UTC
# to delete accounts marked for deletion
delete_accounts_job:
cron: '0 1 * * *'
class: 'Internal::DeleteAccountsJob'
queue: scheduled_jobs
@@ -19,9 +19,9 @@ module Enterprise::Api::V1::Accounts::ConversationsController
response = Captain::Copilot::ChatService.new( response = Captain::Copilot::ChatService.new(
assistant, assistant,
previous_messages: copilot_params[:previous_messages], previous_history: copilot_params[:previous_history],
conversation_history: @conversation.to_llm_text, conversation_id: @conversation.display_id,
language: @conversation.account.locale_english_name user_id: Current.user.id
).generate_response(copilot_params[:message]) ).generate_response(copilot_params[:message])
render json: { message: response['response'] } render json: { message: response['response'] }
@@ -44,6 +44,6 @@ module Enterprise::Api::V1::Accounts::ConversationsController
private private
def copilot_params def copilot_params
params.permit(:previous_messages, :message, :assistant_id) params.permit(:previous_history, :message, :assistant_id)
end end
end end
@@ -33,6 +33,6 @@ module Enterprise::SuperAdmin::AppConfigsController
def internal_config_options def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS %w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL] CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL]
end end
end end
+20 -5
View File
@@ -1,6 +1,6 @@
module Captain::ChatHelper module Captain::ChatHelper
def request_chat_completion def request_chat_completion
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{@messages}" } log_chat_completion_request
response = @client.chat( response = @client.chat(
parameters: { parameters: {
@@ -15,13 +15,17 @@ module Captain::ChatHelper
handle_response(response) handle_response(response)
end end
private
def handle_response(response) def handle_response(response)
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{response}" } Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
message = response.dig('choices', 0, 'message') message = response.dig('choices', 0, 'message')
if message['tool_calls'] if message['tool_calls']
process_tool_calls(message['tool_calls']) process_tool_calls(message['tool_calls'])
else else
JSON.parse(message['content'].strip) message = JSON.parse(message['content'].strip)
persist_message(message, 'assistant')
message
end end
end end
@@ -41,12 +45,14 @@ module Captain::ChatHelper
if @tool_registry.respond_to?(function_name) if @tool_registry.respond_to?(function_name)
execute_tool(function_name, arguments, tool_call_id) execute_tool(function_name, arguments, tool_call_id)
else else
process_invalid_tool_call(tool_call_id) process_invalid_tool_call(function_name, tool_call_id)
end end
end end
def execute_tool(function_name, arguments, tool_call_id) def execute_tool(function_name, arguments, tool_call_id)
persist_message({ content: "Using tool #{function_name}", function_name: function_name }, 'assistant_thinking')
result = @tool_registry.send(function_name, arguments) result = @tool_registry.send(function_name, arguments)
persist_message({ content: "Completed #{function_name} tool call", function_name: function_name }, 'assistant_thinking')
append_tool_response(result, tool_call_id) append_tool_response(result, tool_call_id)
end end
@@ -57,7 +63,8 @@ module Captain::ChatHelper
} }
end end
def process_invalid_tool_call(tool_call_id) def process_invalid_tool_call(function_name, tool_call_id)
persist_message({ content: 'Invalid tool call', function_name: function_name }, 'assistant_thinking')
append_tool_response('Tool not available', tool_call_id) append_tool_response('Tool not available', tool_call_id)
end end
@@ -68,4 +75,12 @@ module Captain::ChatHelper
content: content content: content
} }
end end
def log_chat_completion_request
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion
for messages #{@messages} with #{@tool_registry&.registered_tools&.length || 0} tools
"
)
end
end end
@@ -48,6 +48,12 @@ messenger:
enabled: true enabled: true
icon: 'icon-messenger-line' icon: 'icon-messenger-line'
config_key: 'facebook' config_key: 'facebook'
instagram:
name: 'Instagram'
description: 'Stay connected with your customers on Instagram'
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
whatsapp: whatsapp:
name: 'WhatsApp' name: 'WhatsApp'
description: 'Manage your WhatsApp business interactions from Chatwoot.' description: 'Manage your WhatsApp business interactions from Chatwoot.'
@@ -81,19 +87,19 @@ microsoft:
config_key: 'microsoft' config_key: 'microsoft'
linear: linear:
name: 'Linear' name: 'Linear'
description: 'Configuration for setting up Linear' description: 'Configuration for setting up Linear Integration'
enabled: true enabled: true
icon: 'icon-linear' icon: 'icon-linear'
config_key: 'linear' config_key: 'linear'
instagram: slack:
name: 'Instagram' name: 'Slack'
description: 'Configuration for setting up Instagram' description: 'Configuration for setting up Slack Integration'
enabled: true enabled: true
icon: 'icon-instagram' icon: 'icon-slack'
config_key: 'instagram' config_key: 'slack'
shopify: shopify:
name: 'Shopify' name: 'Shopify'
description: 'Configuration for setting up Shopify' description: 'Configuration for setting up Shopify Integration'
enabled: true enabled: true
icon: 'icon-shopify' icon: 'icon-shopify'
config_key: 'shopify' config_key: 'shopify'
+10
View File
@@ -25,6 +25,7 @@ class CopilotMessage < ApplicationRecord
validates :message_type, presence: true, inclusion: { in: message_types.keys } validates :message_type, presence: true, inclusion: { in: message_types.keys }
validates :message, presence: true validates :message, presence: true
validate :validate_message_attributes
after_create_commit :broadcast_message after_create_commit :broadcast_message
@@ -47,4 +48,13 @@ class CopilotMessage < ApplicationRecord
def broadcast_message def broadcast_message
Rails.configuration.dispatcher.dispatch(COPILOT_MESSAGE_CREATED, Time.zone.now, copilot_message: self) Rails.configuration.dispatcher.dispatch(COPILOT_MESSAGE_CREATED, Time.zone.now, copilot_message: self)
end end
def validate_message_attributes
return if message.blank?
allowed_keys = %w[content reasoning function_name]
invalid_keys = message.keys - allowed_keys
errors.add(:message, "contains invalid attributes: #{invalid_keys.join(', ')}") if invalid_keys.any?
end
end end
+1 -1
View File
@@ -40,7 +40,7 @@ class CopilotThread < ApplicationRecord
.order(created_at: :asc) .order(created_at: :asc)
.map do |copilot_message| .map do |copilot_message|
{ {
content: copilot_message.message, content: copilot_message.message['content'],
role: copilot_message.message_type role: copilot_message.message_type
} }
end end
@@ -6,7 +6,6 @@ module Enterprise::Concerns::User
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
has_many :copilot_threads, dependent: :destroy_async has_many :copilot_threads, dependent: :destroy_async
has_many :copilot_messages, dependent: :destroy_async
end end
def ensure_installation_pricing_plan_quantity def ensure_installation_pricing_plan_quantity
@@ -3,49 +3,110 @@ require 'openai'
class Captain::Copilot::ChatService < Llm::BaseOpenAiService class Captain::Copilot::ChatService < Llm::BaseOpenAiService
include Captain::ChatHelper include Captain::ChatHelper
attr_reader :assistant, :account, :user, :copilot_thread, :previous_history, :messages
def initialize(assistant, config) def initialize(assistant, config)
super() super()
@assistant = assistant @assistant = assistant
@conversation_history = config[:conversation_history] @account = assistant.account
@previous_messages = config[:previous_messages] || [] @user = nil
@language = config[:language] || 'english' @copilot_thread = nil
@previous_history = []
setup_user(config)
setup_message_history(config)
register_tools register_tools
@messages = [system_message, conversation_history_context] + @previous_messages @messages = build_messages(config)
@response = ''
end end
def generate_response(input) def generate_response(input)
@messages << { role: 'user', content: input } if input.present? @messages << { role: 'user', content: input } if input.present?
response = request_chat_completion response = request_chat_completion
Rails.logger.info("[CAPTAIN][CopilotChatService] Incrementing response usage for #{@assistant.account.id}")
@assistant.account.increment_response_usage Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Incrementing response usage for account #{@account.id}"
)
@account.increment_response_usage
response response
end end
private private
def setup_user(config)
@user = @account.users.find_by(id: config[:user_id]) if config[:user_id].present?
end
def build_messages(config)
messages= [system_message]
messages << account_id_context
messages += @previous_history if @previous_history.present?
messages += current_viewing_history(config[:conversation_id]) if config[:conversation_id].present?
messages
end
def setup_message_history(config)
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Previous History: #{config[:previous_history]&.length || 0}, Language: #{config[:language]}"
)
@copilot_thread = @account.copilot_threads.find_by(id: config[:thread_id]) if config[:thread_id].present?
@previous_history = if @copilot_thread.present?
@copilot_thread.previous_history
else
config[:previous_history].presence || []
end
end
def register_tools def register_tools
@tool_registry = Captain::ToolRegistryService.new(@assistant) @tool_registry = Captain::ToolRegistryService.new(@assistant, user: @user)
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService) @tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetArticleService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetContactService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetConversationService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchArticlesService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchContactsService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchConversationsService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchLinearIssuesService)
end end
def system_message def system_message
{ {
role: 'system', role: 'system',
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'], @language) content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'])
} }
end end
def conversation_history_context def account_id_context
{ {
role: 'system', role: 'system',
content: " content: "The current account id is #{@account.id}. The account is using #{@account.locale_english_name} as the language."
Message History with the user is below:
#{@conversation_history}
"
} }
end end
def current_viewing_history(conversation_id)
conversation = @account.conversations.find_by(display_id: conversation_id)
return [] unless conversation
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Setting viewing history for conversation_id=#{conversation_id}")
contact_id = conversation.contact_id
[{
role: 'system',
content: <<~HISTORY.strip
You are currently viewing the conversation with the following details:
Conversation ID: #{conversation_id}
Contact ID: #{contact_id}
HISTORY
}]
end
def persist_message(message, message_type = 'assistant')
return if @copilot_thread.blank?
@copilot_thread.copilot_messages.create!(
message: message,
message_type: message_type
)
end
end end
@@ -21,7 +21,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
private private
def register_tools def register_tools
@tool_registry = Captain::ToolRegistryService.new(@assistant) @tool_registry = Captain::ToolRegistryService.new(@assistant, user: nil)
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService) @tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
end end
@@ -31,4 +31,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.name, @assistant.config['product_name'], @assistant.config) content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.name, @assistant.config['product_name'], @assistant.config)
} }
end end
def persist_message(message, message_type = 'assistant')
# No need to implement
end
end end
@@ -56,18 +56,18 @@ class Captain::Llm::SystemPromptsService
SYSTEM_PROMPT_MESSAGE SYSTEM_PROMPT_MESSAGE
end end
def copilot_response_generator(product_name, language) def copilot_response_generator(product_name)
<<~SYSTEM_PROMPT_MESSAGE <<~SYSTEM_PROMPT_MESSAGE
[Identity] [Identity]
You are Captain, a helpful and friendly copilot assistant for support agents using the product #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions. You are Captain, a helpful and friendly copilot assistant for support agents using the product #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions.
You should only provide information related to #{product_name} and must not address queries about other products or external events. You should only provide information related to #{product_name} and must not address queries about other products or external events.
[Context] [Context]
You will be provided with the message history between the support agent and the customer. Use this context to understand the conversation flow, identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation. Identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
[Response Guidelines] [Response Guidelines]
- Use natural, polite, and conversational language that is clear and easy to follow. Keep sentences short and use simple words. - Use natural, polite, and conversational language that is clear and easy to follow. Keep sentences short and use simple words.
- Reply in the language the agent is using, if you're not able to detect the language, reply in #{language}. - Reply in the language the agent is using, if you're not able to detect the language.
- Provide brief and relevant responsestypically one or two sentences unless a more detailed explanation is necessary. - Provide brief and relevant responsestypically one or two sentences unless a more detailed explanation is necessary.
- Do not use your own training data or assumptions to answer queries. Base responses strictly on the provided information. - Do not use your own training data or assumptions to answer queries. Base responses strictly on the provided information.
- If the query is unclear, ask concise clarifying questions instead of making assumptions. - If the query is unclear, ask concise clarifying questions instead of making assumptions.
@@ -46,7 +46,7 @@ class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseS
end end
def active? def active?
@assistant.account.hooks.find_by(app_id: 'linear').present? @user.present? && @assistant.account.hooks.find_by(app_id: 'linear').present?
end end
private private
+1 -1
View File
@@ -1,6 +1,6 @@
class GlobalConfigService class GlobalConfigService
def self.load(config_key, default_value) def self.load(config_key, default_value)
config = ENV.fetch(config_key) { GlobalConfig.get(config_key)[config_key] } config = GlobalConfig.get(config_key)[config_key]
return config if config.present? return config if config.present?
# To support migrating existing instance relying on env variables # To support migrating existing instance relying on env variables
+2 -2
View File
@@ -32,8 +32,8 @@ class Integrations::Slack::HookBuilder
def fetch_access_token def fetch_access_token
client = Slack::Web::Client.new client = Slack::Web::Client.new
slack_access = client.oauth_v2_access( slack_access = client.oauth_v2_access(
client_id: ENV.fetch('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'), client_id: GlobalConfigService.load('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'),
client_secret: ENV.fetch('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'), client_secret: GlobalConfigService.load('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'),
code: params[:code], code: params[:code],
redirect_uri: Integrations::App.slack_integration_url redirect_uri: Integrations::App.slack_integration_url
) )
@@ -296,4 +296,32 @@ RSpec.describe 'Profile API', type: :request do
end end
end end
end end
describe 'POST /api/v1/profile/reset_access_token' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post '/api/v1/profile/reset_access_token'
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'regenerates the access token' do
old_token = agent.access_token.token
post '/api/v1/profile/reset_access_token',
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
agent.reload
json_response = response.parsed_body
expect(json_response['access_token']).to eq(agent.access_token.token)
expect(agent.access_token.token).not_to eq(old_token)
end
end
end
end end
@@ -23,6 +23,10 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
end end
describe '#omniauth_sucess' do describe '#omniauth_sucess' do
before do
GlobalConfig.clear_cache
end
it 'allows signup' do it 'allows signup' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('test_not_preset@example.com') set_omniauth_config('test_not_preset@example.com')
@@ -47,9 +47,9 @@ RSpec.describe CopilotThread, type: :model do
expect(history.length).to eq(2) expect(history.length).to eq(2)
expect(history[0][:role]).to eq('user') expect(history[0][:role]).to eq('user')
expect(history[0][:content]).to eq({ 'content' => 'User message' }) expect(history[0][:content]).to eq('User message')
expect(history[1][:role]).to eq('assistant') expect(history[1][:role]).to eq('assistant')
expect(history[1][:content]).to eq({ 'content' => 'Assistant message' }) expect(history[1][:content]).to eq('Assistant message')
end end
end end
@@ -2,87 +2,245 @@ require 'rails_helper'
RSpec.describe Captain::Copilot::ChatService do RSpec.describe Captain::Copilot::ChatService do
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) } let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
let(:captain_inbox_association) { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) } let(:user) { create(:user, account: account) }
let(:mock_captain_agent) { instance_double(Captain::Agent) }
let(:mock_captain_tool) { instance_double(Captain::Tool) }
let(:mock_openai_client) { instance_double(OpenAI::Client) }
let(:inbox) { create(:inbox, account: account) } let(:inbox) { create(:inbox, account: account) }
let(:assistant) { create(:captain_assistant, account: account) } let(:assistant) { create(:captain_assistant, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
let(:mock_openai_client) { instance_double(OpenAI::Client) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
let!(:copilot_message) do
create(
:captain_copilot_message, account: account, copilot_thread: copilot_thread
)
end
let(:previous_history) { [{ role: copilot_message.message_type, content: copilot_message.message['content'] }] }
let(:config) do
{ user_id: user.id, thread_id: copilot_thread.id, conversation_id: conversation.display_id }
end
before do before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
allow(mock_openai_client).to receive(:chat).and_return({
choices: [{ message: { content: '{ "content": "Hey" }' } }]
}.with_indifferent_access)
end end
describe '#initialize' do describe '#initialize' do
it 'sets default language to english when not specified' do it 'sets up the service with correct instance variables' do
service = described_class.new(assistant, { previous_messages: [], conversation_history: '' }) service = described_class.new(assistant, config)
expect(service.instance_variable_get(:@language)).to eq('english')
expect(service.assistant).to eq(assistant)
expect(service.account).to eq(account)
expect(service.user).to eq(user)
expect(service.copilot_thread).to eq(copilot_thread)
expect(service.previous_history).to eq(previous_history)
end end
it 'uses the specified language when provided' do it 'builds messages with system message and account context' do
service = described_class.new(assistant, { service = described_class.new(assistant, config)
previous_messages: [], messages = service.messages
conversation_history: '',
language: 'spanish' expect(messages.first[:role]).to eq('system')
}) expect(messages.second[:role]).to eq('system')
expect(service.instance_variable_get(:@language)).to eq('spanish') expect(messages.second[:content]).to include(account.id.to_s)
end end
end end
describe '#generate_response' do describe '#generate_response' do
before do let(:service) { described_class.new(assistant, config) }
allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
allow(mock_openai_client).to receive(:chat).and_return({ choices: [{ message: { content: '{ "result": "Hey" }' } }] }.with_indifferent_access)
allow(Captain::Agent).to receive(:new).and_return(mock_captain_agent) it 'adds user input to messages when present' do
allow(mock_captain_agent).to receive(:execute).and_return(true) expect do
allow(mock_captain_agent).to receive(:register_tool).and_return(true) service.generate_response('Hello')
end.to(change { service.messages.count }.by(1))
allow(Captain::Tool).to receive(:new).and_return(mock_captain_tool) last_message = service.messages.last
allow(mock_captain_tool).to receive(:register_method).and_return(true) expect(last_message[:role]).to eq('user')
expect(last_message[:content]).to eq('Hello')
allow(account).to receive(:increment_response_usage).and_return(true)
end end
it 'increments usage' do it 'does not add user input to messages when blank' do
described_class.new(assistant, { previous_messages: ['Hello'], conversation_history: 'Hi' }).generate_response('Hey') expect do
expect(account).to have_received(:increment_response_usage).once service.generate_response('')
end.not_to(change { service.messages.count })
end end
it 'includes language in system message' do it 'returns the response from request_chat_completion' do
service = described_class.new(assistant, { expect(service.generate_response('Hello')).to eq({ 'content' => 'Hey' })
previous_messages: [], end
conversation_history: '',
language: 'spanish'
})
allow(Captain::Llm::SystemPromptsService).to receive(:copilot_response_generator) context 'when response contains tool calls' do
.with(assistant.config['product_name'], 'spanish') before do
.and_return('Spanish system prompt') allow(mock_openai_client).to receive(:chat).and_return(
{
choices: [{ message: { 'tool_calls' => tool_calls } }]
}.with_indifferent_access,
{
choices: [{ message: { content: '{ "content": "Tool response processed" }' } }]
}.with_indifferent_access
)
end
system_message = service.send(:system_message) context 'when tool call is valid' do
expect(system_message[:content]).to eq('Spanish system prompt') let(:tool_calls) do
[{
'id' => 'call_123',
'function' => {
'name' => 'get_conversation',
'arguments' => "{ \"conversation_id\": #{conversation.display_id} }"
}
}]
end
it 'processes tool calls and appends them to messages' do
result = service.generate_response("Find conversation #{conversation.id}")
expect(result).to eq({ 'content' => 'Tool response processed' })
expect(service.messages).to include(
{ role: 'assistant', tool_calls: tool_calls }
)
expect(service.messages).to include(
{
role: 'tool', tool_call_id: 'call_123', content: conversation.to_llm_text
}
)
expect(result).to eq({ 'content' => 'Tool response processed' })
end
end
context 'when tool call is invalid' do
let(:tool_calls) do
[{
'id' => 'call_123',
'function' => {
'name' => 'get_settings',
'arguments' => '{}'
}
}]
end
it 'handles invalid tool calls' do
result = service.generate_response('Find settings')
expect(result).to eq({ 'content' => 'Tool response processed' })
expect(service.messages).to include(
{
role: 'assistant', tool_calls: tool_calls
}
)
expect(service.messages).to include(
{
role: 'tool',
tool_call_id: 'call_123',
content: 'Tool not available'
}
)
end
end
end end
end end
describe '#execute' do describe '#setup_user' do
before do it 'sets user when user_id is present in config' do
allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client) service = described_class.new(assistant, { user_id: user.id })
allow(mock_openai_client).to receive(:chat).and_return({ choices: [{ message: { content: '{ "result": "Hey" }' } }] }.with_indifferent_access) expect(service.user).to eq(user)
allow(Captain::Agent).to receive(:new).and_return(mock_captain_agent)
allow(mock_captain_agent).to receive(:execute).and_return(true)
allow(mock_captain_agent).to receive(:register_tool).and_return(true)
allow(Captain::Tool).to receive(:new).and_return(mock_captain_tool)
allow(mock_captain_tool).to receive(:register_method).and_return(true)
allow(account).to receive(:increment_response_usage).and_return(true)
end end
it 'increments usage' do it 'does not set user when user_id is not present in config' do
described_class.new(assistant, { previous_messages: ['Hello'], conversation_history: 'Hi' }).generate_response('Hey') service = described_class.new(assistant, {})
expect(account).to have_received(:increment_response_usage).once expect(service.user).to be_nil
end
end
describe '#setup_message_history' do
context 'when thread_id is present' do
it 'finds the copilot thread and sets previous history from it' do
service = described_class.new(assistant, { thread_id: copilot_thread.id })
expect(service.copilot_thread).to eq(copilot_thread)
expect(service.previous_history).to eq previous_history
end
end
context 'when thread_id is not present' do
it 'uses previous_history from config if present' do
custom_history = [{ role: 'user', content: 'Custom message' }]
service = described_class.new(assistant, { previous_history: custom_history })
expect(service.copilot_thread).to be_nil
expect(service.previous_history).to eq(custom_history)
end
it 'uses empty array if previous_history is not present in config' do
service = described_class.new(assistant, {})
expect(service.copilot_thread).to be_nil
expect(service.previous_history).to eq([])
end
end
end
describe '#build_messages' do
it 'includes system message and account context' do
service = described_class.new(assistant, {})
messages = service.messages
expect(messages.first[:role]).to eq('system')
expect(messages.second[:role]).to eq('system')
expect(messages.second[:content]).to include(account.id.to_s)
end
it 'includes previous history when present' do
custom_history = [{ role: 'user', content: 'Custom message' }]
service = described_class.new(assistant, { previous_history: custom_history })
messages = service.messages
expect(messages.count).to be >= 3
expect(messages.any? { |m| m[:content] == 'Custom message' }).to be true
end
it 'includes current viewing history when conversation_id is present' do
service = described_class.new(assistant, { conversation_id: conversation.display_id })
messages = service.messages
viewing_history = messages.find { |m| m[:content].include?('You are currently viewing the conversation') }
expect(viewing_history).not_to be_nil
expect(viewing_history[:content]).to include(conversation.display_id.to_s)
expect(viewing_history[:content]).to include(contact.id.to_s)
end
end
describe '#persist_message' do
context 'when copilot_thread is present' do
it 'creates a copilot message' do
allow(mock_openai_client).to receive(:chat).and_return({
choices: [{ message: { content: '{ "content": "Hey" }' } }]
}.with_indifferent_access)
expect do
described_class.new(assistant, { thread_id: copilot_thread.id }).generate_response('Hello')
end.to change(CopilotMessage, :count).by(1)
last_message = CopilotMessage.last
expect(last_message.message_type).to eq('assistant')
expect(last_message.message['content']).to eq('Hey')
end
end
context 'when copilot_thread is not present' do
it 'does not create a copilot message' do
allow(mock_openai_client).to receive(:chat).and_return({
choices: [{ message: { content: '{ "content": "Hey" }' } }]
}.with_indifferent_access)
expect do
described_class.new(assistant, {}).generate_response('Hello')
end.not_to(change(CopilotMessage, :count))
end
end end
end end
end end
@@ -49,6 +49,10 @@ RSpec.describe Enterprise::ClearbitLookupService do
end end
context 'when Clearbit is not enabled' do context 'when Clearbit is not enabled' do
before do
GlobalConfig.clear_cache
end
it 'returns nil without making an API call' do it 'returns nil without making an API call' do
with_modified_env CLEARBIT_API_KEY: nil do with_modified_env CLEARBIT_API_KEY: nil do
expect(Net::HTTP).not_to receive(:start) expect(Net::HTTP).not_to receive(:start)
@@ -0,0 +1,44 @@
require 'rails_helper'
RSpec.describe Internal::DeleteAccountsJob do
subject(:job) { described_class.perform_later }
let!(:account_marked_for_deletion) { create(:account) }
let!(:future_deletion_account) { create(:account) }
let!(:active_account) { create(:account) }
let(:account_deletion_service) { instance_double(AccountDeletionService, perform: true) }
before do
account_marked_for_deletion.update!(
custom_attributes: {
'marked_for_deletion_at' => 1.day.ago.iso8601,
'marked_for_deletion_reason' => 'user_requested'
}
)
future_deletion_account.update!(
custom_attributes: {
'marked_for_deletion_at' => 3.days.from_now.iso8601,
'marked_for_deletion_reason' => 'user_requested'
}
)
allow(AccountDeletionService).to receive(:new).and_return(account_deletion_service)
end
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
end
describe '#perform' do
it 'calls AccountDeletionService for accounts past deletion date' do
described_class.new.perform
expect(AccountDeletionService).to have_received(:new).with(account: account_marked_for_deletion)
expect(AccountDeletionService).not_to have_received(:new).with(account: future_deletion_account)
expect(AccountDeletionService).not_to have_received(:new).with(account: active_account)
expect(account_deletion_service).to have_received(:perform)
end
end
end
@@ -203,24 +203,4 @@ describe ActionCableListener do
listener.conversation_updated(event) listener.conversation_updated(event)
end end
end end
describe '#copilot_message_created' do
let(:event_name) { :copilot_message_created }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
let(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread) }
let(:event) { Events::Base.new(event_name, Time.zone.now, copilot_message: copilot_message) }
it 'broadcasts message to the user' do
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[user.pubsub_token],
'copilot.message.created',
copilot_message.push_event_data
)
listener.copilot_message_created(event)
end
end
end end
@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe AdministratorNotifications::AccountComplianceMailer do
let(:account) do
create(:account, custom_attributes: { 'marked_for_deletion_at' => 1.day.ago.iso8601, 'marked_for_deletion_reason' => 'user_requested' })
end
let(:soft_deleted_users) do
[
{ id: 1, original_email: 'user1@example.com' },
{ id: 2, original_email: 'user2@example.com' }
]
end
describe 'account_deleted' do
it 'has the right subject format' do
subject = described_class.new.send(:subject_for, account)
expect(subject).to eq("Account Deletion Notice for #{account.id} - #{account.name}")
end
it 'includes soft deleted users in meta when provided' do
mailer_instance = described_class.new
allow(mailer_instance).to receive(:params).and_return(
{ soft_deleted_users: soft_deleted_users }
)
meta = mailer_instance.send(:build_meta, account)
expect(meta['deleted_user_count']).to eq(2)
expect(meta['soft_deleted_users'].size).to eq(2)
expect(meta['soft_deleted_users'].first['user_id']).to eq('1')
expect(meta['soft_deleted_users'].first['user_email']).to eq('user1@example.com')
end
end
end
@@ -0,0 +1,63 @@
require 'rails_helper'
RSpec.describe AccountDeletionService do
let(:account) { create(:account) }
let(:mailer) { instance_double(ActionMailer::MessageDelivery, deliver_later: nil) }
describe '#perform' do
before do
allow(DeleteObjectJob).to receive(:perform_later)
allow(AdministratorNotifications::AccountComplianceMailer).to receive(:with).and_return(
instance_double(AdministratorNotifications::AccountComplianceMailer, account_deleted: mailer)
)
end
it 'enqueues DeleteObjectJob with the account' do
described_class.new(account: account).perform
expect(DeleteObjectJob).to have_received(:perform_later).with(account)
end
it 'sends a compliance notification email' do
described_class.new(account: account).perform
expect(AdministratorNotifications::AccountComplianceMailer).to have_received(:with) do |args|
expect(args[:account]).to eq(account)
expect(args).to include(:soft_deleted_users)
end
expect(mailer).to have_received(:deliver_later)
end
context 'when handling users' do
let(:user_with_one_account) { create(:user) }
let(:user_with_multiple_accounts) { create(:user) }
let(:second_account) { create(:account) }
before do
create(:account_user, user: user_with_one_account, account: account)
create(:account_user, user: user_with_multiple_accounts, account: account)
create(:account_user, user: user_with_multiple_accounts, account: second_account)
end
it 'soft deletes users who only belong to the deleted account' do
original_email = user_with_one_account.email
described_class.new(account: account).perform
# Reload the user to get the updated email
user_with_one_account.reload
expect(user_with_one_account.email).to eq("#{original_email}-deleted.com")
end
it 'does not modify emails for users belonging to multiple accounts' do
original_email = user_with_multiple_accounts.email
described_class.new(account: account).perform
# Reload the user to get the updated email
user_with_multiple_accounts.reload
expect(user_with_multiple_accounts.email).to eq(original_email)
end
end
end
end
@@ -56,6 +56,7 @@ RSpec.describe Conversations::MessageWindowService do
describe 'on Facebook channels' do describe 'on Facebook channels' do
before do before do
stub_request(:post, /graph.facebook.com/) stub_request(:post, /graph.facebook.com/)
GlobalConfig.clear_cache
end end
let!(:facebook_channel) { create(:channel_facebook_page) } let!(:facebook_channel) { create(:channel_facebook_page) }