Compare commits

..
33 changed files with 259 additions and 217 deletions
@@ -1,4 +1,5 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :check_authorization
before_action :agent_bot, except: [:index, :create]
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
@@ -1,4 +1,5 @@
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :fetch_label, except: [:index, :create]
before_action :check_authorization
@@ -3,13 +3,8 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import {
isTwilioComplete,
isTwilioMediaTemplate,
getTwilioMediaVariableKey,
getTwilioMediaUrl,
applyTwilioMediaFilename,
} from '@chatwoot/utils';
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -45,23 +40,30 @@ const templateBody = computed(() => {
return props.template.body || '';
});
// Media-template detection and variable extraction are shared with the mobile
// app via @chatwoot/utils.
const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template));
const hasMediaTemplate = computed(() => {
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
});
const hasVariables = computed(() => {
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
});
const mediaVariableKey = computed(() =>
getTwilioMediaVariableKey(props.template)
);
const mediaVariableKey = computed(() => {
if (!hasMediaTemplate.value) return null;
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
if (!mediaUrl) return null;
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
});
const hasMediaVariable = computed(() => mediaVariableKey.value !== null);
const hasMediaVariable = computed(() => {
return hasMediaTemplate.value && mediaVariableKey.value !== null;
});
const templateMediaUrl = computed(() =>
hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : ''
);
const templateMediaUrl = computed(() => {
if (!hasMediaTemplate.value) return '';
return props.template?.types?.['twilio/media']?.media?.[0] || '';
});
const variablePattern = computed(() => {
if (!hasVariables.value) return [];
@@ -81,10 +83,26 @@ const renderedTemplate = computed(() => {
return rendered;
});
// Completeness validation is shared with the mobile app via @chatwoot/utils.
const isFormInvalid = computed(
() => !isTwilioComplete(props.template, processedParams.value)
);
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaVariable.value) return false;
if (hasVariables.value) {
const hasEmptyVariable = variablePattern.value.some(
variable => !processedParams.value[variable]
);
if (hasEmptyVariable) return true;
}
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
!processedParams.value[mediaVariableKey.value]
) {
return true;
}
return false;
});
const v$ = useVuelidate(
{
@@ -117,11 +135,19 @@ const sendMessage = () => {
const { friendly_name, language } = props.template;
// For media templates, reduce the media URL to a filename before sending.
const processedParameters = applyTwilioMediaFilename(
props.template,
processedParams.value
);
// Process parameters and extract filename from media URL if needed
const processedParameters = { ...processedParams.value };
// For media templates, extract filename from full URL
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
processedParameters[mediaVariableKey.value]
) {
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
processedParameters[mediaVariableKey.value]
);
}
const payload = {
message: renderedTemplate.value,
@@ -13,7 +13,6 @@ import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import { isWhatsAppComplete } from '@chatwoot/utils';
import Input from 'dashboard/components-next/input/Input.vue';
import {
buildTemplateParameters,
@@ -85,10 +84,29 @@ const renderedTemplate = computed(() => {
return replaceTemplateVariables(bodyText.value, processedParams.value);
});
// Completeness validation is shared with the mobile app via @chatwoot/utils.
const isFormInvalid = computed(
() => !isWhatsAppComplete(props.template, processedParams.value)
);
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaHeader.value) return false;
if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
return true;
}
if (hasVariables.value && processedParams.value.body) {
const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
value => !value
);
if (hasEmptyBodyVariable) return true;
}
if (processedParams.value.buttons) {
const hasEmptyButtonParameter = processedParams.value.buttons.some(
button => !button.parameter
);
if (hasEmptyButtonParameter) return true;
}
return false;
});
const v$ = useVuelidate(
{
+19 -2
View File
@@ -127,8 +127,25 @@ export const getHostNameFromURL = url => {
}
};
// Shared with the mobile app via @chatwoot/utils.
export { extractFilenameFromUrl } from '@chatwoot/utils';
/**
* Extracts filename from a URL
* @param {string} url - The URL to extract filename from
* @returns {string} - The extracted filename or original URL if extraction fails
*/
export const extractFilenameFromUrl = url => {
if (!url || typeof url !== 'string') return url;
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split('/').pop();
return filename || url;
} catch (error) {
// If URL parsing fails, try to extract filename using regex
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
return match ? match[1] : url;
}
};
/**
* Normalizes a comma/newline separated list of domains
@@ -156,18 +156,12 @@ describe('templateHelper', () => {
]);
});
it('should handle templates with no variables but a media header', () => {
it('should handle templates with no variables', () => {
const emptyTemplate = templates.find(
t => t.name === 'no_variable_template'
);
const result = buildTemplateParameters(emptyTemplate);
// hasMediaHeader is derived from the template, so the document header is kept.
expect(result.body).toBeUndefined();
expect(result.header).toEqual({
media_url: '',
media_type: 'document',
media_name: '',
});
const result = buildTemplateParameters(emptyTemplate, false);
expect(result).toEqual({});
});
it('should build parameters for templates with multiple component types', () => {
@@ -1,16 +1,19 @@
import { processVariable, buildWhatsAppProcessedParams } from '@chatwoot/utils';
// Constants and pure template helpers are shared with the mobile app via
// @chatwoot/utils so the logic lives in one place.
export {
MEDIA_FORMATS,
COMPONENT_TYPES,
findComponentByType,
processVariable,
} from '@chatwoot/utils';
// Constants
export const DEFAULT_LANGUAGE = 'en';
export const DEFAULT_CATEGORY = 'UTILITY';
export const COMPONENT_TYPES = {
HEADER: 'HEADER',
BODY: 'BODY',
BUTTONS: 'BUTTONS',
};
export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
export const findComponentByType = (template, type) =>
template.components?.find(component => component.type === type);
export const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
export const allKeysRequired = value => {
const keys = Object.keys(value);
@@ -24,7 +27,70 @@ export const replaceTemplateVariables = (templateText, processedParams) => {
});
};
// The media-header flag is derived from the template inside the shared helper;
// the second argument is kept for backwards-compatible call sites.
export const buildTemplateParameters = template =>
buildWhatsAppProcessedParams(template);
export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
const allVariables = {};
const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
if (!bodyComponent) return allVariables;
const templateString = bodyComponent.text;
// Process body variables
const matchedVariables = templateString.match(/{{([^}]+)}}/g);
if (matchedVariables) {
allVariables.body = {};
matchedVariables.forEach(variable => {
const key = processVariable(variable);
allVariables.body[key] = '';
});
}
if (hasMediaHeaderValue) {
if (!allVariables.header) allVariables.header = {};
allVariables.header.media_url = '';
allVariables.header.media_type = headerComponent.format.toLowerCase();
// For document templates, include media_name field for filename support
if (headerComponent.format.toLowerCase() === 'document') {
allVariables.header.media_name = '';
}
}
// Process button variables
const buttonComponents = template.components.filter(
component => component.type === COMPONENT_TYPES.BUTTONS
);
buttonComponents.forEach(buttonComponent => {
if (buttonComponent.buttons) {
buttonComponent.buttons.forEach((button, index) => {
// Handle URL buttons with variables
if (button.type === 'URL' && button.url && button.url.includes('{{')) {
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
if (buttonVars.length > 0) {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'url',
parameter: '',
url: button.url,
variables: buttonVars.map(v => processVariable(v)),
};
}
}
// Handle copy code buttons
if (button.type === 'COPY_CODE') {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'copy_code',
parameter: '',
};
}
});
}
});
return allVariables;
};
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
@@ -14,7 +14,7 @@ const { accountId, currentAccount } = useAccount();
const globalConfig = useMapGetter('globalConfig/get');
const enabledFeatures = computed(() => currentAccount.value?.features || {});
const enabledFeatures = ref({});
const hasTiktokConfigured = computed(() => {
return window.chatwootConfig?.tiktokAppId;
@@ -105,6 +105,10 @@ const channelList = computed(() => {
return channels;
});
const initializeEnabledFeatures = async () => {
enabledFeatures.value = currentAccount.value.features;
};
const initChannelAuth = channel => {
const params = {
sub_page: channel,
@@ -112,6 +116,10 @@ const initChannelAuth = channel => {
};
router.push({ name: 'settings_inboxes_page_channel', params });
};
onMounted(() => {
initializeEnabledFeatures();
});
</script>
<template>
@@ -3,7 +3,6 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'next/avatar/Avatar.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import { useBranding } from 'shared/composables/useBranding';
const props = defineProps({
senderNameType: {
@@ -23,7 +22,6 @@ const props = defineProps({
const emit = defineEmits(['update']);
const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const senderNameKeyOptions = computed(() => [
{
@@ -32,7 +30,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'),
preview: {
senderName: 'Smith',
businessName: replaceInstallationName('Chatwoot'),
businessName: 'Chatwoot',
email: '<support@yourbusiness.com>',
},
},
@@ -42,7 +40,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'),
preview: {
senderName: '',
businessName: replaceInstallationName('Chatwoot'),
businessName: 'Chatwoot',
email: '<support@yourbusiness.com>',
},
},
@@ -53,7 +51,7 @@ const isKeyOptionFriendly = key => key === 'friendly';
const userName = keyOption =>
isKeyOptionFriendly(keyOption.key)
? keyOption.preview.senderName
: props.businessName || keyOption.preview.businessName;
: keyOption.preview.businessName;
const toggleSenderNameType = key => {
emit('update', key);
@@ -7,7 +7,6 @@ import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel';
import WhatsappChannel from '../../api/channel/whatsappChannel';
import { throwErrorMessage } from '../utils/api';
import { isSendableTemplate } from '@chatwoot/utils';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
@@ -68,8 +67,45 @@ export const getters = {
return [];
}
// Sendable-template filtering is shared with the mobile app via @chatwoot/utils.
return templates.filter(isSendableTemplate);
return templates.filter(template => {
// Ensure template has required properties
if (!template || !template.status || !template.components) {
return false;
}
// Only show approved templates
if (template.status.toLowerCase() !== 'approved') {
return false;
}
// Filter out authentication templates
if (template.category === 'AUTHENTICATION') {
return false;
}
// Filter out CSAT templates (customer_satisfaction_survey and its versions)
if (
template.name &&
template.name.startsWith('customer_satisfaction_survey')
) {
return false;
}
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
const hasUnsupportedComponents = template.components.some(
component =>
['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes(
component.type
) ||
(component.type === 'HEADER' && component.format === 'LOCATION')
);
if (hasUnsupportedComponents) {
return false;
}
return true;
});
},
getNewConversationInboxes($state) {
return $state.records.filter(inbox => {
@@ -73,13 +73,13 @@ describe('useBranding', () => {
expect(result).toBe('Welcome to our platform');
});
it('should replace "Chatwoot" regardless of casing', () => {
it('should be case-sensitive for "Chatwoot"', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName(
'Welcome to chatwoot, Chatwoot and CHATWOOT'
'Welcome to chatwoot and CHATWOOT'
);
expect(result).toBe('Welcome to MyCompany, MyCompany and MyCompany');
expect(result).toBe('Welcome to chatwoot and CHATWOOT');
});
it('should handle special characters in installation name', () => {
@@ -7,8 +7,7 @@ import { useMapGetter } from 'dashboard/composables/store.js';
export function useBranding() {
const globalConfig = useMapGetter('globalConfig/get');
/**
* Replaces "Chatwoot" (any casing) in text with the installation name from
* global config
* Replaces "Chatwoot" in text with the installation name from global config
* @param {string} text - The text to process
* @returns {string} - Text with "Chatwoot" replaced by installation name
*/
@@ -18,7 +17,7 @@ export function useBranding() {
const installationName = globalConfig.value?.installationName;
if (!installationName) return text;
return text.replace(/chatwoot/gi, installationName);
return text.replace(/Chatwoot/g, installationName);
};
return {
@@ -1,37 +0,0 @@
class Whatsapp::InReplyToMessageFinder
MESSAGE_TOKEN_PATTERN = /(?<![0-9a-f])(?:[0-9a-f]{32}|[0-9a-f]{20})(?![0-9a-f])/i
pattr_initialize [:conversation!, :source_id!]
def perform
exact_match || matching_scoped_message
end
private
def exact_match
conversation.messages.find_by(source_id: source_id)
end
def matching_scoped_message
token = message_token(source_id)
return if token.blank?
# Phone-scoped and BSUID-scoped WAMIDs can carry the same message token
# even when their complete source IDs differ.
matches = conversation.messages.where('source_id LIKE ?', 'wamid.%').select(:id, :source_id).select do |message|
message_token(message.source_id) == token
end
matches.one? ? matches.first : nil
end
def message_token(message_id)
return unless message_id.start_with?('wamid.')
tokens = Base64.strict_decode64(message_id.delete_prefix('wamid.')).scan(MESSAGE_TOKEN_PATTERN)
tokens.one? ? tokens.first.downcase : nil
rescue ArgumentError
nil
end
end
@@ -174,7 +174,6 @@ class Whatsapp::IncomingMessageBaseService
def message_content_attributes(message)
content_attrs = outgoing_echo ? { external_echo: true } : {}
content_attrs[:in_reply_to] = @in_reply_to_message_id if @in_reply_to_message_id.present?
content_attrs[:in_reply_to_external_id] = @in_reply_to_external_id if @in_reply_to_external_id.present?
referral_content_attrs = referral_attributes(message)
content_attrs[:referral] = referral_content_attrs if referral_content_attrs.present?
@@ -69,12 +69,6 @@ module Whatsapp::IncomingMessageServiceHelpers
def process_in_reply_to(message)
@in_reply_to_external_id = message['context']&.[]('id')
return if @in_reply_to_external_id.blank?
@in_reply_to_message_id = Whatsapp::InReplyToMessageFinder.new(
conversation: @conversation,
source_id: @in_reply_to_external_id
).perform&.id
end
def referral_attributes(message)
+3 -27
View File
@@ -31,11 +31,10 @@ class Rack::Attack
(default_allowed_ips + env_allowed_ips).include?(remote_ip)
end
# Rails allows paths with extensions and trailing slashes, so compare against a normalized path.
# For example, /auth, /auth.json, and /auth/ should all use the same throttle.
# Rails would allow requests to paths with extensions, so lets compare against the path with extension stripped
# example /auth & /auth.json would both work
def path_without_extensions
normalized_path = path[/^[^.]+/]
normalized_path == '/' ? normalized_path : normalized_path.sub(%r{/+\z}, '')
path[/^[^.]+/]
end
end
@@ -189,11 +188,6 @@ class Rack::Attack
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req|
req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
end
## Prevent Transcript Bombing on Widget API ###
throttle('api/v1/widget/conversations/transcript', limit: 5, period: 1.hour) do |req|
req.ip if req.path_without_extensions == '/api/v1/widget/conversations/transcript' && req.post?
end
end
##-----------------------------------------------##
@@ -218,24 +212,6 @@ class Rack::Attack
match_data[:account_id] if match_data.present?
end
## Prevent abuse of agent create APIs (per account, covers bulk_create)
throttle('/api/v1/accounts/:account_id/agents POST',
limit: ENV.fetch('RATE_LIMIT_AGENT_CREATE', '100').to_i, period: 1.day) do |req|
next unless req.post?
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/agents(?:/bulk_create)?/?\z}.match(req.path_without_extensions)
match_data[:account_id] if match_data.present?
end
## Prevent abuse of agent delete API (per account)
throttle('/api/v1/accounts/:account_id/agents/:id DELETE',
limit: ENV.fetch('RATE_LIMIT_AGENT_DELETE', '50').to_i, period: 1.day) do |req|
next unless req.delete?
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/agents/(?<id>\d+)/?\z}.match(req.path_without_extensions)
match_data[:account_id] if match_data.present?
end
## Prevent Abuse of attachment upload APIs ##
throttle('/api/v1/accounts/:account_id/upload', limit: 60, period: 1.hour) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/upload}.match(req.path)
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :validate_params
before_action :type_matches?
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :ensure_custom_tools_enabled
before_action -> { check_authorization(Captain::CustomTool) }
before_action :set_custom_tool, only: [:show, :update, :destroy]
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Scenario) }
before_action :set_assistant
before_action :set_scenario, only: [:show, :update, :destroy]
+1 -10
View File
@@ -1,9 +1,4 @@
module Enterprise::Account
# Transitional marker for the Captain V1 to V2 rollout. New cloud accounts get
# this marker so plan reconciliation can enable V2 for them without upgrading
# existing paid accounts. Remove once every account is migrated to V2.
CAPTAIN_V2_DEFAULT_ELIGIBLE = 'captain_v2_default_eligible'.freeze
class << self
def captain_document_sync_intervals
parse_captain_document_sync_intervals(InstallationConfig.find_by(name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS')&.value)
@@ -107,11 +102,7 @@ module Enterprise::Account
def enable_default_features
super
if ChatwootApp.self_hosted_enterprise?
enable_features('captain_integration', 'captain_integration_v2')
elsif ChatwootApp.chatwoot_cloud?
internal_attributes[CAPTAIN_V2_DEFAULT_ELIGIBLE] = true
end
enable_features('captain_integration', 'captain_integration_v2') if ChatwootApp.self_hosted_enterprise?
end
def sync_assignment_features
@@ -59,11 +59,6 @@ class Captain::Assistant::SessionCaptureService
def current_turn_history
history = Array(context[:conversation_history])
last_user_index = history.rindex { |message| message[:role].to_s == 'user' }
current_turn = last_user_index ? history[last_user_index..] : history
current_turn.map do |message|
content = message[:content]
content.is_a?(RubyLLM::Content) ? message.merge(content: content.to_h) : message
end
last_user_index ? history[last_user_index..] : history
end
end
@@ -13,6 +13,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
channel_instagram
channel_tiktok
captain_integration
captain_integration_v2
captain_document_auto_sync
advanced_search_indexing
advanced_search
@@ -37,9 +38,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
def perform
account.disable_features(*PREMIUM_PLAN_FEATURES)
account.disable_features('captain_integration_v2') if default_plan?
account.enable_features(*current_plan_features)
account.enable_features('captain_integration_v2') if captain_v2_default_eligible?
account.enable_features(*manually_managed_features)
account.save!
end
@@ -72,8 +71,4 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
def manually_managed_features
@manually_managed_features ||= Internal::Accounts::InternalAttributesService.new(account).manually_managed_features
end
def captain_v2_default_eligible?
!default_plan? && account.internal_attributes[Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE] == true
end
end
+1 -1
View File
@@ -35,7 +35,7 @@
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.3.22",
"@chatwoot/utils": "^0.0.56",
"@chatwoot/utils": "^0.0.55",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
+5 -5
View File
@@ -28,8 +28,8 @@ importers:
specifier: 1.3.22
version: 1.3.22
'@chatwoot/utils':
specifier: ^0.0.56
version: 0.0.56
specifier: ^0.0.55
version: 0.0.55
'@formkit/core':
specifier: ^1.7.2
version: 1.7.2
@@ -464,8 +464,8 @@ packages:
'@chatwoot/prosemirror-schema@1.3.22':
resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
'@chatwoot/utils@0.0.56':
resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==}
'@chatwoot/utils@0.0.55':
resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
engines: {node: '>=10'}
'@codemirror/commands@6.7.0':
@@ -5154,7 +5154,7 @@ snapshots:
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1
'@chatwoot/utils@0.0.56':
'@chatwoot/utils@0.0.55':
dependencies:
date-fns: 2.30.0
@@ -64,14 +64,6 @@ RSpec.describe 'Agent Bot API', type: :request do
expect(response).to have_http_status(:success)
expect(response.body).to include(agent_bot.access_token.token)
end
it 'supports API token authentication' do
get "/api/v1/accounts/#{account.id}/agent_bots",
headers: { api_access_token: admin.access_token.token },
as: :json
expect(response).to have_http_status(:success)
end
end
end
+1 -3
View File
@@ -284,13 +284,11 @@ RSpec.describe Account, type: :model do
expect(account.captain_models).to be_nil
end
it 'marks new cloud accounts as eligible for the Captain V2 paid-plan default' do
it 'does not enable Captain for new cloud accounts before plan reconciliation' do
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(false)
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
account = create(:account)
expect(account.internal_attributes[Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE]).to be true
expect(account).not_to be_feature_enabled('captain_integration')
expect(account).not_to be_feature_enabled('captain_integration_v2')
end
@@ -111,25 +111,6 @@ RSpec.describe Captain::Assistant::SessionCaptureService do
expect(history.first).to include('role' => 'user', 'content' => 'CUST001')
end
it 'stores multimodal content without cached attachment bytes' do
content = RubyLLM::Content.new('See image', ['https://example.com/image.jpg'])
content.attachments.first.instance_variable_set(:@content, "\xFF\xD8\xFF\xE0JFIF".b)
run_context[:conversation_history] = [
{ role: :user, content: content },
{ role: :assistant, content: 'I can see the image', agent_name: 'Assistant' }
]
history = service.capture!.run_context
expect(history.first).to include(
'role' => 'user',
'content' => {
'text' => 'See image',
'attachments' => [{ 'type' => 'image', 'source' => 'https://example.com/image.jpg' }]
}
)
end
it 'stores the full history when it contains no user message' do
run_context[:conversation_history] = conversation_history.reject { |message| message[:role] == :user }
@@ -221,21 +221,7 @@ describe Enterprise::Billing::HandleStripeEventService do
end
end
it 'does not enable Captain V2 for existing paid accounts during reconciliation' do
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
stripe_event_service.new.perform(event: event)
expect(account.reload).not_to be_feature_enabled('captain_integration_v2')
end
it 'enables Captain V2 for new cloud accounts marked as default eligible' do
account.update!(
internal_attributes: account.internal_attributes.merge(
Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE => true
)
)
it 'enables Captain V2 for paid accounts during reconciliation' do
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })