Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33bafb0f63 | ||
|
|
ae49af354d | ||
|
|
d1fa8d8c2f | ||
|
|
71fffdd2b9 | ||
|
|
eae9841eb4 | ||
|
|
160732c07d | ||
|
|
7a299307b8 | ||
|
|
08f49f5896 |
@@ -1,5 +1,4 @@
|
||||
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,5 +1,4 @@
|
||||
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :authorize_account_update, only: [:update]
|
||||
|
||||
def show
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :fetch_label, except: [:index, :create]
|
||||
before_action :check_authorization
|
||||
|
||||
|
||||
+26
-52
@@ -3,8 +3,13 @@ import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
|
||||
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
|
||||
import {
|
||||
isTwilioComplete,
|
||||
isTwilioMediaTemplate,
|
||||
getTwilioMediaVariableKey,
|
||||
getTwilioMediaUrl,
|
||||
applyTwilioMediaFilename,
|
||||
} from '@chatwoot/utils';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
@@ -40,30 +45,23 @@ const templateBody = computed(() => {
|
||||
return props.template.body || '';
|
||||
});
|
||||
|
||||
const hasMediaTemplate = computed(() => {
|
||||
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
|
||||
});
|
||||
// Media-template detection and variable extraction are shared with the mobile
|
||||
// app via @chatwoot/utils.
|
||||
const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template));
|
||||
|
||||
const hasVariables = computed(() => {
|
||||
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
|
||||
});
|
||||
|
||||
const mediaVariableKey = computed(() => {
|
||||
if (!hasMediaTemplate.value) return null;
|
||||
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
|
||||
if (!mediaUrl) return null;
|
||||
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
|
||||
});
|
||||
const mediaVariableKey = computed(() =>
|
||||
getTwilioMediaVariableKey(props.template)
|
||||
);
|
||||
|
||||
const hasMediaVariable = computed(() => {
|
||||
return hasMediaTemplate.value && mediaVariableKey.value !== null;
|
||||
});
|
||||
const hasMediaVariable = computed(() => mediaVariableKey.value !== null);
|
||||
|
||||
const templateMediaUrl = computed(() => {
|
||||
if (!hasMediaTemplate.value) return '';
|
||||
|
||||
return props.template?.types?.['twilio/media']?.media?.[0] || '';
|
||||
});
|
||||
const templateMediaUrl = computed(() =>
|
||||
hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : ''
|
||||
);
|
||||
|
||||
const variablePattern = computed(() => {
|
||||
if (!hasVariables.value) return [];
|
||||
@@ -83,26 +81,10 @@ const renderedTemplate = computed(() => {
|
||||
return rendered;
|
||||
});
|
||||
|
||||
const isFormInvalid = computed(() => {
|
||||
if (!hasVariables.value && !hasMediaVariable.value) return false;
|
||||
|
||||
if (hasVariables.value) {
|
||||
const hasEmptyVariable = variablePattern.value.some(
|
||||
variable => !processedParams.value[variable]
|
||||
);
|
||||
if (hasEmptyVariable) return true;
|
||||
}
|
||||
|
||||
if (
|
||||
hasMediaVariable.value &&
|
||||
mediaVariableKey.value &&
|
||||
!processedParams.value[mediaVariableKey.value]
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
// Completeness validation is shared with the mobile app via @chatwoot/utils.
|
||||
const isFormInvalid = computed(
|
||||
() => !isTwilioComplete(props.template, processedParams.value)
|
||||
);
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
@@ -135,19 +117,11 @@ const sendMessage = () => {
|
||||
|
||||
const { friendly_name, language } = props.template;
|
||||
|
||||
// Process parameters and extract filename from media URL if needed
|
||||
const processedParameters = { ...processedParams.value };
|
||||
|
||||
// For media templates, extract filename from full URL
|
||||
if (
|
||||
hasMediaVariable.value &&
|
||||
mediaVariableKey.value &&
|
||||
processedParameters[mediaVariableKey.value]
|
||||
) {
|
||||
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
|
||||
processedParameters[mediaVariableKey.value]
|
||||
);
|
||||
}
|
||||
// For media templates, reduce the media URL to a filename before sending.
|
||||
const processedParameters = applyTwilioMediaFilename(
|
||||
props.template,
|
||||
processedParams.value
|
||||
);
|
||||
|
||||
const payload = {
|
||||
message: renderedTemplate.value,
|
||||
|
||||
@@ -13,6 +13,7 @@ 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,
|
||||
@@ -84,29 +85,10 @@ const renderedTemplate = computed(() => {
|
||||
return replaceTemplateVariables(bodyText.value, 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;
|
||||
});
|
||||
// Completeness validation is shared with the mobile app via @chatwoot/utils.
|
||||
const isFormInvalid = computed(
|
||||
() => !isWhatsAppComplete(props.template, processedParams.value)
|
||||
);
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
|
||||
@@ -127,25 +127,8 @@ export const getHostNameFromURL = url => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
};
|
||||
// Shared with the mobile app via @chatwoot/utils.
|
||||
export { extractFilenameFromUrl } from '@chatwoot/utils';
|
||||
|
||||
/**
|
||||
* Normalizes a comma/newline separated list of domains
|
||||
|
||||
@@ -156,12 +156,18 @@ describe('templateHelper', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle templates with no variables', () => {
|
||||
it('should handle templates with no variables but a media header', () => {
|
||||
const emptyTemplate = templates.find(
|
||||
t => t.name === 'no_variable_template'
|
||||
);
|
||||
const result = buildTemplateParameters(emptyTemplate, false);
|
||||
expect(result).toEqual({});
|
||||
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: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should build parameters for templates with multiple component types', () => {
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
// Constants
|
||||
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';
|
||||
|
||||
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);
|
||||
@@ -27,70 +24,7 @@ export const replaceTemplateVariables = (templateText, processedParams) => {
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
// 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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { computed } 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 = ref({});
|
||||
const enabledFeatures = computed(() => currentAccount.value?.features || {});
|
||||
|
||||
const hasTiktokConfigured = computed(() => {
|
||||
return window.chatwootConfig?.tiktokAppId;
|
||||
@@ -105,10 +105,6 @@ const channelList = computed(() => {
|
||||
return channels;
|
||||
});
|
||||
|
||||
const initializeEnabledFeatures = async () => {
|
||||
enabledFeatures.value = currentAccount.value.features;
|
||||
};
|
||||
|
||||
const initChannelAuth = channel => {
|
||||
const params = {
|
||||
sub_page: channel,
|
||||
@@ -116,10 +112,6 @@ const initChannelAuth = channel => {
|
||||
};
|
||||
router.push({ name: 'settings_inboxes_page_channel', params });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeEnabledFeatures();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+5
-3
@@ -3,6 +3,7 @@ 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: {
|
||||
@@ -22,6 +23,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const senderNameKeyOptions = computed(() => [
|
||||
{
|
||||
@@ -30,7 +32,7 @@ const senderNameKeyOptions = computed(() => [
|
||||
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'),
|
||||
preview: {
|
||||
senderName: 'Smith',
|
||||
businessName: 'Chatwoot',
|
||||
businessName: replaceInstallationName('Chatwoot'),
|
||||
email: '<support@yourbusiness.com>',
|
||||
},
|
||||
},
|
||||
@@ -40,7 +42,7 @@ const senderNameKeyOptions = computed(() => [
|
||||
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'),
|
||||
preview: {
|
||||
senderName: '',
|
||||
businessName: 'Chatwoot',
|
||||
businessName: replaceInstallationName('Chatwoot'),
|
||||
email: '<support@yourbusiness.com>',
|
||||
},
|
||||
},
|
||||
@@ -51,7 +53,7 @@ const isKeyOptionFriendly = key => key === 'friendly';
|
||||
const userName = keyOption =>
|
||||
isKeyOptionFriendly(keyOption.key)
|
||||
? keyOption.preview.senderName
|
||||
: keyOption.preview.businessName;
|
||||
: props.businessName || keyOption.preview.businessName;
|
||||
|
||||
const toggleSenderNameType = key => {
|
||||
emit('update', key);
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -67,45 +68,8 @@ export const getters = {
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
// Sendable-template filtering is shared with the mobile app via @chatwoot/utils.
|
||||
return templates.filter(isSendableTemplate);
|
||||
},
|
||||
getNewConversationInboxes($state) {
|
||||
return $state.records.filter(inbox => {
|
||||
|
||||
@@ -73,13 +73,13 @@ describe('useBranding', () => {
|
||||
expect(result).toBe('Welcome to our platform');
|
||||
});
|
||||
|
||||
it('should be case-sensitive for "Chatwoot"', () => {
|
||||
it('should replace "Chatwoot" regardless of casing', () => {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName(
|
||||
'Welcome to chatwoot and CHATWOOT'
|
||||
'Welcome to chatwoot, Chatwoot and CHATWOOT'
|
||||
);
|
||||
|
||||
expect(result).toBe('Welcome to chatwoot and CHATWOOT');
|
||||
expect(result).toBe('Welcome to MyCompany, MyCompany and MyCompany');
|
||||
});
|
||||
|
||||
it('should handle special characters in installation name', () => {
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
export function useBranding() {
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
/**
|
||||
* Replaces "Chatwoot" in text with the installation name from global config
|
||||
* Replaces "Chatwoot" (any casing) 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
|
||||
*/
|
||||
@@ -17,7 +18,7 @@ export function useBranding() {
|
||||
const installationName = globalConfig.value?.installationName;
|
||||
if (!installationName) return text;
|
||||
|
||||
return text.replace(/Chatwoot/g, installationName);
|
||||
return text.replace(/chatwoot/gi, installationName);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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,6 +174,7 @@ 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,6 +69,12 @@ 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)
|
||||
|
||||
@@ -31,10 +31,11 @@ class Rack::Attack
|
||||
(default_allowed_ips + env_allowed_ips).include?(remote_ip)
|
||||
end
|
||||
|
||||
# Rails would allow requests to paths with extensions, so lets compare against the path with extension stripped
|
||||
# example /auth & /auth.json would both work
|
||||
# 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.
|
||||
def path_without_extensions
|
||||
path[/^[^.]+/]
|
||||
normalized_path = path[/^[^.]+/]
|
||||
normalized_path == '/' ? normalized_path : normalized_path.sub(%r{/+\z}, '')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -188,6 +189,11 @@ 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
|
||||
|
||||
##-----------------------------------------------##
|
||||
@@ -212,6 +218,24 @@ 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,5 +1,4 @@
|
||||
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,5 +1,4 @@
|
||||
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,5 +1,4 @@
|
||||
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,5 +1,4 @@
|
||||
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,5 +1,4 @@
|
||||
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,5 +1,4 @@
|
||||
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,5 +1,4 @@
|
||||
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]
|
||||
|
||||
@@ -59,6 +59,11 @@ class Captain::Assistant::SessionCaptureService
|
||||
def current_turn_history
|
||||
history = Array(context[:conversation_history])
|
||||
last_user_index = history.rindex { |message| message[:role].to_s == 'user' }
|
||||
last_user_index ? history[last_user_index..] : history
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
+1
-1
@@ -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.55",
|
||||
"@chatwoot/utils": "^0.0.56",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
|
||||
Generated
+5
-5
@@ -28,8 +28,8 @@ importers:
|
||||
specifier: 1.3.22
|
||||
version: 1.3.22
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.55
|
||||
version: 0.0.55
|
||||
specifier: ^0.0.56
|
||||
version: 0.0.56
|
||||
'@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.55':
|
||||
resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
|
||||
'@chatwoot/utils@0.0.56':
|
||||
resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==}
|
||||
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.55':
|
||||
'@chatwoot/utils@0.0.56':
|
||||
dependencies:
|
||||
date-fns: 2.30.0
|
||||
|
||||
|
||||
@@ -64,6 +64,14 @@ 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
|
||||
|
||||
|
||||
@@ -111,6 +111,25 @@ 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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user