+
-
+
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/BusinessDay.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/BusinessDay.vue
index 14ba5b371..b90ea92ad 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/BusinessDay.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/BusinessDay.vue
@@ -96,11 +96,12 @@ export default {
return parse(this.toTime, 'hh:mm a', new Date());
},
totalHours() {
- if (this.timeSlot.openAllDay) {
- return 24;
- }
- const totalHours = differenceInMinutes(this.toDate, this.fromDate) / 60;
- return totalHours;
+ if (this.timeSlot.openAllDay) return '24h';
+
+ const totalMinutes = differenceInMinutes(this.toDate, this.fromDate);
+ const [h, m] = [Math.floor(totalMinutes / 60), totalMinutes % 60];
+
+ return [h && `${h}h`, m && `${m}m`].filter(Boolean).join(' ') || '0m';
},
hasError() {
return !this.timeSlot.valid;
@@ -211,7 +212,7 @@ export default {
v-if="isDayEnabled && !hasError"
class="label bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text text-xs inline-block px-2 py-1 rounded-lg cursor-default whitespace-nowrap"
>
- {{ totalHours }} {{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.HOURS') }}
+ {{ totalHours }}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js b/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js
index 69089bf3c..b73368035 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js
@@ -53,6 +53,7 @@ export const generateTimeSlots = (step = 15) => {
Generates a list of time strings from 12:00 AM to next 24 hours. Each new string
will be generated by adding `step` minutes to the previous one.
The list is generated by starting with a random day and adding step minutes till end of the same day.
+ Always includes 11:59 PM as the final slot to complete the day.
*/
const date = new Date(1970, 1, 1);
const slots = [];
@@ -66,6 +67,13 @@ export const generateTimeSlots = (step = 15) => {
);
date.setMinutes(date.getMinutes() + step);
}
+
+ // Always add 11:59 PM as the final slot if it's not already included
+ const lastSlot = '11:59 PM';
+ if (!slots.includes(lastSlot)) {
+ slots.push(lastSlot);
+ }
+
return slots;
};
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/specs/businessHour.spec.js b/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/specs/businessHour.spec.js
index 077337ae6..c9cfa2d24 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/specs/businessHour.spec.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/specs/businessHour.spec.js
@@ -7,10 +7,19 @@ import {
} from '../businessHour';
describe('#generateTimeSlots', () => {
- it('returns correct number of time slots', () => {
- expect(generateTimeSlots(15).length).toStrictEqual((60 / 15) * 24);
+ it('returns correct number of time slots for 15-minute intervals', () => {
+ const slots = generateTimeSlots(15);
+ // 24 hours * 4 slots per hour + 1 for 11:59 PM = 97 slots
+ expect(slots.length).toStrictEqual(97);
});
- it('returns correct time slots', () => {
+
+ it('returns correct number of time slots for 30-minute intervals', () => {
+ const slots = generateTimeSlots(30);
+ // 24 hours * 2 slots per hour + 1 for 11:59 PM = 49 slots
+ expect(slots.length).toStrictEqual(49);
+ });
+
+ it('returns correct time slots for 4-hour intervals', () => {
expect(generateTimeSlots(240)).toStrictEqual([
'12:00 AM',
'04:00 AM',
@@ -18,8 +27,51 @@ describe('#generateTimeSlots', () => {
'12:00 PM',
'04:00 PM',
'08:00 PM',
+ '11:59 PM',
]);
});
+
+ it('always starts with 12:00 AM', () => {
+ expect(generateTimeSlots(15)[0]).toStrictEqual('12:00 AM');
+ expect(generateTimeSlots(30)[0]).toStrictEqual('12:00 AM');
+ expect(generateTimeSlots(60)[0]).toStrictEqual('12:00 AM');
+ });
+
+ it('always ends with 11:59 PM', () => {
+ const slots15 = generateTimeSlots(15);
+ const slots30 = generateTimeSlots(30);
+ const slots60 = generateTimeSlots(60);
+
+ expect(slots15[slots15.length - 1]).toStrictEqual('11:59 PM');
+ expect(slots30[slots30.length - 1]).toStrictEqual('11:59 PM');
+ expect(slots60[slots60.length - 1]).toStrictEqual('11:59 PM');
+ });
+
+ it('includes 11:59 PM even when it would not be in regular intervals', () => {
+ const slots = generateTimeSlots(30);
+ expect(slots).toContain('11:59 PM');
+ expect(slots).toContain('11:30 PM'); // Regular interval
+ });
+
+ it('does not duplicate 11:59 PM if it already exists in regular intervals', () => {
+ // Test with a step that would naturally include 11:59 PM
+ const slots = generateTimeSlots(1); // 1-minute intervals
+ const count11_59 = slots.filter(slot => slot === '11:59 PM').length;
+ expect(count11_59).toStrictEqual(1);
+ });
+
+ it('generates correct time format', () => {
+ const slots = generateTimeSlots(60);
+ expect(slots).toContain('01:00 AM');
+ expect(slots).toContain('12:00 PM');
+ expect(slots).toContain('01:00 PM');
+ expect(slots).toContain('11:00 PM');
+ });
+
+ it('handles edge case with very large step', () => {
+ const slots = generateTimeSlots(1440); // 24 hours
+ expect(slots).toStrictEqual(['12:00 AM', '11:59 PM']);
+ });
});
describe('#getTime', () => {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/inbox.routes.js b/app/javascript/dashboard/routes/dashboard/settings/inbox/inbox.routes.js
index 6c2d3e6ea..7dd00ba4f 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/inbox.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/inbox.routes.js
@@ -94,7 +94,7 @@ export default {
],
},
{
- path: ':inboxId',
+ path: ':inboxId/:tab?',
name: 'settings_inbox_show',
component: Settings,
meta: {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/LiveReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/LiveReports.vue
index a0eeb3ab9..1eb9640ac 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/LiveReports.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/LiveReports.vue
@@ -1,6 +1,7 @@
-
-
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/HeatmapContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/HeatmapContainer.vue
deleted file mode 100644
index 280e1d6ee..000000000
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/HeatmapContainer.vue
+++ /dev/null
@@ -1,119 +0,0 @@
-
-
-
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmap.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmap.vue
new file mode 100644
index 000000000..3f9dd9db4
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmap.vue
@@ -0,0 +1,214 @@
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmapContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmapContainer.vue
new file mode 100644
index 000000000..2a692b12a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmapContainer.vue
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ConversationHeatmapContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ConversationHeatmapContainer.vue
new file mode 100644
index 000000000..394b7cdc2
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ConversationHeatmapContainer.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapTooltip.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapTooltip.vue
new file mode 100644
index 000000000..79377a6a3
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapTooltip.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+ {{ tooltipText }}
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ResolutionHeatmapContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ResolutionHeatmapContainer.vue
new file mode 100644
index 000000000..24530d0c7
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ResolutionHeatmapContainer.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/composables/useHeatmapTooltip.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/composables/useHeatmapTooltip.js
new file mode 100644
index 000000000..28b050542
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/composables/useHeatmapTooltip.js
@@ -0,0 +1,34 @@
+import { ref } from 'vue';
+
+export function useHeatmapTooltip() {
+ const visible = ref(false);
+ const x = ref(0);
+ const y = ref(0);
+ const value = ref(null);
+
+ let timeoutId = null;
+
+ const show = (event, cellValue) => {
+ clearTimeout(timeoutId);
+
+ // Update position immediately for smooth movement
+ const rect = event.target.getBoundingClientRect();
+ x.value = rect.left + rect.width / 2;
+ y.value = rect.top;
+
+ // Only delay content update and visibility
+ timeoutId = setTimeout(() => {
+ value.value = cellValue;
+ visible.value = true;
+ }, 100);
+ };
+
+ const hide = () => {
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => {
+ visible.value = false;
+ }, 50);
+ };
+
+ return { visible, x, y, value, show, hide };
+}
diff --git a/app/javascript/dashboard/store/captain/customTools.js b/app/javascript/dashboard/store/captain/customTools.js
new file mode 100644
index 000000000..3d3af03c0
--- /dev/null
+++ b/app/javascript/dashboard/store/captain/customTools.js
@@ -0,0 +1,35 @@
+import CaptainCustomTools from 'dashboard/api/captain/customTools';
+import { createStore } from './storeFactory';
+import { throwErrorMessage } from 'dashboard/store/utils/api';
+
+export default createStore({
+ name: 'CaptainCustomTool',
+ API: CaptainCustomTools,
+ actions: mutations => ({
+ update: async ({ commit }, { id, ...updateObj }) => {
+ commit(mutations.SET_UI_FLAG, { updatingItem: true });
+ try {
+ const response = await CaptainCustomTools.update(id, updateObj);
+ commit(mutations.EDIT, response.data);
+ commit(mutations.SET_UI_FLAG, { updatingItem: false });
+ return response.data;
+ } catch (error) {
+ commit(mutations.SET_UI_FLAG, { updatingItem: false });
+ return throwErrorMessage(error);
+ }
+ },
+
+ delete: async ({ commit }, id) => {
+ commit(mutations.SET_UI_FLAG, { deletingItem: true });
+ try {
+ await CaptainCustomTools.delete(id);
+ commit(mutations.DELETE, id);
+ commit(mutations.SET_UI_FLAG, { deletingItem: false });
+ return id;
+ } catch (error) {
+ commit(mutations.SET_UI_FLAG, { deletingItem: false });
+ return throwErrorMessage(error);
+ }
+ },
+ }),
+});
diff --git a/app/javascript/dashboard/store/captain/tools.js b/app/javascript/dashboard/store/captain/tools.js
index 9a9bcc330..9638e45c3 100644
--- a/app/javascript/dashboard/store/captain/tools.js
+++ b/app/javascript/dashboard/store/captain/tools.js
@@ -3,7 +3,7 @@ import CaptainToolsAPI from '../../api/captain/tools';
import { throwErrorMessage } from 'dashboard/store/utils/api';
const toolsStore = createStore({
- name: 'captainTool',
+ name: 'Tools',
API: CaptainToolsAPI,
actions: mutations => ({
getTools: async ({ commit }) => {
diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js
index 16bcab3f9..d56958eb5 100755
--- a/app/javascript/dashboard/store/index.js
+++ b/app/javascript/dashboard/store/index.js
@@ -57,6 +57,7 @@ import copilotThreads from './captain/copilotThreads';
import copilotMessages from './captain/copilotMessages';
import captainScenarios from './captain/scenarios';
import captainTools from './captain/tools';
+import captainCustomTools from './captain/customTools';
const plugins = [];
@@ -119,6 +120,7 @@ export default createStore({
copilotMessages,
captainScenarios,
captainTools,
+ captainCustomTools,
},
plugins,
});
diff --git a/app/javascript/dashboard/store/modules/conversationStats.js b/app/javascript/dashboard/store/modules/conversationStats.js
index 917781bbf..ba3e5c455 100644
--- a/app/javascript/dashboard/store/modules/conversationStats.js
+++ b/app/javascript/dashboard/store/modules/conversationStats.js
@@ -25,18 +25,18 @@ const fetchMetaData = async (commit, params) => {
}
};
-const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1000);
-const longDebouncedFetchMetaData = debounce(fetchMetaData, 500, false, 5000);
+const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1500);
+const longDebouncedFetchMetaData = debounce(fetchMetaData, 5000, false, 10000);
const superLongDebouncedFetchMetaData = debounce(
fetchMetaData,
- 1500,
+ 10000,
false,
- 10000
+ 20000
);
export const actions = {
get: async ({ commit, state: $state }, params) => {
- if ($state.allCount > 10000) {
+ if ($state.allCount > 5000) {
superLongDebouncedFetchMetaData(commit, params);
} else if ($state.allCount > 100) {
longDebouncedFetchMetaData(commit, params);
diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
index 3d627e3ef..96f4a0123 100644
--- a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
+++ b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
@@ -154,7 +154,10 @@ const equalTo = (filterValue, conversationValue) => {
* It only works with string values and returns false for non-string types.
*/
const contains = (filterValue, conversationValue) => {
- if (typeof conversationValue === 'string') {
+ if (
+ typeof conversationValue === 'string' &&
+ typeof filterValue === 'string'
+ ) {
return conversationValue.toLowerCase().includes(filterValue.toLowerCase());
}
return false;
@@ -190,10 +193,8 @@ const compareDates = (conversationValue, filterValue, compareFn) => {
const matchesCondition = (conversationValue, filter) => {
const { filter_operator: filterOperator, values } = filter;
- // Handle null/undefined values
- if (conversationValue === null || conversationValue === undefined) {
- return filterOperator === 'is_not_present';
- }
+ const isNullish =
+ conversationValue === null || conversationValue === undefined;
const filterValue = Array.isArray(values)
? values.map(resolveValue)
@@ -213,10 +214,10 @@ const matchesCondition = (conversationValue, filter) => {
return !contains(filterValue, conversationValue);
case 'is_present':
- return true; // We already handled null/undefined above
+ return !isNullish;
case 'is_not_present':
- return false; // We already handled null/undefined above
+ return isNullish;
case 'is_greater_than':
return compareDates(conversationValue, filterValue, (a, b) => a > b);
@@ -225,6 +226,10 @@ const matchesCondition = (conversationValue, filter) => {
return compareDates(conversationValue, filterValue, (a, b) => a < b);
case 'days_before': {
+ if (isNullish) {
+ return false;
+ }
+
const today = new Date();
const daysInMilliseconds = filterValue * 24 * 60 * 60 * 1000;
const targetDate = new Date(today.getTime() - daysInMilliseconds);
diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
index 096481c69..db1017407 100644
--- a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
+++ b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
@@ -192,6 +192,32 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true);
});
+ it('should not match conversation with equal_to operator when assignee is null', () => {
+ const conversation = { meta: { assignee: null } };
+ const filters = [
+ {
+ attribute_key: 'assignee_id',
+ filter_operator: 'equal_to',
+ values: { id: 1, name: 'John Doe' },
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ it('should match conversation with not_equal_to operator when assignee is null', () => {
+ const conversation = { meta: { assignee: null } };
+ const filters = [
+ {
+ attribute_key: 'assignee_id',
+ filter_operator: 'not_equal_to',
+ values: { id: 1, name: 'John Doe' },
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
it('should match conversation with is_not_present operator for assignee_id', () => {
const conversation = { meta: { assignee: null } };
const filters = [
@@ -285,6 +311,58 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(false);
});
+ it('should not match contains operator when display_id is null', () => {
+ const conversation = { id: null };
+ const filters = [
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'contains',
+ values: '234',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ it('should not match contains operator when filter value is null', () => {
+ const conversation = { id: '12345' };
+ const filters = [
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'contains',
+ values: null,
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ it('should match does_not_contain operator when display_id is null', () => {
+ const conversation = { id: null };
+ const filters = [
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'does_not_contain',
+ values: '234',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should match does_not_contain operator when filter value is null', () => {
+ const conversation = { id: '12345' };
+ const filters = [
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'does_not_contain',
+ values: null,
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
it('should match conversation with does_not_contain operator when value is not present', () => {
const conversation = { id: '12345' };
const filters = [
diff --git a/app/javascript/dashboard/store/modules/reports.js b/app/javascript/dashboard/store/modules/reports.js
index bb5364bb5..99b2acf18 100644
--- a/app/javascript/dashboard/store/modules/reports.js
+++ b/app/javascript/dashboard/store/modules/reports.js
@@ -57,11 +57,13 @@ const state = {
uiFlags: {
isFetchingAccountConversationMetric: false,
isFetchingAccountConversationsHeatmap: false,
+ isFetchingAccountResolutionsHeatmap: false,
isFetchingAgentConversationMetric: false,
isFetchingTeamConversationMetric: false,
},
accountConversationMetric: {},
accountConversationHeatmap: [],
+ accountResolutionHeatmap: [],
agentConversationMetric: [],
teamConversationMetric: [],
},
@@ -89,6 +91,9 @@ const getters = {
getAccountConversationHeatmapData(_state) {
return _state.overview.accountConversationHeatmap;
},
+ getAccountResolutionHeatmapData(_state) {
+ return _state.overview.accountResolutionHeatmap;
+ },
getAgentConversationMetric(_state) {
return _state.overview.agentConversationMetric;
},
@@ -130,6 +135,16 @@ export const actions = {
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
});
},
+ fetchAccountResolutionHeatmap({ commit }, reportObj) {
+ commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, true);
+ Report.getReports({ ...reportObj, groupBy: 'hour' }).then(heatmapData => {
+ let { data } = heatmapData;
+ data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
+
+ commit(types.default.SET_RESOLUTION_HEATMAP_DATA, data);
+ commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, false);
+ });
+ },
fetchAccountSummary({ commit }, reportObj) {
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
Report.getSummary(
@@ -287,6 +302,9 @@ const mutations = {
[types.default.SET_HEATMAP_DATA](_state, heatmapData) {
_state.overview.accountConversationHeatmap = heatmapData;
},
+ [types.default.SET_RESOLUTION_HEATMAP_DATA](_state, heatmapData) {
+ _state.overview.accountResolutionHeatmap = heatmapData;
+ },
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
_state.accountReport.isFetching[metric] = value;
},
@@ -299,6 +317,9 @@ const mutations = {
[types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;
},
+ [types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING](_state, flag) {
+ _state.overview.uiFlags.isFetchingAccountResolutionsHeatmap = flag;
+ },
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
_state.accountSummary = summaryData;
},
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index 4f361e140..68ff79e66 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -187,6 +187,8 @@ export default {
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
SET_HEATMAP_DATA: 'SET_HEATMAP_DATA',
TOGGLE_HEATMAP_LOADING: 'TOGGLE_HEATMAP_LOADING',
+ SET_RESOLUTION_HEATMAP_DATA: 'SET_RESOLUTION_HEATMAP_DATA',
+ TOGGLE_RESOLUTION_HEATMAP_LOADING: 'TOGGLE_RESOLUTION_HEATMAP_LOADING',
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
diff --git a/app/javascript/shared/constants/links.js b/app/javascript/shared/constants/links.js
index b9732c0a2..94d3de3f6 100644
--- a/app/javascript/shared/constants/links.js
+++ b/app/javascript/shared/constants/links.js
@@ -6,3 +6,5 @@ export const REPLY_POLICY = {
WHATSAPP_CLOUD:
'https://business.whatsapp.com/policy#:~:text=You%20may%20reply%20to%20a,messages%20via%20approved%20Message%20Templates.',
};
+
+export const CHANGELOG_API_URL = 'https://hub.2.chatwoot.com/changelogs';
diff --git a/app/javascript/survey/i18n/locale/bn.json b/app/javascript/survey/i18n/locale/bn.json
new file mode 100644
index 000000000..beee65ac5
--- /dev/null
+++ b/app/javascript/survey/i18n/locale/bn.json
@@ -0,0 +1,19 @@
+{
+ "SURVEY": {
+ "DESCRIPTION": "Dear customer 👋, please take a few moments to share feedback about the conversation you had with {inboxName}.",
+ "RATING": {
+ "LABEL": "Rate your conversation",
+ "SUCCESS_MESSAGE": "Thank you for submitting the rating"
+ },
+ "FEEDBACK": {
+ "LABEL": "Do you have any thoughts you'd like to share?",
+ "PLACEHOLDER": "Your feedback (optional)",
+ "BUTTON_TEXT": "Submit feedback"
+ },
+ "API": {
+ "SUCCESS_MESSAGE": "Survey updated successfully",
+ "ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
+ }
+ },
+ "POWERED_BY": "Powered by Chatwoot"
+}
diff --git a/app/javascript/survey/i18n/locale/et.json b/app/javascript/survey/i18n/locale/et.json
new file mode 100644
index 000000000..beee65ac5
--- /dev/null
+++ b/app/javascript/survey/i18n/locale/et.json
@@ -0,0 +1,19 @@
+{
+ "SURVEY": {
+ "DESCRIPTION": "Dear customer 👋, please take a few moments to share feedback about the conversation you had with {inboxName}.",
+ "RATING": {
+ "LABEL": "Rate your conversation",
+ "SUCCESS_MESSAGE": "Thank you for submitting the rating"
+ },
+ "FEEDBACK": {
+ "LABEL": "Do you have any thoughts you'd like to share?",
+ "PLACEHOLDER": "Your feedback (optional)",
+ "BUTTON_TEXT": "Submit feedback"
+ },
+ "API": {
+ "SUCCESS_MESSAGE": "Survey updated successfully",
+ "ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
+ }
+ },
+ "POWERED_BY": "Powered by Chatwoot"
+}
diff --git a/app/javascript/v3/views/login/Index.vue b/app/javascript/v3/views/login/Index.vue
index 028af4f72..11a61d3dd 100644
--- a/app/javascript/v3/views/login/Index.vue
+++ b/app/javascript/v3/views/login/Index.vue
@@ -22,6 +22,8 @@ import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
const ERROR_MESSAGES = {
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
+ 'saml-authentication-failed': 'LOGIN.SAML.API.ERROR_MESSAGE',
+ 'saml-not-enabled': 'LOGIN.SAML.API.ERROR_MESSAGE',
};
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
@@ -257,9 +259,9 @@ export default {
}"
>
-
+
-
+
{
name="authenticity_token"
:value="csrfToken"
/>
+
({
authError: route.query.error,
+ target: route.query.target,
}),
},
{
diff --git a/app/javascript/widget/i18n/locale/bn.json b/app/javascript/widget/i18n/locale/bn.json
new file mode 100644
index 000000000..c3d6ddfc1
--- /dev/null
+++ b/app/javascript/widget/i18n/locale/bn.json
@@ -0,0 +1,153 @@
+{
+ "COMPONENTS": {
+ "FILE_BUBBLE": {
+ "DOWNLOAD": "Download",
+ "UPLOADING": "Uploading..."
+ },
+ "FORM_BUBBLE": {
+ "SUBMIT": "Submit"
+ },
+ "MESSAGE_BUBBLE": {
+ "RETRY": "Send message again",
+ "ERROR_MESSAGE": "Couldn't send, try again"
+ }
+ },
+ "THUMBNAIL": {
+ "AUTHOR": {
+ "NOT_AVAILABLE": "Not available"
+ }
+ },
+ "TEAM_AVAILABILITY": {
+ "ONLINE": "We are online",
+ "OFFLINE": "We are away at the moment",
+ "BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
+ },
+ "REPLY_TIME": {
+ "IN_A_FEW_MINUTES": "Typically replies in a few minutes",
+ "IN_A_FEW_HOURS": "Typically replies in a few hours",
+ "IN_A_DAY": "Typically replies in a day",
+ "BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
+ "BACK_IN_MINUTES": "We will be back online in {time} minutes",
+ "BACK_AT_TIME": "We will be back online at {time}",
+ "BACK_ON_DAY": "We will be back online on {day}",
+ "BACK_TOMORROW": "We will be back online tomorrow",
+ "BACK_IN_SOME_TIME": "We will be back online in some time"
+ },
+ "DAY_NAMES": {
+ "SUNDAY": "Sunday",
+ "MONDAY": "Monday",
+ "TUESDAY": "Tuesday",
+ "WEDNESDAY": "Wednesday",
+ "THURSDAY": "Thursday",
+ "FRIDAY": "Friday",
+ "SATURDAY": "Saturday"
+ },
+ "START_CONVERSATION": "Start Conversation",
+ "END_CONVERSATION": "End Conversation",
+ "CONTINUE_CONVERSATION": "Continue conversation",
+ "YOU": "You",
+ "START_NEW_CONVERSATION": "Start a new conversation",
+ "VIEW_UNREAD_MESSAGES": "You have unread messages",
+ "UNREAD_VIEW": {
+ "VIEW_MESSAGES_BUTTON": "See new messages",
+ "CLOSE_MESSAGES_BUTTON": "Close",
+ "COMPANY_FROM": "from",
+ "BOT": "Bot"
+ },
+ "BUBBLE": {
+ "LABEL": "Chat with us"
+ },
+ "POWERED_BY": "Powered by Chatwoot",
+ "EMAIL_PLACEHOLDER": "Please enter your email",
+ "CHAT_PLACEHOLDER": "Type your message",
+ "TODAY": "Today",
+ "YESTERDAY": "Yesterday",
+ "PRE_CHAT_FORM": {
+ "FIELDS": {
+ "FULL_NAME": {
+ "LABEL": "Full Name",
+ "PLACEHOLDER": "Please enter your full name",
+ "REQUIRED_ERROR": "Full Name is required"
+ },
+ "EMAIL_ADDRESS": {
+ "LABEL": "Email Address",
+ "PLACEHOLDER": "Please enter your email address",
+ "REQUIRED_ERROR": "Email Address is required",
+ "VALID_ERROR": "Please enter a valid email address"
+ },
+ "PHONE_NUMBER": {
+ "LABEL": "Phone Number",
+ "PLACEHOLDER": "Please enter your phone number",
+ "REQUIRED_ERROR": "Phone Number is required",
+ "DIAL_CODE_VALID_ERROR": "Please select a country code",
+ "VALID_ERROR": "Please enter a valid phone number",
+ "DROPDOWN_EMPTY": "No results found",
+ "DROPDOWN_SEARCH": "Search country"
+ },
+ "MESSAGE": {
+ "LABEL": "Message",
+ "PLACEHOLDER": "Please enter your message",
+ "ERROR": "Message too short"
+ }
+ },
+ "CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
+ "IS_REQUIRED": "is required",
+ "REQUIRED": "Required",
+ "REGEX_ERROR": "Please provide a valid input"
+ },
+ "FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
+ "CHAT_FORM": {
+ "INVALID": {
+ "FIELD": "Invalid field"
+ }
+ },
+ "EMOJI": {
+ "PLACEHOLDER": "Search emojis",
+ "NOT_FOUND": "No emoji match your search",
+ "ARIA_LABEL": "Emoji picker"
+ },
+ "CSAT": {
+ "TITLE": "Rate your conversation",
+ "SUBMITTED_TITLE": "Thank you for submitting the rating",
+ "PLACEHOLDER": "Tell us more..."
+ },
+ "EMAIL_TRANSCRIPT": {
+ "BUTTON_TEXT": "Request a conversation transcript",
+ "SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
+ "SEND_EMAIL_ERROR": "There was an error, please try again"
+ },
+ "INTEGRATIONS": {
+ "DYTE": {
+ "CLICK_HERE_TO_JOIN": "Click here to join",
+ "LEAVE_THE_ROOM": "Leave the call"
+ }
+ },
+ "PORTAL": {
+ "POPULAR_ARTICLES": "Popular Articles",
+ "VIEW_ALL_ARTICLES": "View all articles",
+ "IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
+ },
+ "ATTACHMENTS": {
+ "image": {
+ "CONTENT": "Picture message"
+ },
+ "audio": {
+ "CONTENT": "Audio message"
+ },
+ "video": {
+ "CONTENT": "Video message"
+ },
+ "file": {
+ "CONTENT": "File Attachment"
+ },
+ "location": {
+ "CONTENT": "Location"
+ },
+ "fallback": {
+ "CONTENT": "has shared a url"
+ }
+ },
+ "FOOTER_REPLY_TO": {
+ "REPLY_TO": "Replying to:"
+ }
+}
diff --git a/app/javascript/widget/i18n/locale/cs.json b/app/javascript/widget/i18n/locale/cs.json
index 4e7234d59..61bebf777 100644
--- a/app/javascript/widget/i18n/locale/cs.json
+++ b/app/javascript/widget/i18n/locale/cs.json
@@ -30,8 +30,8 @@
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
"BACK_AT_TIME": "We will be back online at {time}",
"BACK_ON_DAY": "We will be back online on {day}",
- "BACK_TOMORROW": "We will be back online tomorrow",
- "BACK_IN_SOME_TIME": "We will be back online in some time"
+ "BACK_TOMORROW": "Zítra budeme opět k dispozici",
+ "BACK_IN_SOME_TIME": "Za chvíli budeme opět k dispozici"
},
"DAY_NAMES": {
"SUNDAY": "Neděle",
diff --git a/app/javascript/widget/i18n/locale/et.json b/app/javascript/widget/i18n/locale/et.json
new file mode 100644
index 000000000..c3d6ddfc1
--- /dev/null
+++ b/app/javascript/widget/i18n/locale/et.json
@@ -0,0 +1,153 @@
+{
+ "COMPONENTS": {
+ "FILE_BUBBLE": {
+ "DOWNLOAD": "Download",
+ "UPLOADING": "Uploading..."
+ },
+ "FORM_BUBBLE": {
+ "SUBMIT": "Submit"
+ },
+ "MESSAGE_BUBBLE": {
+ "RETRY": "Send message again",
+ "ERROR_MESSAGE": "Couldn't send, try again"
+ }
+ },
+ "THUMBNAIL": {
+ "AUTHOR": {
+ "NOT_AVAILABLE": "Not available"
+ }
+ },
+ "TEAM_AVAILABILITY": {
+ "ONLINE": "We are online",
+ "OFFLINE": "We are away at the moment",
+ "BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
+ },
+ "REPLY_TIME": {
+ "IN_A_FEW_MINUTES": "Typically replies in a few minutes",
+ "IN_A_FEW_HOURS": "Typically replies in a few hours",
+ "IN_A_DAY": "Typically replies in a day",
+ "BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
+ "BACK_IN_MINUTES": "We will be back online in {time} minutes",
+ "BACK_AT_TIME": "We will be back online at {time}",
+ "BACK_ON_DAY": "We will be back online on {day}",
+ "BACK_TOMORROW": "We will be back online tomorrow",
+ "BACK_IN_SOME_TIME": "We will be back online in some time"
+ },
+ "DAY_NAMES": {
+ "SUNDAY": "Sunday",
+ "MONDAY": "Monday",
+ "TUESDAY": "Tuesday",
+ "WEDNESDAY": "Wednesday",
+ "THURSDAY": "Thursday",
+ "FRIDAY": "Friday",
+ "SATURDAY": "Saturday"
+ },
+ "START_CONVERSATION": "Start Conversation",
+ "END_CONVERSATION": "End Conversation",
+ "CONTINUE_CONVERSATION": "Continue conversation",
+ "YOU": "You",
+ "START_NEW_CONVERSATION": "Start a new conversation",
+ "VIEW_UNREAD_MESSAGES": "You have unread messages",
+ "UNREAD_VIEW": {
+ "VIEW_MESSAGES_BUTTON": "See new messages",
+ "CLOSE_MESSAGES_BUTTON": "Close",
+ "COMPANY_FROM": "from",
+ "BOT": "Bot"
+ },
+ "BUBBLE": {
+ "LABEL": "Chat with us"
+ },
+ "POWERED_BY": "Powered by Chatwoot",
+ "EMAIL_PLACEHOLDER": "Please enter your email",
+ "CHAT_PLACEHOLDER": "Type your message",
+ "TODAY": "Today",
+ "YESTERDAY": "Yesterday",
+ "PRE_CHAT_FORM": {
+ "FIELDS": {
+ "FULL_NAME": {
+ "LABEL": "Full Name",
+ "PLACEHOLDER": "Please enter your full name",
+ "REQUIRED_ERROR": "Full Name is required"
+ },
+ "EMAIL_ADDRESS": {
+ "LABEL": "Email Address",
+ "PLACEHOLDER": "Please enter your email address",
+ "REQUIRED_ERROR": "Email Address is required",
+ "VALID_ERROR": "Please enter a valid email address"
+ },
+ "PHONE_NUMBER": {
+ "LABEL": "Phone Number",
+ "PLACEHOLDER": "Please enter your phone number",
+ "REQUIRED_ERROR": "Phone Number is required",
+ "DIAL_CODE_VALID_ERROR": "Please select a country code",
+ "VALID_ERROR": "Please enter a valid phone number",
+ "DROPDOWN_EMPTY": "No results found",
+ "DROPDOWN_SEARCH": "Search country"
+ },
+ "MESSAGE": {
+ "LABEL": "Message",
+ "PLACEHOLDER": "Please enter your message",
+ "ERROR": "Message too short"
+ }
+ },
+ "CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
+ "IS_REQUIRED": "is required",
+ "REQUIRED": "Required",
+ "REGEX_ERROR": "Please provide a valid input"
+ },
+ "FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
+ "CHAT_FORM": {
+ "INVALID": {
+ "FIELD": "Invalid field"
+ }
+ },
+ "EMOJI": {
+ "PLACEHOLDER": "Search emojis",
+ "NOT_FOUND": "No emoji match your search",
+ "ARIA_LABEL": "Emoji picker"
+ },
+ "CSAT": {
+ "TITLE": "Rate your conversation",
+ "SUBMITTED_TITLE": "Thank you for submitting the rating",
+ "PLACEHOLDER": "Tell us more..."
+ },
+ "EMAIL_TRANSCRIPT": {
+ "BUTTON_TEXT": "Request a conversation transcript",
+ "SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
+ "SEND_EMAIL_ERROR": "There was an error, please try again"
+ },
+ "INTEGRATIONS": {
+ "DYTE": {
+ "CLICK_HERE_TO_JOIN": "Click here to join",
+ "LEAVE_THE_ROOM": "Leave the call"
+ }
+ },
+ "PORTAL": {
+ "POPULAR_ARTICLES": "Popular Articles",
+ "VIEW_ALL_ARTICLES": "View all articles",
+ "IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
+ },
+ "ATTACHMENTS": {
+ "image": {
+ "CONTENT": "Picture message"
+ },
+ "audio": {
+ "CONTENT": "Audio message"
+ },
+ "video": {
+ "CONTENT": "Video message"
+ },
+ "file": {
+ "CONTENT": "File Attachment"
+ },
+ "location": {
+ "CONTENT": "Location"
+ },
+ "fallback": {
+ "CONTENT": "has shared a url"
+ }
+ },
+ "FOOTER_REPLY_TO": {
+ "REPLY_TO": "Replying to:"
+ }
+}
diff --git a/app/javascript/widget/i18n/locale/it.json b/app/javascript/widget/i18n/locale/it.json
index 13cf4a433..ad355f61b 100644
--- a/app/javascript/widget/i18n/locale/it.json
+++ b/app/javascript/widget/i18n/locale/it.json
@@ -14,24 +14,24 @@
},
"THUMBNAIL": {
"AUTHOR": {
- "NOT_AVAILABLE": "Not available"
+ "NOT_AVAILABLE": "Non disponibile"
}
},
"TEAM_AVAILABILITY": {
"ONLINE": "Siamo online",
"OFFLINE": "Siamo offline in questo momento",
- "BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
+ "BACK_AS_SOON_AS_POSSIBLE": "Torneremo il prima possibile"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "In genere risponde in pochi minuti",
"IN_A_FEW_HOURS": "In genere risponde in poche ore",
"IN_A_DAY": "In genere risponde in un giorno",
- "BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
- "BACK_IN_MINUTES": "We will be back online in {time} minutes",
- "BACK_AT_TIME": "We will be back online at {time}",
- "BACK_ON_DAY": "We will be back online on {day}",
- "BACK_TOMORROW": "We will be back online tomorrow",
- "BACK_IN_SOME_TIME": "We will be back online in some time"
+ "BACK_IN_HOURS": "Torneremo online in {n} ore | Torneremo online in {n} ore",
+ "BACK_IN_MINUTES": "Torneremo online in {time} minuti",
+ "BACK_AT_TIME": "Torneremo online alle {time}",
+ "BACK_ON_DAY": "Torneremo online {day}",
+ "BACK_TOMORROW": "Torneremo online domani",
+ "BACK_IN_SOME_TIME": "Torneremo online a breve"
},
"DAY_NAMES": {
"SUNDAY": "Sunday",
diff --git a/app/javascript/widget/i18n/locale/ja.json b/app/javascript/widget/i18n/locale/ja.json
index 969b5483a..b8300fbab 100644
--- a/app/javascript/widget/i18n/locale/ja.json
+++ b/app/javascript/widget/i18n/locale/ja.json
@@ -14,24 +14,24 @@
},
"THUMBNAIL": {
"AUTHOR": {
- "NOT_AVAILABLE": "Not available"
+ "NOT_AVAILABLE": "離席中"
}
},
"TEAM_AVAILABILITY": {
"ONLINE": "オンライン",
"OFFLINE": "留守中",
- "BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
+ "BACK_AS_SOON_AS_POSSIBLE": "間もなく対応再開"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "通常数分以内にご返信します。",
"IN_A_FEW_HOURS": "通常数時間以内にご返信します。",
"IN_A_DAY": "通常数日以内にご返信します。",
- "BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
- "BACK_IN_MINUTES": "We will be back online in {time} minutes",
- "BACK_AT_TIME": "We will be back online at {time}",
- "BACK_ON_DAY": "We will be back online on {day}",
- "BACK_TOMORROW": "We will be back online tomorrow",
- "BACK_IN_SOME_TIME": "We will be back online in some time"
+ "BACK_IN_HOURS": "{n}時間後に対応再開 | {n}時間後に対応再開",
+ "BACK_IN_MINUTES": "{time}分後に対応再開",
+ "BACK_AT_TIME": "{time}に対応再開",
+ "BACK_ON_DAY": "{day}曜日に対応再開",
+ "BACK_TOMORROW": "翌日に対応再開",
+ "BACK_IN_SOME_TIME": "間もなく対応再開"
},
"DAY_NAMES": {
"SUNDAY": "日",
diff --git a/app/javascript/widget/i18n/locale/ka.json b/app/javascript/widget/i18n/locale/ka.json
index c3d6ddfc1..ce5f10c07 100644
--- a/app/javascript/widget/i18n/locale/ka.json
+++ b/app/javascript/widget/i18n/locale/ka.json
@@ -14,24 +14,24 @@
},
"THUMBNAIL": {
"AUTHOR": {
- "NOT_AVAILABLE": "Not available"
+ "NOT_AVAILABLE": "მიუწვდომელია"
}
},
"TEAM_AVAILABILITY": {
"ONLINE": "We are online",
"OFFLINE": "We are away at the moment",
- "BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
+ "BACK_AS_SOON_AS_POSSIBLE": "ჩვენ დავბრუნდებით რაც შეიძლება მალე"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
"IN_A_FEW_HOURS": "Typically replies in a few hours",
"IN_A_DAY": "Typically replies in a day",
- "BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
- "BACK_IN_MINUTES": "We will be back online in {time} minutes",
- "BACK_AT_TIME": "We will be back online at {time}",
- "BACK_ON_DAY": "We will be back online on {day}",
- "BACK_TOMORROW": "We will be back online tomorrow",
- "BACK_IN_SOME_TIME": "We will be back online in some time"
+ "BACK_IN_HOURS": "ჩვენ ონლაინ დავბრუნდებით {n} საათში | ჩვენ ონლაინ დავბრუნდებით {n} საათში",
+ "BACK_IN_MINUTES": "ჩვენ ონლაინ დავბრუნდებით {time} წუთში",
+ "BACK_AT_TIME": "ჩვენ ონლაინ დავბრუნდებით {time}-ზე",
+ "BACK_ON_DAY": "ჩვენ ონლაინ დავბრუნდებით {day}-ს",
+ "BACK_TOMORROW": "ჩვენ ონლაინ დავბრუნდებით ხვალ",
+ "BACK_IN_SOME_TIME": "ჩვენ მალე დავბრუნდებით ონლაინ"
},
"DAY_NAMES": {
"SUNDAY": "Sunday",
diff --git a/app/javascript/widget/i18n/locale/ko.json b/app/javascript/widget/i18n/locale/ko.json
index d084e243b..d9f213da4 100644
--- a/app/javascript/widget/i18n/locale/ko.json
+++ b/app/javascript/widget/i18n/locale/ko.json
@@ -14,20 +14,20 @@
},
"THUMBNAIL": {
"AUTHOR": {
- "NOT_AVAILABLE": "Not available"
+ "NOT_AVAILABLE": "사용 불가"
}
},
"TEAM_AVAILABILITY": {
"ONLINE": "온라인",
"OFFLINE": "부재중",
- "BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
+ "BACK_AS_SOON_AS_POSSIBLE": "가능한 한 빨리 돌아오겠습니다"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "보통 몇 분 안에 응답",
"IN_A_FEW_HOURS": "보통 몇 시간 내에 응답",
"IN_A_DAY": "보통 하루 안에 응답",
- "BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
- "BACK_IN_MINUTES": "We will be back online in {time} minutes",
+ "BACK_IN_HOURS": "{n}시간 후에 서비스를 재개할 예정입니다 | {n}시간 후에 서비스를 재개할 예정입니다",
+ "BACK_IN_MINUTES": "{time}분 후에 다시 접속 가능합니다.",
"BACK_AT_TIME": "We will be back online at {time}",
"BACK_ON_DAY": "We will be back online on {day}",
"BACK_TOMORROW": "We will be back online tomorrow",
diff --git a/app/javascript/widget/i18n/locale/pt_BR.json b/app/javascript/widget/i18n/locale/pt_BR.json
index 124e9f8a9..c5c85a251 100644
--- a/app/javascript/widget/i18n/locale/pt_BR.json
+++ b/app/javascript/widget/i18n/locale/pt_BR.json
@@ -27,10 +27,10 @@
"IN_A_FEW_HOURS": "Normalmente responde em algumas horas",
"IN_A_DAY": "Normalmente responde em um dia",
"BACK_IN_HOURS": "Estaremos de volta em {n} hora | Estaremos de volta em {n} horas",
- "BACK_IN_MINUTES": "We will be back online in {time} minutes",
- "BACK_AT_TIME": "We will be back online at {time}",
- "BACK_ON_DAY": "We will be back online on {day}",
- "BACK_TOMORROW": "We will be back online tomorrow",
+ "BACK_IN_MINUTES": "Estaremos de volta em {time} minutos",
+ "BACK_AT_TIME": "Estaremos de volta em {time}",
+ "BACK_ON_DAY": "Estaremos de volta em {day}",
+ "BACK_TOMORROW": "Estaremos de volta amanhã",
"BACK_IN_SOME_TIME": "Estaremos disponíveis novamente em breve"
},
"DAY_NAMES": {
diff --git a/app/javascript/widget/i18n/locale/ru.json b/app/javascript/widget/i18n/locale/ru.json
index 37cb9ba04..a2d1005c1 100644
--- a/app/javascript/widget/i18n/locale/ru.json
+++ b/app/javascript/widget/i18n/locale/ru.json
@@ -14,24 +14,24 @@
},
"THUMBNAIL": {
"AUTHOR": {
- "NOT_AVAILABLE": "Not available"
+ "NOT_AVAILABLE": "Недоступен"
}
},
"TEAM_AVAILABILITY": {
"ONLINE": "Мы в сети",
"OFFLINE": "В данный момент мы отсутствуем",
- "BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
+ "BACK_AS_SOON_AS_POSSIBLE": "Мы вернемся как можно скорее"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "Обычно отвечаем в течение нескольких минут",
"IN_A_FEW_HOURS": "Обычно отвечаем в течение нескольких часов",
"IN_A_DAY": "Обычно отвечаем в течение дня",
- "BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
- "BACK_IN_MINUTES": "We will be back online in {time} minutes",
- "BACK_AT_TIME": "We will be back online at {time}",
- "BACK_ON_DAY": "We will be back online on {day}",
- "BACK_TOMORROW": "We will be back online tomorrow",
- "BACK_IN_SOME_TIME": "We will be back online in some time"
+ "BACK_IN_HOURS": "Мы будем доступны через {n} час | Мы вернемся к вам через {n} часов",
+ "BACK_IN_MINUTES": "Мы будем доступны {time} минут",
+ "BACK_AT_TIME": "Мы будем доступны {time}",
+ "BACK_ON_DAY": "Мы будем доступны в {day}",
+ "BACK_TOMORROW": "Мы будем доступны завтра",
+ "BACK_IN_SOME_TIME": "Мы будем доступны через некоторое время"
},
"DAY_NAMES": {
"SUNDAY": "Воскресенье",
diff --git a/app/jobs/agent_bots/webhook_job.rb b/app/jobs/agent_bots/webhook_job.rb
index d0c4e7959..b3a3d6cc1 100644
--- a/app/jobs/agent_bots/webhook_job.rb
+++ b/app/jobs/agent_bots/webhook_job.rb
@@ -1,3 +1,7 @@
class AgentBots::WebhookJob < WebhookJob
queue_as :high
+
+ def perform(url, payload, webhook_type = :agent_bot_webhook)
+ super(url, payload, webhook_type)
+ end
end
diff --git a/app/jobs/conversation_reply_email_job.rb b/app/jobs/conversation_reply_email_job.rb
new file mode 100644
index 000000000..5d186bf29
--- /dev/null
+++ b/app/jobs/conversation_reply_email_job.rb
@@ -0,0 +1,15 @@
+class ConversationReplyEmailJob < ApplicationJob
+ queue_as :mailers
+
+ def perform(conversation_id, last_queued_id)
+ conversation = Conversation.find(conversation_id)
+
+ if conversation.messages.incoming&.last&.content_type == 'incoming_email'
+ ConversationReplyMailer.with(account: conversation.account).reply_without_summary(conversation, last_queued_id).deliver_later
+ else
+ ConversationReplyMailer.with(account: conversation.account).reply_with_summary(conversation, last_queued_id).deliver_later
+ end
+
+ Redis::Alfred.delete(format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id))
+ end
+end
diff --git a/app/jobs/send_reply_job.rb b/app/jobs/send_reply_job.rb
index b1cee960b..0aba9f7f1 100644
--- a/app/jobs/send_reply_job.rb
+++ b/app/jobs/send_reply_job.rb
@@ -1,27 +1,29 @@
class SendReplyJob < ApplicationJob
queue_as :high
+ CHANNEL_SERVICES = {
+ 'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService,
+ 'Channel::TwilioSms' => ::Twilio::SendOnTwilioService,
+ 'Channel::Line' => ::Line::SendOnLineService,
+ 'Channel::Telegram' => ::Telegram::SendOnTelegramService,
+ 'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
+ 'Channel::Sms' => ::Sms::SendOnSmsService,
+ 'Channel::Instagram' => ::Instagram::SendOnInstagramService,
+ 'Channel::Email' => ::Email::SendOnEmailService,
+ 'Channel::WebWidget' => ::Messages::SendEmailNotificationService,
+ 'Channel::Api' => ::Messages::SendEmailNotificationService
+ }.freeze
+
def perform(message_id)
message = Message.find(message_id)
- conversation = message.conversation
- channel_name = conversation.inbox.channel.class.to_s
+ channel_name = message.conversation.inbox.channel.class.to_s
- services = {
- 'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService,
- 'Channel::TwilioSms' => ::Twilio::SendOnTwilioService,
- 'Channel::Line' => ::Line::SendOnLineService,
- 'Channel::Telegram' => ::Telegram::SendOnTelegramService,
- 'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
- 'Channel::Sms' => ::Sms::SendOnSmsService,
- 'Channel::Instagram' => ::Instagram::SendOnInstagramService
- }
+ return send_on_facebook_page(message) if channel_name == 'Channel::FacebookPage'
- case channel_name
- when 'Channel::FacebookPage'
- send_on_facebook_page(message)
- else
- services[channel_name].new(message: message).perform if services[channel_name].present?
- end
+ service_class = CHANNEL_SERVICES[channel_name]
+ return unless service_class
+
+ service_class.new(message: message).perform
end
private
diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb
index a82c65440..8dbe67bf8 100644
--- a/app/mailers/conversation_reply_mailer.rb
+++ b/app/mailers/conversation_reply_mailer.rb
@@ -39,8 +39,7 @@ class ConversationReplyMailer < ApplicationMailer
init_conversation_attributes(message.conversation)
@message = message
- reply_mail_object = prepare_mail(true)
- message.update(source_id: reply_mail_object.message_id)
+ prepare_mail(true)
end
def conversation_transcript(conversation, to_email)
diff --git a/app/models/channel/email.rb b/app/models/channel/email.rb
index a8fadb61e..b1124dd75 100644
--- a/app/models/channel/email.rb
+++ b/app/models/channel/email.rb
@@ -40,6 +40,12 @@ class Channel::Email < ApplicationRecord
AUTHORIZATION_ERROR_THRESHOLD = 10
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ if Chatwoot.encryption_configured?
+ encrypts :imap_password
+ encrypts :smtp_password
+ end
+
self.table_name = 'channel_email'
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl,
:smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto,
diff --git a/app/models/channel/facebook_page.rb b/app/models/channel/facebook_page.rb
index 1b6e151cb..1866d245b 100644
--- a/app/models/channel/facebook_page.rb
+++ b/app/models/channel/facebook_page.rb
@@ -21,6 +21,12 @@ class Channel::FacebookPage < ApplicationRecord
include Channelable
include Reauthorizable
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ if Chatwoot.encryption_configured?
+ encrypts :page_access_token
+ encrypts :user_access_token
+ end
+
self.table_name = 'channel_facebook_pages'
validates :page_id, uniqueness: { scope: :account_id }
diff --git a/app/models/channel/instagram.rb b/app/models/channel/instagram.rb
index 964a4c1a2..7e6444b30 100644
--- a/app/models/channel/instagram.rb
+++ b/app/models/channel/instagram.rb
@@ -19,6 +19,9 @@ class Channel::Instagram < ApplicationRecord
include Reauthorizable
self.table_name = 'channel_instagram'
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ encrypts :access_token if Chatwoot.encryption_configured?
+
AUTHORIZATION_ERROR_THRESHOLD = 1
validates :access_token, presence: true
diff --git a/app/models/channel/line.rb b/app/models/channel/line.rb
index a417dbf64..63b0924da 100644
--- a/app/models/channel/line.rb
+++ b/app/models/channel/line.rb
@@ -18,6 +18,12 @@
class Channel::Line < ApplicationRecord
include Channelable
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ if Chatwoot.encryption_configured?
+ encrypts :line_channel_secret
+ encrypts :line_channel_token
+ end
+
self.table_name = 'channel_line'
EDITABLE_ATTRS = [:line_channel_id, :line_channel_secret, :line_channel_token].freeze
diff --git a/app/models/channel/telegram.rb b/app/models/channel/telegram.rb
index b00897614..b18c6dbc9 100644
--- a/app/models/channel/telegram.rb
+++ b/app/models/channel/telegram.rb
@@ -17,6 +17,9 @@
class Channel::Telegram < ApplicationRecord
include Channelable
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ encrypts :bot_token, deterministic: true if Chatwoot.encryption_configured?
+
self.table_name = 'channel_telegram'
EDITABLE_ATTRS = [:bot_token].freeze
diff --git a/app/models/channel/twilio_sms.rb b/app/models/channel/twilio_sms.rb
index 73e5c873e..2f9130cbb 100644
--- a/app/models/channel/twilio_sms.rb
+++ b/app/models/channel/twilio_sms.rb
@@ -28,6 +28,9 @@ class Channel::TwilioSms < ApplicationRecord
self.table_name = 'channel_twilio_sms'
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ encrypts :auth_token if Chatwoot.encryption_configured?
+
validates :account_sid, presence: true
# The same parameter is used to store api_key_secret if api_key authentication is opted
validates :auth_token, presence: true
diff --git a/app/models/channel/twitter_profile.rb b/app/models/channel/twitter_profile.rb
index d0f765e9f..4ec167ce5 100644
--- a/app/models/channel/twitter_profile.rb
+++ b/app/models/channel/twitter_profile.rb
@@ -19,6 +19,12 @@
class Channel::TwitterProfile < ApplicationRecord
include Channelable
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ if Chatwoot.encryption_configured?
+ encrypts :twitter_access_token
+ encrypts :twitter_access_token_secret
+ end
+
self.table_name = 'channel_twitter_profiles'
validates :profile_id, uniqueness: { scope: :account_id }
diff --git a/app/models/concerns/activity_message_handler.rb b/app/models/concerns/activity_message_handler.rb
index 54e58b4d9..c25aba47f 100644
--- a/app/models/concerns/activity_message_handler.rb
+++ b/app/models/concerns/activity_message_handler.rb
@@ -106,7 +106,7 @@ module ActivityMessageHandler
end
def generate_assignee_change_activity_content(user_name)
- params = { assignee_name: assignee&.name, user_name: user_name }.compact
+ params = { assignee_name: assignee&.name || '', user_name: user_name }
key = assignee_id ? 'assigned' : 'removed'
key = 'self_assigned' if self_assign? assignee_id
I18n.t("conversations.activity.assignee.#{key}", **params)
diff --git a/app/models/contact.rb b/app/models/contact.rb
index a3570b2af..0dc92b51e 100644
--- a/app/models/contact.rb
+++ b/app/models/contact.rb
@@ -21,6 +21,7 @@
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
+# company_id :bigint
#
# Indexes
#
@@ -28,6 +29,7 @@
# index_contacts_on_account_id_and_contact_type (account_id,contact_type)
# index_contacts_on_account_id_and_last_activity_at (account_id,last_activity_at DESC NULLS LAST)
# index_contacts_on_blocked (blocked)
+# index_contacts_on_company_id (company_id)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
@@ -244,3 +246,4 @@ class Contact < ApplicationRecord
Rails.configuration.dispatcher.dispatch(CONTACT_DELETED, Time.zone.now, contact: self)
end
end
+Contact.include_mod_with('Concerns::Contact')
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
index b3d58fd17..6d64c0447 100644
--- a/app/models/custom_filter.rb
+++ b/app/models/custom_filter.rb
@@ -17,7 +17,6 @@
# index_custom_filters_on_user_id (user_id)
#
class CustomFilter < ApplicationRecord
- MAX_FILTER_PER_USER = 50
belongs_to :user
belongs_to :account
@@ -25,7 +24,7 @@ class CustomFilter < ApplicationRecord
validate :validate_number_of_filters
def validate_number_of_filters
- return true if account.custom_filters.where(user_id: user_id).size < MAX_FILTER_PER_USER
+ return true if account.custom_filters.where(user_id: user_id).size < Limits::MAX_CUSTOM_FILTERS_PER_USER
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
end
diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb
index ca77fa13d..97d3f91ae 100644
--- a/app/models/integrations/hook.rb
+++ b/app/models/integrations/hook.rb
@@ -21,6 +21,9 @@ class Integrations::Hook < ApplicationRecord
before_validation :ensure_hook_type
after_create :trigger_setup_if_crm
+ # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
+ encrypts :access_token, deterministic: true if Chatwoot.encryption_configured?
+
validates :account_id, presence: true
validates :app_id, presence: true
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
diff --git a/app/models/message.rb b/app/models/message.rb
index dbab19df3..5f98493d0 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -39,7 +39,7 @@
#
class Message < ApplicationRecord
- searchkick callbacks: :async if ChatwootApp.advanced_search_allowed?
+ searchkick callbacks: false if ChatwootApp.advanced_search_allowed?
include MessageFilterHelpers
include Liquidable
@@ -135,6 +135,7 @@ class Message < ApplicationRecord
after_create_commit :execute_after_create_commit_callbacks
after_update_commit :dispatch_update_event
+ after_commit :reindex_for_search, if: :should_index?, on: [:create, :update]
def channel_token
@token ||= inbox.channel.try(:page_access_token)
@@ -299,7 +300,6 @@ class Message < ApplicationRecord
def execute_after_create_commit_callbacks
# rails issue with order of active record callbacks being executed https://github.com/rails/rails/issues/20911
reopen_conversation
- notify_via_mail
set_conversation_activity
dispatch_create_events
send_reply
@@ -385,48 +385,6 @@ class Message < ApplicationRecord
::MessageTemplates::HookExecutionService.new(message: self).perform
end
- def email_notifiable_webwidget?
- inbox.web_widget? && inbox.channel.continuity_via_email
- end
-
- def email_notifiable_api_channel?
- inbox.api? && inbox.account.feature_enabled?('email_continuity_on_api_channel')
- end
-
- def email_notifiable_channel?
- email_notifiable_webwidget? || %w[Email].include?(inbox.inbox_type) || email_notifiable_api_channel?
- end
-
- def can_notify_via_mail?
- return unless email_notifiable_message?
- return unless email_notifiable_channel?
- return if conversation.contact.email.blank?
-
- true
- end
-
- def notify_via_mail
- return unless can_notify_via_mail?
-
- trigger_notify_via_mail
- end
-
- def trigger_notify_via_mail
- return EmailReplyWorker.perform_in(1.second, id) if inbox.inbox_type == 'Email'
-
- # will set a redis key for the conversation so that we don't need to send email for every new message
- # last few messages coupled together is sent every 2 minutes rather than one email for each message
- # if redis key exists there is an unprocessed job that will take care of delivering the email
- return if Redis::Alfred.get(conversation_mail_key).present?
-
- Redis::Alfred.setex(conversation_mail_key, id)
- ConversationReplyEmailWorker.perform_in(2.minutes, conversation.id, id)
- end
-
- def conversation_mail_key
- format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
- end
-
def validate_attachments_limit(_attachment)
errors.add(:attachments, message: 'exceeded maximum allowed') if attachments.size >= NUMBER_OF_PERMITTED_ATTACHMENTS
end
@@ -436,6 +394,10 @@ class Message < ApplicationRecord
conversation.update_columns(last_activity_at: created_at)
# rubocop:enable Rails/SkipsModelValidations
end
+
+ def reindex_for_search
+ reindex(mode: :async)
+ end
end
Message.prepend_mod_with('Message')
diff --git a/app/models/super_admin.rb b/app/models/super_admin.rb
index 316d60c7b..9bcee9b8a 100644
--- a/app/models/super_admin.rb
+++ b/app/models/super_admin.rb
@@ -19,7 +19,7 @@
# message_signature :text
# name :string not null
# otp_backup_codes :text
-# otp_required_for_login :boolean default(FALSE), not null
+# otp_required_for_login :boolean default(FALSE)
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
diff --git a/app/models/user.rb b/app/models/user.rb
index 4923d0a35..cc25357f6 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -19,7 +19,7 @@
# message_signature :text
# name :string not null
# otp_backup_codes :text
-# otp_required_for_login :boolean default(FALSE), not null
+# otp_required_for_login :boolean default(FALSE)
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
diff --git a/app/policies/conversation_policy.rb b/app/policies/conversation_policy.rb
index 931e17435..d23f29e54 100644
--- a/app/policies/conversation_policy.rb
+++ b/app/policies/conversation_policy.rb
@@ -4,6 +4,44 @@ class ConversationPolicy < ApplicationPolicy
end
def destroy?
- @account_user&.administrator?
+ administrator?
+ end
+
+ def show?
+ administrator? || agent_bot? || agent_can_view_conversation?
+ end
+
+ private
+
+ def agent_can_view_conversation?
+ inbox_access? || team_access?
+ end
+
+ def administrator?
+ account_user&.administrator?
+ end
+
+ def agent_bot?
+ user.is_a?(AgentBot)
+ end
+
+ def inbox_access?
+ user.inboxes.where(account_id: account&.id).exists?(id: record.inbox_id)
+ end
+
+ def team_access?
+ return false if record.team_id.blank?
+
+ user.teams.where(account_id: account&.id).exists?(id: record.team_id)
+ end
+
+ def assigned_to_user?
+ record.assignee_id == user.id
+ end
+
+ def participant?
+ record.conversation_participants.exists?(user_id: user.id)
end
end
+
+ConversationPolicy.prepend_mod_with('ConversationPolicy')
diff --git a/app/services/email/send_on_email_service.rb b/app/services/email/send_on_email_service.rb
new file mode 100644
index 000000000..b3a0060b1
--- /dev/null
+++ b/app/services/email/send_on_email_service.rb
@@ -0,0 +1,18 @@
+class Email::SendOnEmailService < Base::SendOnChannelService
+ private
+
+ def channel_class
+ Channel::Email
+ end
+
+ def perform_reply
+ return unless message.email_notifiable_message?
+
+ reply_mail = ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
+ Rails.logger.info("Email message #{message.id} sent with source_id: #{reply_mail.message_id}")
+ message.update(source_id: reply_mail.message_id)
+ rescue StandardError => e
+ ChatwootExceptionTracker.new(e, account: message.account).capture_exception
+ Messages::StatusUpdateService.new(message, 'failed', e.message).perform
+ end
+end
diff --git a/app/services/messages/send_email_notification_service.rb b/app/services/messages/send_email_notification_service.rb
new file mode 100644
index 000000000..25a77b0d5
--- /dev/null
+++ b/app/services/messages/send_email_notification_service.rb
@@ -0,0 +1,38 @@
+class Messages::SendEmailNotificationService
+ pattr_initialize [:message!]
+
+ def perform
+ return unless should_send_email_notification?
+
+ conversation = message.conversation
+ conversation_mail_key = format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
+
+ # Atomically set redis key to prevent duplicate email workers. Keep the key alive longer than
+ # the worker delay (1 hour) so slow queues don't enqueue duplicate jobs, but let it expire if
+ # the worker never manages to clean up.
+ return unless Redis::Alfred.set(conversation_mail_key, message.id, nx: true, ex: 1.hour.to_i)
+
+ ConversationReplyEmailJob.set(wait: 2.minutes).perform_later(conversation.id, message.id)
+ end
+
+ private
+
+ def should_send_email_notification?
+ return false unless message.email_notifiable_message?
+ return false if message.conversation.contact.email.blank?
+
+ email_reply_enabled?
+ end
+
+ def email_reply_enabled?
+ inbox = message.inbox
+ case inbox.channel.class.to_s
+ when 'Channel::WebWidget'
+ inbox.channel.continuity_via_email
+ when 'Channel::Api'
+ inbox.account.feature_enabled?('email_continuity_on_api_channel')
+ else
+ false
+ end
+ end
+end
diff --git a/app/services/twilio/incoming_message_service.rb b/app/services/twilio/incoming_message_service.rb
index 7a74488e2..5d695ebb2 100644
--- a/app/services/twilio/incoming_message_service.rb
+++ b/app/services/twilio/incoming_message_service.rb
@@ -44,6 +44,12 @@ class Twilio::IncomingMessageService
twilio_channel.sms? ? params[:From] : params[:From].gsub('whatsapp:', '')
end
+ def normalized_phone_number
+ return phone_number unless twilio_channel.whatsapp?
+
+ Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio)
+ end
+
def formatted_phone_number
TelephoneNumber.parse(phone_number).international_number
end
@@ -53,8 +59,10 @@ class Twilio::IncomingMessageService
end
def set_contact
+ source_id = twilio_channel.whatsapp? ? normalized_phone_number : params[:From]
+
contact_inbox = ::ContactInboxWithContactBuilder.new(
- source_id: params[:From],
+ source_id: source_id,
inbox: inbox,
contact_attributes: contact_attributes
).perform
diff --git a/app/services/whatsapp/incoming_message_service_helpers.rb b/app/services/whatsapp/incoming_message_service_helpers.rb
index e40dc408f..46ad255aa 100644
--- a/app/services/whatsapp/incoming_message_service_helpers.rb
+++ b/app/services/whatsapp/incoming_message_service_helpers.rb
@@ -48,7 +48,7 @@ module Whatsapp::IncomingMessageServiceHelpers
end
def processed_waid(waid)
- Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
+ Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(waid, :cloud)
end
def error_webhook_event?(message)
diff --git a/app/services/whatsapp/phone_normalizers/argentina_phone_normalizer.rb b/app/services/whatsapp/phone_normalizers/argentina_phone_normalizer.rb
new file mode 100644
index 000000000..109a0683f
--- /dev/null
+++ b/app/services/whatsapp/phone_normalizers/argentina_phone_normalizer.rb
@@ -0,0 +1,18 @@
+# Handles Argentina phone number normalization
+#
+# Argentina phone numbers can appear with or without "9" after country code
+# This normalizer removes the "9" when present to create consistent format: 54 + area + number
+class Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer < Whatsapp::PhoneNormalizers::BasePhoneNormalizer
+ def normalize(waid)
+ return waid unless handles_country?(waid)
+
+ # Remove "9" after country code if present (549 → 54)
+ waid.sub(/^549/, '54')
+ end
+
+ private
+
+ def country_code_pattern
+ /^54/
+ end
+end
diff --git a/app/services/whatsapp/phone_number_normalization_service.rb b/app/services/whatsapp/phone_number_normalization_service.rb
index b8e416794..1e52d9b02 100644
--- a/app/services/whatsapp/phone_number_normalization_service.rb
+++ b/app/services/whatsapp/phone_number_normalization_service.rb
@@ -1,23 +1,32 @@
# Service to handle phone number normalization for WhatsApp messages
-# Currently supports Brazil phone number format variations
-# Designed to be extensible for additional countries in future PRs
-#
-# Usage: Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
+# Currently supports Brazil and Argentina phone number format variations
+# Supports both WhatsApp Cloud API and Twilio WhatsApp providers
class Whatsapp::PhoneNumberNormalizationService
def initialize(inbox)
@inbox = inbox
end
- # Main entry point for phone number normalization
- # Returns the source_id of an existing contact if found, otherwise returns original waid
- def normalize_and_find_contact(waid)
- normalizer = find_normalizer_for_country(waid)
- return waid unless normalizer
+ # @param raw_number [String] The phone number in provider-specific format
+ # - Cloud: "5541988887777" (clean number)
+ # - Twilio: "whatsapp:+5541988887777" (prefixed format)
+ # @param provider [Symbol] :cloud or :twilio
+ # @return [String] Normalized source_id in provider format or original if not found
+ def normalize_and_find_contact_by_provider(raw_number, provider)
+ # Extract clean number based on provider format
+ clean_number = extract_clean_number(raw_number, provider)
- normalized_waid = normalizer.normalize(waid)
- existing_contact_inbox = find_existing_contact_inbox(normalized_waid)
+ # Find appropriate normalizer for the country
+ normalizer = find_normalizer_for_country(clean_number)
+ return raw_number unless normalizer
- existing_contact_inbox&.source_id || waid
+ # Normalize the clean number
+ normalized_clean_number = normalizer.normalize(clean_number)
+
+ # Format for provider and check for existing contact
+ provider_format = format_for_provider(normalized_clean_number, provider)
+ existing_contact_inbox = find_existing_contact_inbox(provider_format)
+
+ existing_contact_inbox&.source_id || raw_number
end
private
@@ -33,7 +42,28 @@ class Whatsapp::PhoneNumberNormalizationService
inbox.contact_inboxes.find_by(source_id: normalized_waid)
end
+ # Extract clean number from provider-specific format
+ def extract_clean_number(raw_number, provider)
+ case provider
+ when :twilio
+ raw_number.gsub(/^whatsapp:\+/, '') # Remove prefix: "whatsapp:+5541988887777" → "5541988887777"
+ else
+ raw_number # Default fallback for unknown providers
+ end
+ end
+
+ # Format normalized number for provider-specific storage
+ def format_for_provider(clean_number, provider)
+ case provider
+ when :twilio
+ "whatsapp:+#{clean_number}" # Add prefix: "5541988887777" → "whatsapp:+5541988887777"
+ else
+ clean_number # Default for :cloud and unknown providers: "5541988887777"
+ end
+ end
+
NORMALIZERS = [
- Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer
+ Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer,
+ Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer
].freeze
end
diff --git a/app/services/whatsapp/populate_template_parameters_service.rb b/app/services/whatsapp/populate_template_parameters_service.rb
index 3f9f64b91..6ea3e6e05 100644
--- a/app/services/whatsapp/populate_template_parameters_service.rb
+++ b/app/services/whatsapp/populate_template_parameters_service.rb
@@ -34,8 +34,9 @@ class Whatsapp::PopulateTemplateParametersService
return nil if url.blank?
sanitized_url = sanitize_parameter(url)
- validate_url(sanitized_url)
- build_media_type_parameter(sanitized_url, media_type.downcase, media_name)
+ normalized_url = normalize_url(sanitized_url)
+ validate_url(normalized_url)
+ build_media_type_parameter(normalized_url, media_type.downcase, media_name)
end
def build_named_parameter(parameter_name, value)
@@ -138,9 +139,20 @@ class Whatsapp::PopulateTemplateParametersService
sanitized[0...1000] # Limit length to prevent DoS
end
+ def normalize_url(url)
+ # Use Addressable::URI for better URL normalization
+ # It handles spaces, special characters, and encoding automatically
+ Addressable::URI.parse(url).normalize.to_s
+ rescue Addressable::URI::InvalidURIError
+ # Fallback: simple space encoding if Addressable fails
+ url.gsub(' ', '%20')
+ end
+
def validate_url(url)
return if url.blank?
+ # url is already normalized by the caller
+
uri = URI.parse(url)
raise ArgumentError, "Invalid URL scheme: #{uri.scheme}. Only http and https are allowed" unless %w[http https].include?(uri.scheme)
raise ArgumentError, 'URL too long (max 2000 characters)' if url.length > 2000
diff --git a/app/views/mailers/conversation_reply_mailer/email_reply.html.erb b/app/views/mailers/conversation_reply_mailer/email_reply.html.erb
index feb5dff96..f5f827e4c 100644
--- a/app/views/mailers/conversation_reply_mailer/email_reply.html.erb
+++ b/app/views/mailers/conversation_reply_mailer/email_reply.html.erb
@@ -1,9 +1,11 @@
-<% if @message.content %>
- <%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
+<% if @message.content_attributes.dig('email', 'html_content', 'reply').present? %>
+<%= @message.content_attributes.dig('email', 'html_content', 'reply').html_safe %>
+<% elsif @message.content %>
+<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<% end %>
<% if @large_attachments.present? %>
- Attachments:
- <% @large_attachments.each do |attachment| %>
- <%= attachment.file.filename.to_s %>
- <% end %>
+Attachments:
+<% @large_attachments.each do |attachment| %>
+<%= attachment.file.filename.to_s %>
+<% end %>
<% end %>
diff --git a/app/workers/conversation_reply_email_worker.rb b/app/workers/conversation_reply_email_worker.rb
deleted file mode 100644
index 0eddecaf7..000000000
--- a/app/workers/conversation_reply_email_worker.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# TODO: lets move this to active job, since thats what we use over all
-class ConversationReplyEmailWorker
- include Sidekiq::Worker
- sidekiq_options queue: :mailers
-
- def perform(conversation_id, last_queued_id)
- @conversation = Conversation.find(conversation_id)
-
- # send the email
- if @conversation.messages.incoming&.last&.content_type == 'incoming_email'
- ConversationReplyMailer.with(account: @conversation.account).reply_without_summary(@conversation, last_queued_id).deliver_later
- else
- ConversationReplyMailer.with(account: @conversation.account).reply_with_summary(@conversation, last_queued_id).deliver_later
- end
-
- # delete the redis set from the first new message on the conversation
- Redis::Alfred.delete(conversation_mail_key)
- end
-
- private
-
- def email_inbox?
- @conversation.inbox&.inbox_type == 'Email'
- end
-
- def conversation_mail_key
- format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: @conversation.id)
- end
-end
diff --git a/app/workers/email_reply_worker.rb b/app/workers/email_reply_worker.rb
deleted file mode 100644
index 14b668637..000000000
--- a/app/workers/email_reply_worker.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-class EmailReplyWorker
- include Sidekiq::Worker
- sidekiq_options queue: :mailers, retry: 3
-
- def perform(message_id)
- message = Message.find(message_id)
-
- return unless message.email_notifiable_message?
-
- # send the email
- ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
- rescue StandardError => e
- ChatwootExceptionTracker.new(e, account: message.account).capture_exception
- Messages::StatusUpdateService.new(message, 'failed', e.message).perform
- end
-end
diff --git a/config/app.yml b/config/app.yml
index 34d044656..e3d9c2c69 100644
--- a/config/app.yml
+++ b/config/app.yml
@@ -1,5 +1,5 @@
shared: &shared
- version: '4.6.0'
+ version: '4.7.0'
development:
<<: *shared
diff --git a/config/application.rb b/config/application.rb
index d644dd28f..aa150794a 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -75,7 +75,11 @@ module Chatwoot
config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', nil)
config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', nil)
+ # TODO: Remove once encryption is mandatory and legacy plaintext is migrated.
config.active_record.encryption.support_unencrypted_data = true
+ # Extend deterministic queries so they match both encrypted and plaintext rows
+ config.active_record.encryption.extend_queries = true
+ # Store a per-row key reference to support future key rotation
config.active_record.encryption.store_key_references = true
end
end
@@ -94,6 +98,8 @@ module Chatwoot
end
def self.encryption_configured?
+ # TODO: Once Active Record encryption keys are mandatory (target 3-4 releases out),
+ # remove this guard and assume encryption is always enabled.
# Check if proper encryption keys are configured
# MFA/2FA features should only be enabled when proper keys are set
ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present? &&
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index 96561f2ad..04d605c2e 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -6,9 +6,22 @@ Sidekiq.configure_client do |config|
config.redis = Redis::Config.app
end
+# Logs whenever a job is pulled off Redis for execution.
+class ChatwootDequeuedLogger
+ def call(_worker, job, queue)
+ payload = job['args'].first
+ Sidekiq.logger.info("Dequeued #{job['wrapped']} #{payload['job_id']} from #{queue}")
+ yield
+ end
+end
+
Sidekiq.configure_server do |config|
config.redis = Redis::Config.app
+ config.server_middleware do |chain|
+ chain.add ChatwootDequeuedLogger
+ end
+
# skip the default start stop logging
if Rails.env.production?
config.logger.formatter = Sidekiq::Logger::Formatters::JSON.new
diff --git a/config/locales/am.yml b/config/locales/am.yml
index c91f19922..f1e9bf52b 100644
--- a/config/locales/am.yml
+++ b/config/locales/am.yml
@@ -62,6 +62,9 @@ am:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ am:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ am:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ am:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/ar.yml b/config/locales/ar.yml
index 777216dd5..b4998c6e4 100644
--- a/config/locales/ar.yml
+++ b/config/locales/ar.yml
@@ -62,6 +62,9 @@ ar:
invalid: إيميل غير صالح
phone_number:
invalid: يجب أن يكون بصيغة e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: يجب أن تكون فريدة من نوعها في الفئة والبوابة
@@ -86,7 +89,7 @@ ar:
validations:
name: لا ينبغي أن تبدأ أو تنتهي بالرموز، ولا ينبغي أن يكون أقل من > / \ أحرف @ .
custom_filters:
- number_of_records: تم الوصول إلى الحد الأقصى. الحد الأقصى لعدد عوامل التصفية المخصصة المسموح به للمستخدم لكل حساب هو 50.
+ number_of_records: تم الوصول إلى الحد الأقصى. الحد الأقصى لعدد عوامل التصفية المخصصة المسموح به للمستخدم لكل حساب هو 1000.
invalid_attribute: مفتاح السمة غير صالح - [%{key}]. يجب أن يكون المفتاح واحد من [%{allowed_keys}] أو سمة مخصصة محددة في الحساب.
invalid_operator: مشغل غير صالح. المشغل المسموح به لـ %{attribute_name} هو [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ ar:
captain:
resolved: 'تم تحديد هذه المحادثة كمحلولة بواسطة %{user_name} بسبب عدم النشاط'
open: 'تم تحديد هذه المحادثة كمفتوحة بواسطة %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'تم تحديث حالة المحادثة لـ"مغلقة" بواسطة %{user_name}'
contact_resolved: 'تم حل المحادثة بواسطة %{contact_name}'
@@ -322,6 +327,8 @@ ar:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: البحث عن مقالة حسب العنوان أو الجسم...
diff --git a/config/locales/az.yml b/config/locales/az.yml
index 582e5d233..4ca5260f5 100644
--- a/config/locales/az.yml
+++ b/config/locales/az.yml
@@ -62,6 +62,9 @@ az:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ az:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ az:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ az:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/bg.yml b/config/locales/bg.yml
index be1ebaf87..9a83df74a 100644
--- a/config/locales/bg.yml
+++ b/config/locales/bg.yml
@@ -62,6 +62,9 @@ bg:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ bg:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ bg:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ bg:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/bn.yml b/config/locales/bn.yml
new file mode 100644
index 000000000..6e2cedeb1
--- /dev/null
+++ b/config/locales/bn.yml
@@ -0,0 +1,422 @@
+#Files in the config/locales directory are used for internationalization
+#and are automatically loaded by Rails. If you want to use locales other
+#than English, add the necessary files in this directory.
+#To use the locales, use `I18n.t`:
+#I18n.t 'hello'
+#In views, this is aliased to just `t`:
+#<%= t('hello') %>
+#To use a different locale, set it with `I18n.locale`:
+#I18n.locale = :es
+#This would use the information in config/locales/es.yml.
+#The following keys must be escaped otherwise they will not be retrieved by
+#the default I18n backend:
+#true, false, on, off, yes, no
+#Instead, surround them with single quotes.
+#en:
+#'true': 'foo'
+#To learn more, please read the Rails Internationalization guide
+#available at https://guides.rubyonrails.org/i18n.html.
+bn:
+ hello: 'Hello world'
+ inbox:
+ reauthorization:
+ success: 'Channel reauthorized successfully'
+ not_required: 'Reauthorization is not required for this inbox'
+ invalid_channel: 'Invalid channel type for reauthorization'
+ auth:
+ saml:
+ invalid_email: 'Please enter a valid email address'
+ authentication_failed: 'Authentication failed. Please check your credentials and try again.'
+ messages:
+ reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
+ reset_password_failure: Uh ho! We could not find any user with the specified email.
+ reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
+ login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
+ saml_not_available: SAML authentication is not available in this installation.
+ inbox_deletetion_response: Your inbox deletion request will be processed in some time.
+ errors:
+ validations:
+ presence: must not be blank
+ webhook:
+ invalid: Invalid events
+ signup:
+ disposable_email: We do not allow disposable emails
+ blocked_domain: This domain is not allowed. If you believe this is a mistake, please contact support.
+ invalid_email: You have entered an invalid email
+ email_already_exists: 'You have already signed up for an account with %{email}'
+ invalid_params: 'Invalid, please check the signup paramters and try again'
+ failed: Signup failed
+ assignment_policy:
+ not_found: Assignment policy not found
+ saml:
+ feature_not_enabled: SAML feature not enabled for this account
+ data_import:
+ data_type:
+ invalid: Invalid data type
+ contacts:
+ import:
+ failed: File is blank
+ export:
+ success: We will notify you once contacts export file is ready to view.
+ email:
+ invalid: Invalid email
+ phone_number:
+ invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
+ categories:
+ locale:
+ unique: should be unique in the category and portal
+ dyte:
+ invalid_message_type: 'Invalid message type. Action not permitted'
+ slack:
+ invalid_channel_id: 'Invalid slack channel. Please try again'
+ whatsapp:
+ token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
+ invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
+ phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ reauthorization:
+ generic: 'Failed to reauthorize WhatsApp. Please try again.'
+ not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
+ inboxes:
+ imap:
+ socket_error: Please check the network connection, IMAP address and try again.
+ no_response_error: Please check the IMAP credentials and try again.
+ host_unreachable_error: Host unreachable, Please check the IMAP address, IMAP port and try again.
+ connection_timed_out_error: Connection timed out for %{address}:%{port}
+ connection_closed_error: Connection closed.
+ validations:
+ name: should not start or end with symbols, and it should not have < > / \ @ characters.
+ custom_filters:
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
+ invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
+ invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
+ invalid_query_operator: Query operator must be either "AND" or "OR".
+ invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
+ mfa:
+ already_enabled: MFA is already enabled
+ not_enabled: MFA is not enabled
+ invalid_code: Invalid verification code
+ invalid_backup_code: Invalid backup code
+ invalid_token: Invalid or expired MFA token
+ invalid_credentials: Invalid credentials or verification code
+ feature_unavailable: MFA feature is not available. Please configure encryption keys.
+ profile:
+ mfa:
+ enabled: MFA enabled successfully
+ disabled: MFA disabled successfully
+ account_saml_settings:
+ invalid_certificate: must be a valid X.509 certificate in PEM format
+ reports:
+ period: Reporting period %{since} to %{until}
+ utc_warning: The report generated is in UTC timezone
+ agent_csv:
+ agent_name: Agent name
+ conversations_count: Assigned conversations
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ resolution_count: Resolution Count
+ avg_customer_waiting_time: Avg customer waiting time
+ inbox_csv:
+ inbox_name: Inbox name
+ inbox_type: Inbox type
+ conversations_count: No. of conversations
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ label_csv:
+ label_title: Label
+ conversations_count: No. of conversations
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Resolution Count
+ team_csv:
+ team_name: Team name
+ conversations_count: Conversations count
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ resolution_count: Resolution Count
+ avg_customer_waiting_time: Avg customer waiting time
+ conversation_traffic_csv:
+ timezone: Timezone
+ sla_csv:
+ conversation_id: Conversation ID
+ sla_policy_breached: SLA Policy
+ assignee: Assignee
+ team: Team
+ inbox: Inbox
+ labels: Labels
+ conversation_link: Link to the Conversation
+ breached_events: Breached Events
+ default_group_by: day
+ csat:
+ headers:
+ contact_name: Contact Name
+ contact_email_address: Contact Email Address
+ contact_phone_number: Contact Phone Number
+ link_to_the_conversation: Link to the conversation
+ agent_name: Agent Name
+ rating: Rating
+ feedback: Feedback Comment
+ recorded_at: Recorded date
+ notifications:
+ notification_title:
+ conversation_creation: 'A conversation (#%{display_id}) has been created in %{inbox_name}'
+ conversation_assignment: 'A conversation (#%{display_id}) has been assigned to you'
+ assigned_conversation_new_message: 'A new message is created in conversation (#%{display_id})'
+ conversation_mention: 'You have been mentioned in conversation (#%{display_id})'
+ sla_missed_first_response: 'SLA target first response missed for conversation (#%{display_id})'
+ sla_missed_next_response: 'SLA target next response missed for conversation (#%{display_id})'
+ sla_missed_resolution: 'SLA target resolution missed for conversation (#%{display_id})'
+ attachment: 'Attachment'
+ no_content: 'No content'
+ conversations:
+ captain:
+ handoff: 'Transferring to another agent for further assistance.'
+ messages:
+ instagram_story_content: '%{story_sender} mentioned you in the story: '
+ instagram_deleted_story_content: This story is no longer available.
+ deleted: This message was deleted
+ whatsapp:
+ list_button_label: 'Choose an item'
+ delivery_status:
+ error_code: 'Error code: %{error_code}'
+ activity:
+ captain:
+ resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
+ open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
+ status:
+ resolved: 'Conversation was marked resolved by %{user_name}'
+ contact_resolved: 'Conversation was resolved by %{contact_name}'
+ open: 'Conversation was reopened by %{user_name}'
+ pending: 'Conversation was marked as pending by %{user_name}'
+ snoozed: 'Conversation was snoozed by %{user_name}'
+ auto_resolved_days: 'Conversation was marked resolved by system due to %{count} days of inactivity'
+ auto_resolved_hours: 'Conversation was marked resolved by system due to %{count} hours of inactivity'
+ auto_resolved_minutes: 'Conversation was marked resolved by system due to %{count} minutes of inactivity'
+ system_auto_open: System reopened the conversation due to a new incoming message.
+ priority:
+ added: '%{user_name} set the priority to %{new_priority}'
+ updated: '%{user_name} changed the priority from %{old_priority} to %{new_priority}'
+ removed: '%{user_name} removed the priority'
+ assignee:
+ self_assigned: '%{user_name} self-assigned this conversation'
+ assigned: 'Assigned to %{assignee_name} by %{user_name}'
+ removed: 'Conversation unassigned by %{user_name}'
+ team:
+ assigned: 'Assigned to %{team_name} by %{user_name}'
+ assigned_with_assignee: 'Assigned to %{assignee_name} via %{team_name} by %{user_name}'
+ removed: 'Unassigned from %{team_name} by %{user_name}'
+ labels:
+ added: '%{user_name} added %{labels}'
+ removed: '%{user_name} removed %{labels}'
+ sla:
+ added: '%{user_name} added SLA policy %{sla_name}'
+ removed: '%{user_name} removed SLA policy %{sla_name}'
+ linear:
+ issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
+ issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
+ issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
+ csat:
+ not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
+ auto_resolve:
+ not_sent_due_to_messaging_window: 'Auto-resolve message not sent due to outgoing message restrictions'
+ muted: '%{user_name} has muted the conversation'
+ unmuted: '%{user_name} has unmuted the conversation'
+ auto_resolution_message: 'Resolving the conversation as it has been inactive for a while. Please start a new conversation if you need further assistance.'
+ templates:
+ greeting_message_body: '%{account_name} typically replies in a few hours.'
+ ways_to_reach_you_message_body: 'Give the team a way to reach you.'
+ email_input_box_message_body: 'Get notified by email'
+ csat_input_message_body: 'Please rate the conversation'
+ reply:
+ email:
+ header:
+ notifications: 'Notifications'
+ from_with_name: '%{assignee_name} from %{inbox_name} <%{from_email}>'
+ reply_with_name: '%{assignee_name} from %{inbox_name} '
+ friendly_name: '%{sender_name} from %{business_name} <%{from_email}>'
+ professional_name: '%{business_name} <%{from_email}>'
+ channel_email:
+ header:
+ reply_with_name: '%{assignee_name} from %{inbox_name} <%{from_email}>'
+ reply_with_inbox_name: '%{inbox_name} <%{from_email}>'
+ email_subject: 'New messages on this conversation'
+ transcript_subject: 'Conversation Transcript'
+ survey:
+ response: 'Please rate this conversation, %{link}'
+ contacts:
+ online:
+ delete: '%{contact_name} is Online, please try again later'
+ integration_apps:
+ #Note: webhooks and dashboard_apps don't need short_description as they use different modal components
+ 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.'
+ 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.'
+ meeting_name: '%{agent_name} has started a meeting'
+ 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."
+ 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.'
+ 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.'
+ 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."
+ 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.'
+ 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.'
+ notion:
+ name: 'Notion'
+ short_description: 'Integrate databases, documents and pages directly with Captain.'
+ description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.'
+ 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.'
+ leadsquared:
+ name: 'LeadSquared'
+ short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
+ description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
+ captain:
+ copilot_message_required: Message is required
+ copilot_error: 'Please connect an assistant to this inbox to use Copilot'
+ copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
+ copilot:
+ using_tool: 'Using tool %{function_name}'
+ completed_tool_call: 'Completed %{function_name} tool call'
+ invalid_tool_call: 'Invalid tool call'
+ tool_not_available: 'Tool not available'
+ documents:
+ limit_exceeded: 'Document limit exceeded'
+ pdf_format_error: 'must be a PDF file'
+ pdf_size_error: 'must be less than 10MB'
+ pdf_upload_failed: 'Failed to upload PDF to OpenAI'
+ pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
+ pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
+ pdf_processing_success: 'Successfully processed PDF document %{document_id}'
+ faq_generation_complete: 'FAQ generation complete. Total FAQs created: %{count}'
+ using_paginated_faq: 'Using paginated FAQ generation for document %{document_id}'
+ using_standard_faq: 'Using standard FAQ generation for document %{document_id}'
+ response_creation_error: 'Error in creating response document: %{error}'
+ missing_openai_file_id: 'Document must have openai_file_id for paginated processing'
+ openai_api_error: 'OpenAI API Error: %{error}'
+ starting_paginated_faq: 'Starting paginated FAQ generation (%{pages_per_chunk} pages per chunk)'
+ stopping_faq_generation: 'Stopping processing. Reason: %{reason}'
+ paginated_faq_complete: 'Paginated generation complete. Total FAQs: %{total_faqs}, Pages processed: %{pages_processed}'
+ processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
+ chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
+ page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
+ public_portal:
+ search:
+ search_placeholder: Search for article by title or body...
+ empty_placeholder: No results found.
+ loading_placeholder: Searching...
+ results_title: Search results
+ toc_header: 'On this page'
+ hero:
+ sub_title: Search for the articles here or browse the categories below.
+ common:
+ home: Home
+ last_updated_on: Last updated on %{last_updated_on}
+ view_all_articles: View all
+ article: article
+ articles: articles
+ author: author
+ authors: authors
+ other: other
+ others: others
+ by: By
+ no_articles: There are no articles here
+ footer:
+ made_with: Made with
+ header:
+ go_to_homepage: Website
+ visit_website: Visit website
+ appearance:
+ system: System
+ light: Light
+ dark: Dark
+ featured_articles: Featured Articles
+ uncategorized: Uncategorized
+ 404:
+ title: Page not found
+ description: We couldn't find the page you were looking for.
+ back_to_home: Go to home page
+ slack_unfurl:
+ fields:
+ name: Name
+ email: Email
+ phone_number: Phone
+ company_name: Company
+ inbox_name: Inbox
+ inbox_type: Inbox Type
+ button: Open conversation
+ time_units:
+ days:
+ one: '%{count} day'
+ other: '%{count} days'
+ hours:
+ one: '%{count} hour'
+ other: '%{count} hours'
+ minutes:
+ one: '%{count} minute'
+ other: '%{count} minutes'
+ seconds:
+ one: '%{count} second'
+ other: '%{count} seconds'
+ automation:
+ system_name: 'Automation System'
+ crm:
+ no_message: 'No messages in conversation'
+ attachment: '[Attachment: %{type}]'
+ no_content: '[No content]'
+ created_activity: |
+ New conversation started on %{brand_name}
+
+ Channel: %{channel_info}
+ Created: %{formatted_creation_time}
+ Conversation ID: %{display_id}
+ View in %{brand_name}: %{url}
+ transcript_activity: |
+ Conversation Transcript from %{brand_name}
+
+ Channel: %{channel_info}
+ Conversation ID: %{display_id}
+ View in %{brand_name}: %{url}
+
+ Transcript:
+ %{format_messages}
+ agent_capacity_policy:
+ inbox_already_assigned: 'Inbox has already been assigned to this policy'
+ portals:
+ send_instructions:
+ email_required: 'Email is required'
+ invalid_email_format: 'Invalid email format'
+ custom_domain_not_configured: 'Custom domain is not configured'
+ instructions_sent_successfully: 'Instructions sent successfully'
+ subject: 'Finish setting up %{custom_domain}'
+ ssl_status:
+ custom_domain_not_configured: 'Custom domain is not configured'
diff --git a/config/locales/ca.yml b/config/locales/ca.yml
index e678aa96d..cd0e36f29 100644
--- a/config/locales/ca.yml
+++ b/config/locales/ca.yml
@@ -62,6 +62,9 @@ ca:
invalid: Correu electrònic invàlid
phone_number:
invalid: hauria d'estar en format e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: hauria de ser únic a la categoria i al portal
@@ -86,7 +89,7 @@ ca:
validations:
name: no hauria de començar ni acabar amb símbols, i no hauria de tenir caràcters < > / \ @.
custom_filters:
- number_of_records: S'ha arribat al límit. El nombre màxim de filtres personalitzats permesos per a un usuari per compte és de 50.
+ number_of_records: S'ha arribat al límit. El nombre màxim de filtres personalitzats permesos per a un usuari per compte és de 1000.
invalid_attribute: 'Clau d''atribut no vàlida: [%{key}]. La clau hauria de ser una de [%{allowed_keys}] o un atribut personalitzat definit al compte.'
invalid_operator: Operador no vàlid. Els operadors permesos per a %{attribute_name} son [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ ca:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'La conversa va ser marcada com resolta per %{user_name}'
contact_resolved: 'La conversa va ser resolta per %{contact_name}'
@@ -322,6 +327,8 @@ ca:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Cerca l'article per títol o cos...
diff --git a/config/locales/cs.yml b/config/locales/cs.yml
index 270a955c6..3fb647b48 100644
--- a/config/locales/cs.yml
+++ b/config/locales/cs.yml
@@ -62,6 +62,9 @@ cs:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ cs:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ cs:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Konverzace byla vyřešena uživatelem %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ cs:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/da.yml b/config/locales/da.yml
index d449604c7..d7c02426f 100644
--- a/config/locales/da.yml
+++ b/config/locales/da.yml
@@ -62,6 +62,9 @@ da:
invalid: Invalid email
phone_number:
invalid: skal være i e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: bør være unik i kategorien og portalen
@@ -86,7 +89,7 @@ da:
validations:
name: bør ikke starte eller slutte med symboler, og det skal ikke have < > / \ @ tegn.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ da:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Samtalen blev markeret som løst af %{user_name}'
contact_resolved: 'Samtalen blev løst af %{contact_name}'
@@ -322,6 +327,8 @@ da:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/de.yml b/config/locales/de.yml
index d9d6fb23d..c59576720 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -62,6 +62,9 @@ de:
invalid: Ungültige E-Mail
phone_number:
invalid: sollte im e164-Format vorliegen
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: sollte in der Kategorie und im Portal eindeutig sein
@@ -86,7 +89,7 @@ de:
validations:
name: Sollte nicht mit Symbolen beginnen oder enden, und es sollte keine < > / \ @ Zeichen enthalten.
custom_filters:
- number_of_records: Limit erreicht. Die maximale Anzahl an benutzerdefinierten Filtern pro Benutzerkonto beträgt 50.
+ number_of_records: Limit erreicht. Die maximale Anzahl an benutzerdefinierten Filtern pro Benutzerkonto beträgt 1000.
invalid_attribute: Ungültiger Attribut schlüssel - [%{key}]. Der Schlüssel sollte einer von [%{allowed_keys}] oder ein benutzerdefiniertes Attribut sein, das im Konto definiert ist.
invalid_operator: Ungültiger Operator. Die erlaubten Operatoren für %{attribute_name} sind [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ de:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Das Gespräch wurde von %{user_name} gelöst'
contact_resolved: 'Konversation wurde von %{contact_name} gelöst'
@@ -322,6 +327,8 @@ de:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Artikel nach Titel oder Text suchen...
diff --git a/config/locales/devise.bn.yml b/config/locales/devise.bn.yml
new file mode 100644
index 000000000..058a690f7
--- /dev/null
+++ b/config/locales/devise.bn.yml
@@ -0,0 +1,61 @@
+#Additional translations at https://github.com/plataformatec/devise/wiki/I18n
+bn:
+ devise:
+ confirmations:
+ confirmed: "Your email address has been successfully confirmed."
+ send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes."
+ failure:
+ already_authenticated: "You are already signed in."
+ inactive: "Your account is not activated yet."
+ invalid: "Invalid %{authentication_keys}/password or account is not verified yet."
+ locked: "Your account is locked."
+ last_attempt: "You have one more attempt before your account is locked."
+ not_found_in_database: "Invalid %{authentication_keys} or password."
+ timeout: "Your session expired. Please sign in again to continue."
+ unauthenticated: "You need to sign in or sign up before continuing."
+ unconfirmed: "You have to confirm your email address before continuing."
+ mailer:
+ confirmation_instructions:
+ subject: "Confirmation Instructions"
+ reset_password_instructions:
+ subject: "Reset password instructions"
+ unlock_instructions:
+ subject: "Unlock instructions"
+ password_change:
+ subject: "Password Changed"
+ omniauth_callbacks:
+ failure: "Could not authenticate you from %{kind} because \"%{reason}\"."
+ success: "Successfully authenticated from %{kind} account."
+ passwords:
+ no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided."
+ send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes."
+ updated: "Your password has been changed successfully. You are now signed in."
+ updated_not_active: "Your password has been changed successfully."
+ registrations:
+ destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon."
+ signed_up: "Welcome! You have signed up successfully."
+ signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated."
+ signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked."
+ signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account."
+ update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirm link to confirm your new email address."
+ updated: "Your account has been updated successfully."
+ sessions:
+ signed_in: "Signed in successfully."
+ signed_out: "Signed out successfully."
+ already_signed_out: "Signed out successfully."
+ unlocks:
+ send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes."
+ send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes."
+ unlocked: "Your account has been unlocked successfully. Please sign in to continue."
+ errors:
+ messages:
+ already_confirmed: "was already confirmed, please try signing in"
+ confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one"
+ expired: "has expired, please request a new one"
+ not_found: "not found"
+ not_locked: "was not locked"
+ not_saved:
+ one: "1 error prohibited this %{resource} from being saved:"
+ other: "%{count} errors prohibited this %{resource} from being saved:"
diff --git a/config/locales/devise.et.yml b/config/locales/devise.et.yml
new file mode 100644
index 000000000..cbad6ba07
--- /dev/null
+++ b/config/locales/devise.et.yml
@@ -0,0 +1,61 @@
+#Additional translations at https://github.com/plataformatec/devise/wiki/I18n
+et:
+ devise:
+ confirmations:
+ confirmed: "Your email address has been successfully confirmed."
+ send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes."
+ failure:
+ already_authenticated: "You are already signed in."
+ inactive: "Your account is not activated yet."
+ invalid: "Invalid %{authentication_keys}/password or account is not verified yet."
+ locked: "Your account is locked."
+ last_attempt: "You have one more attempt before your account is locked."
+ not_found_in_database: "Invalid %{authentication_keys} or password."
+ timeout: "Your session expired. Please sign in again to continue."
+ unauthenticated: "You need to sign in or sign up before continuing."
+ unconfirmed: "You have to confirm your email address before continuing."
+ mailer:
+ confirmation_instructions:
+ subject: "Confirmation Instructions"
+ reset_password_instructions:
+ subject: "Reset password instructions"
+ unlock_instructions:
+ subject: "Unlock instructions"
+ password_change:
+ subject: "Password Changed"
+ omniauth_callbacks:
+ failure: "Could not authenticate you from %{kind} because \"%{reason}\"."
+ success: "Successfully authenticated from %{kind} account."
+ passwords:
+ no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided."
+ send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes."
+ updated: "Your password has been changed successfully. You are now signed in."
+ updated_not_active: "Your password has been changed successfully."
+ registrations:
+ destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon."
+ signed_up: "Welcome! You have signed up successfully."
+ signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated."
+ signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked."
+ signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account."
+ update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirm link to confirm your new email address."
+ updated: "Your account has been updated successfully."
+ sessions:
+ signed_in: "Signed in successfully."
+ signed_out: "Signed out successfully."
+ already_signed_out: "Signed out successfully."
+ unlocks:
+ send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes."
+ send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes."
+ unlocked: "Your account has been unlocked successfully. Please sign in to continue."
+ errors:
+ messages:
+ already_confirmed: "was already confirmed, please try signing in"
+ confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one"
+ expired: "has expired, please request a new one"
+ not_found: "not found"
+ not_locked: "was not locked"
+ not_saved:
+ one: "1 error prohibited this %{resource} from being saved:"
+ other: "%{count} errors prohibited this %{resource} from being saved:"
diff --git a/config/locales/el.yml b/config/locales/el.yml
index 034462107..22fce73b7 100644
--- a/config/locales/el.yml
+++ b/config/locales/el.yml
@@ -62,6 +62,9 @@ el:
invalid: Ακατάλληλο email
phone_number:
invalid: πρέπει να είναι σε μορφή e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: πρέπει να είναι μοναδικό στην κατηγορία και την πύλη
@@ -86,7 +89,7 @@ el:
validations:
name: δεν πρέπει να ξεκινά ή να τελειώνει με σύμβολα, και δεν πρέπει να περιέχει τους χαρακτήρες < > / \ @
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ el:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Η συνομιλία έχει επιλυθεί από τον %{user_name}'
contact_resolved: 'Η συνομιλία επιλύθηκε από τον %{contact_name}'
@@ -322,6 +327,8 @@ el:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Αναζήτηση άρθρου με τίτλο ή περιεχόμενο...
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 02d8e9681..c001d30f1 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -76,6 +76,9 @@ en:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -100,7 +103,7 @@ en:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -199,6 +202,8 @@ en:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -340,6 +345,8 @@ en:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 6f81f1d67..cfe468dee 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -62,6 +62,9 @@ es:
invalid: Email inválido
phone_number:
invalid: debe estar en formato e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: debe ser único en la categoría y el portal
@@ -86,7 +89,7 @@ es:
validations:
name: no debe comenzar ni terminar con símbolos, y no debe tener caracteres < > / \ @.
custom_filters:
- number_of_records: Límite alcanzado. El número máximo de filtros personalizados permitidos para un usuario por cuenta es de 50.
+ number_of_records: Límite alcanzado. El número máximo de filtros personalizados permitidos para un usuario por cuenta es de 1000.
invalid_attribute: Clave de atributo no válida - [%{key}]. La clave debe ser una de [%{allowed_keys}] o un atributo personalizado definido en la cuenta.
invalid_operator: Operador no válido. Los operadores permitidos para %{attribute_name} son [%{allowed_keys}].
invalid_query_operator: El operador de consulta debe ser "Y" o "O".
@@ -185,6 +188,8 @@ es:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'La conversación fue marcada por %{user_name}'
contact_resolved: 'Conversación fue resuelta por %{contact_name}'
@@ -322,6 +327,8 @@ es:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Buscar artículo por título o cuerpo...
diff --git a/config/locales/et.yml b/config/locales/et.yml
new file mode 100644
index 000000000..dcf81c6e3
--- /dev/null
+++ b/config/locales/et.yml
@@ -0,0 +1,422 @@
+#Files in the config/locales directory are used for internationalization
+#and are automatically loaded by Rails. If you want to use locales other
+#than English, add the necessary files in this directory.
+#To use the locales, use `I18n.t`:
+#I18n.t 'hello'
+#In views, this is aliased to just `t`:
+#<%= t('hello') %>
+#To use a different locale, set it with `I18n.locale`:
+#I18n.locale = :es
+#This would use the information in config/locales/es.yml.
+#The following keys must be escaped otherwise they will not be retrieved by
+#the default I18n backend:
+#true, false, on, off, yes, no
+#Instead, surround them with single quotes.
+#en:
+#'true': 'foo'
+#To learn more, please read the Rails Internationalization guide
+#available at https://guides.rubyonrails.org/i18n.html.
+et:
+ hello: 'Hello world'
+ inbox:
+ reauthorization:
+ success: 'Channel reauthorized successfully'
+ not_required: 'Reauthorization is not required for this inbox'
+ invalid_channel: 'Invalid channel type for reauthorization'
+ auth:
+ saml:
+ invalid_email: 'Please enter a valid email address'
+ authentication_failed: 'Authentication failed. Please check your credentials and try again.'
+ messages:
+ reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
+ reset_password_failure: Uh ho! We could not find any user with the specified email.
+ reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
+ login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
+ saml_not_available: SAML authentication is not available in this installation.
+ inbox_deletetion_response: Your inbox deletion request will be processed in some time.
+ errors:
+ validations:
+ presence: must not be blank
+ webhook:
+ invalid: Invalid events
+ signup:
+ disposable_email: We do not allow disposable emails
+ blocked_domain: This domain is not allowed. If you believe this is a mistake, please contact support.
+ invalid_email: You have entered an invalid email
+ email_already_exists: 'You have already signed up for an account with %{email}'
+ invalid_params: 'Invalid, please check the signup paramters and try again'
+ failed: Signup failed
+ assignment_policy:
+ not_found: Assignment policy not found
+ saml:
+ feature_not_enabled: SAML feature not enabled for this account
+ data_import:
+ data_type:
+ invalid: Invalid data type
+ contacts:
+ import:
+ failed: File is blank
+ export:
+ success: We will notify you once contacts export file is ready to view.
+ email:
+ invalid: Invalid email
+ phone_number:
+ invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
+ categories:
+ locale:
+ unique: should be unique in the category and portal
+ dyte:
+ invalid_message_type: 'Invalid message type. Action not permitted'
+ slack:
+ invalid_channel_id: 'Invalid slack channel. Please try again'
+ whatsapp:
+ token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
+ invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
+ phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ reauthorization:
+ generic: 'Failed to reauthorize WhatsApp. Please try again.'
+ not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
+ inboxes:
+ imap:
+ socket_error: Please check the network connection, IMAP address and try again.
+ no_response_error: Please check the IMAP credentials and try again.
+ host_unreachable_error: Host unreachable, Please check the IMAP address, IMAP port and try again.
+ connection_timed_out_error: Connection timed out for %{address}:%{port}
+ connection_closed_error: Connection closed.
+ validations:
+ name: should not start or end with symbols, and it should not have < > / \ @ characters.
+ custom_filters:
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
+ invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
+ invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
+ invalid_query_operator: Query operator must be either "AND" or "OR".
+ invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
+ mfa:
+ already_enabled: MFA is already enabled
+ not_enabled: MFA is not enabled
+ invalid_code: Invalid verification code
+ invalid_backup_code: Invalid backup code
+ invalid_token: Invalid or expired MFA token
+ invalid_credentials: Invalid credentials or verification code
+ feature_unavailable: MFA feature is not available. Please configure encryption keys.
+ profile:
+ mfa:
+ enabled: MFA enabled successfully
+ disabled: MFA disabled successfully
+ account_saml_settings:
+ invalid_certificate: must be a valid X.509 certificate in PEM format
+ reports:
+ period: Reporting period %{since} to %{until}
+ utc_warning: The report generated is in UTC timezone
+ agent_csv:
+ agent_name: Agent name
+ conversations_count: Assigned conversations
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ resolution_count: Resolution Count
+ avg_customer_waiting_time: Avg customer waiting time
+ inbox_csv:
+ inbox_name: Inbox name
+ inbox_type: Inbox type
+ conversations_count: No. of conversations
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ label_csv:
+ label_title: Label
+ conversations_count: No. of conversations
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Resolution Count
+ team_csv:
+ team_name: Team name
+ conversations_count: Conversations count
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ resolution_count: Resolution Count
+ avg_customer_waiting_time: Avg customer waiting time
+ conversation_traffic_csv:
+ timezone: Timezone
+ sla_csv:
+ conversation_id: Conversation ID
+ sla_policy_breached: SLA Policy
+ assignee: Assignee
+ team: Team
+ inbox: Inbox
+ labels: Labels
+ conversation_link: Link to the Conversation
+ breached_events: Breached Events
+ default_group_by: day
+ csat:
+ headers:
+ contact_name: Contact Name
+ contact_email_address: Contact Email Address
+ contact_phone_number: Contact Phone Number
+ link_to_the_conversation: Link to the conversation
+ agent_name: Agent Name
+ rating: Rating
+ feedback: Feedback Comment
+ recorded_at: Recorded date
+ notifications:
+ notification_title:
+ conversation_creation: 'A conversation (#%{display_id}) has been created in %{inbox_name}'
+ conversation_assignment: 'A conversation (#%{display_id}) has been assigned to you'
+ assigned_conversation_new_message: 'A new message is created in conversation (#%{display_id})'
+ conversation_mention: 'You have been mentioned in conversation (#%{display_id})'
+ sla_missed_first_response: 'SLA target first response missed for conversation (#%{display_id})'
+ sla_missed_next_response: 'SLA target next response missed for conversation (#%{display_id})'
+ sla_missed_resolution: 'SLA target resolution missed for conversation (#%{display_id})'
+ attachment: 'Attachment'
+ no_content: 'No content'
+ conversations:
+ captain:
+ handoff: 'Transferring to another agent for further assistance.'
+ messages:
+ instagram_story_content: '%{story_sender} mentioned you in the story: '
+ instagram_deleted_story_content: This story is no longer available.
+ deleted: This message was deleted
+ whatsapp:
+ list_button_label: 'Choose an item'
+ delivery_status:
+ error_code: 'Error code: %{error_code}'
+ activity:
+ captain:
+ resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
+ open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
+ status:
+ resolved: 'Conversation was marked resolved by %{user_name}'
+ contact_resolved: 'Conversation was resolved by %{contact_name}'
+ open: 'Conversation was reopened by %{user_name}'
+ pending: 'Conversation was marked as pending by %{user_name}'
+ snoozed: 'Conversation was snoozed by %{user_name}'
+ auto_resolved_days: 'Conversation was marked resolved by system due to %{count} days of inactivity'
+ auto_resolved_hours: 'Conversation was marked resolved by system due to %{count} hours of inactivity'
+ auto_resolved_minutes: 'Conversation was marked resolved by system due to %{count} minutes of inactivity'
+ system_auto_open: System reopened the conversation due to a new incoming message.
+ priority:
+ added: '%{user_name} set the priority to %{new_priority}'
+ updated: '%{user_name} changed the priority from %{old_priority} to %{new_priority}'
+ removed: '%{user_name} removed the priority'
+ assignee:
+ self_assigned: '%{user_name} self-assigned this conversation'
+ assigned: 'Assigned to %{assignee_name} by %{user_name}'
+ removed: 'Conversation unassigned by %{user_name}'
+ team:
+ assigned: 'Assigned to %{team_name} by %{user_name}'
+ assigned_with_assignee: 'Assigned to %{assignee_name} via %{team_name} by %{user_name}'
+ removed: 'Unassigned from %{team_name} by %{user_name}'
+ labels:
+ added: '%{user_name} added %{labels}'
+ removed: '%{user_name} removed %{labels}'
+ sla:
+ added: '%{user_name} added SLA policy %{sla_name}'
+ removed: '%{user_name} removed SLA policy %{sla_name}'
+ linear:
+ issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
+ issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
+ issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
+ csat:
+ not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
+ auto_resolve:
+ not_sent_due_to_messaging_window: 'Auto-resolve message not sent due to outgoing message restrictions'
+ muted: '%{user_name} has muted the conversation'
+ unmuted: '%{user_name} has unmuted the conversation'
+ auto_resolution_message: 'Resolving the conversation as it has been inactive for a while. Please start a new conversation if you need further assistance.'
+ templates:
+ greeting_message_body: '%{account_name} typically replies in a few hours.'
+ ways_to_reach_you_message_body: 'Give the team a way to reach you.'
+ email_input_box_message_body: 'Get notified by email'
+ csat_input_message_body: 'Please rate the conversation'
+ reply:
+ email:
+ header:
+ notifications: 'Notifications'
+ from_with_name: '%{assignee_name} from %{inbox_name} <%{from_email}>'
+ reply_with_name: '%{assignee_name} from %{inbox_name} '
+ friendly_name: '%{sender_name} from %{business_name} <%{from_email}>'
+ professional_name: '%{business_name} <%{from_email}>'
+ channel_email:
+ header:
+ reply_with_name: '%{assignee_name} from %{inbox_name} <%{from_email}>'
+ reply_with_inbox_name: '%{inbox_name} <%{from_email}>'
+ email_subject: 'New messages on this conversation'
+ transcript_subject: 'Conversation Transcript'
+ survey:
+ response: 'Please rate this conversation, %{link}'
+ contacts:
+ online:
+ delete: '%{contact_name} is Online, please try again later'
+ integration_apps:
+ #Note: webhooks and dashboard_apps don't need short_description as they use different modal components
+ 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.'
+ 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.'
+ meeting_name: '%{agent_name} has started a meeting'
+ 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."
+ 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.'
+ 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.'
+ 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."
+ 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.'
+ 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.'
+ notion:
+ name: 'Notion'
+ short_description: 'Integrate databases, documents and pages directly with Captain.'
+ description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.'
+ 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.'
+ leadsquared:
+ name: 'LeadSquared'
+ short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
+ description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
+ captain:
+ copilot_message_required: Message is required
+ copilot_error: 'Please connect an assistant to this inbox to use Copilot'
+ copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
+ copilot:
+ using_tool: 'Using tool %{function_name}'
+ completed_tool_call: 'Completed %{function_name} tool call'
+ invalid_tool_call: 'Invalid tool call'
+ tool_not_available: 'Tool not available'
+ documents:
+ limit_exceeded: 'Document limit exceeded'
+ pdf_format_error: 'must be a PDF file'
+ pdf_size_error: 'must be less than 10MB'
+ pdf_upload_failed: 'Failed to upload PDF to OpenAI'
+ pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
+ pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
+ pdf_processing_success: 'Successfully processed PDF document %{document_id}'
+ faq_generation_complete: 'FAQ generation complete. Total FAQs created: %{count}'
+ using_paginated_faq: 'Using paginated FAQ generation for document %{document_id}'
+ using_standard_faq: 'Using standard FAQ generation for document %{document_id}'
+ response_creation_error: 'Error in creating response document: %{error}'
+ missing_openai_file_id: 'Document must have openai_file_id for paginated processing'
+ openai_api_error: 'OpenAI API Error: %{error}'
+ starting_paginated_faq: 'Starting paginated FAQ generation (%{pages_per_chunk} pages per chunk)'
+ stopping_faq_generation: 'Stopping processing. Reason: %{reason}'
+ paginated_faq_complete: 'Paginated generation complete. Total FAQs: %{total_faqs}, Pages processed: %{pages_processed}'
+ processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
+ chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
+ page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
+ public_portal:
+ search:
+ search_placeholder: Search for article by title or body...
+ empty_placeholder: No results found.
+ loading_placeholder: Searching...
+ results_title: Search results
+ toc_header: 'On this page'
+ hero:
+ sub_title: Search for the articles here or browse the categories below.
+ common:
+ home: Home
+ last_updated_on: Last updated on %{last_updated_on}
+ view_all_articles: View all
+ article: article
+ articles: articles
+ author: author
+ authors: authors
+ other: other
+ others: others
+ by: By
+ no_articles: There are no articles here
+ footer:
+ made_with: Made with
+ header:
+ go_to_homepage: Website
+ visit_website: Visit website
+ appearance:
+ system: System
+ light: Light
+ dark: Dark
+ featured_articles: Featured Articles
+ uncategorized: Uncategorized
+ 404:
+ title: Page not found
+ description: We couldn't find the page you were looking for.
+ back_to_home: Go to home page
+ slack_unfurl:
+ fields:
+ name: Name
+ email: Email
+ phone_number: Phone
+ company_name: Company
+ inbox_name: Inbox
+ inbox_type: Inbox Type
+ button: Open conversation
+ time_units:
+ days:
+ one: '%{count} day'
+ other: '%{count} days'
+ hours:
+ one: '%{count} hour'
+ other: '%{count} hours'
+ minutes:
+ one: '%{count} minute'
+ other: '%{count} minutes'
+ seconds:
+ one: '%{count} second'
+ other: '%{count} seconds'
+ automation:
+ system_name: 'Automation System'
+ crm:
+ no_message: 'No messages in conversation'
+ attachment: '[Attachment: %{type}]'
+ no_content: '[No content]'
+ created_activity: |
+ New conversation started on %{brand_name}
+
+ Channel: %{channel_info}
+ Created: %{formatted_creation_time}
+ Conversation ID: %{display_id}
+ View in %{brand_name}: %{url}
+ transcript_activity: |
+ Conversation Transcript from %{brand_name}
+
+ Channel: %{channel_info}
+ Conversation ID: %{display_id}
+ View in %{brand_name}: %{url}
+
+ Transcript:
+ %{format_messages}
+ agent_capacity_policy:
+ inbox_already_assigned: 'Inbox has already been assigned to this policy'
+ portals:
+ send_instructions:
+ email_required: 'Email is required'
+ invalid_email_format: 'Invalid email format'
+ custom_domain_not_configured: 'Custom domain is not configured'
+ instructions_sent_successfully: 'Instructions sent successfully'
+ subject: 'Finish setting up %{custom_domain}'
+ ssl_status:
+ custom_domain_not_configured: 'Custom domain is not configured'
diff --git a/config/locales/fa.yml b/config/locales/fa.yml
index 232396757..536c78f47 100644
--- a/config/locales/fa.yml
+++ b/config/locales/fa.yml
@@ -62,6 +62,9 @@ fa:
invalid: ایمیل نامعتبر است
phone_number:
invalid: باید در قالب e164 باشد
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: باید منحصر به فرد در دستهبندی و پورتال باشد
@@ -86,7 +89,7 @@ fa:
validations:
name: نباید با نمادها شروع یا ختم شود و نباید دارای کاراکترهای < > / \ @ باشد.
custom_filters:
- number_of_records: سررسید محدودیت. حداکثر تعداد قابل قبول فیلترها برای یک کاربر در هر اکانت 50 می باشند.
+ number_of_records: سررسید محدودیت. حداکثر تعداد قابل قبول فیلترها برای یک کاربر در هر اکانت 1000 می باشند.
invalid_attribute: کلید ویژگی معتبر نیست (%{key}). کلید باید یکی از %{allowed_keys} باشد یا یک ویژگی سفارشی ایجاد شده در حساب.
invalid_operator: این عملیات مجاز نیست. عملیات های مجاز برای %{attribute_name} شامل %{allowed_keys} می باشد.
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ fa:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'مکالمه توسط ایجنت %{user_name} حل شده، اعلام شده بود'
contact_resolved: 'گفتگو توسط %{contact_name} حل شد'
@@ -322,6 +327,8 @@ fa:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: جستجوی مقاله براساس عنوان یا متن...
diff --git a/config/locales/fi.yml b/config/locales/fi.yml
index 6e7db9ad7..7e8c3e14b 100644
--- a/config/locales/fi.yml
+++ b/config/locales/fi.yml
@@ -62,6 +62,9 @@ fi:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ fi:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ fi:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: '%{user_name} merkitsi keskustelun ratkaistuksi'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ fi:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index edab001cb..53435aafa 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -62,6 +62,9 @@ fr:
invalid: Email non valide
phone_number:
invalid: Doit être au format e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: Doit être unique dans la catégorie et le portail
@@ -86,7 +89,7 @@ fr:
validations:
name: 'ne doit pas commencer ou se terminer par des symboles, et ne doit pas comporter les caractères suivants : "< > / \ @".'
custom_filters:
- number_of_records: Limite atteinte. Le nombre maximum de filtres personnalisés autorisés pour un utilisateur par compte est de 50.
+ number_of_records: Limite atteinte. Le nombre maximum de filtres personnalisés autorisés pour un utilisateur par compte est de 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ fr:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'La conversation a été marquée résolue par %{user_name}'
contact_resolved: 'La conversation a été résolue par %{contact_name}'
@@ -322,6 +327,8 @@ fr:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Rechercher un article par titre ou contenu...
diff --git a/config/locales/he.yml b/config/locales/he.yml
index a2f309a3a..a8c729959 100644
--- a/config/locales/he.yml
+++ b/config/locales/he.yml
@@ -62,6 +62,9 @@ he:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ he:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ he:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'השיחה סומנה כפתורה על ידי %{user_name}'
contact_resolved: 'השיחה נפתרה על ידי %{contact_name}'
@@ -322,6 +327,8 @@ he:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/hi.yml b/config/locales/hi.yml
index 82f962f92..26b89c2e5 100644
--- a/config/locales/hi.yml
+++ b/config/locales/hi.yml
@@ -62,6 +62,9 @@ hi:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ hi:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ hi:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ hi:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/hr.yml b/config/locales/hr.yml
index 3828ebd40..4b4117eed 100644
--- a/config/locales/hr.yml
+++ b/config/locales/hr.yml
@@ -62,6 +62,9 @@ hr:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ hr:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ hr:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ hr:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/hu.yml b/config/locales/hu.yml
index 8ded57a47..87c496aa0 100644
--- a/config/locales/hu.yml
+++ b/config/locales/hu.yml
@@ -62,6 +62,9 @@ hu:
invalid: Hibás email
phone_number:
invalid: e164 formátumban kell megadni
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: egyedinek kell lennie a kategóriában a portálon
@@ -86,7 +89,7 @@ hu:
validations:
name: nem kezdődhet vagy végződhet szimbólummal, és nem tartalmazhat < > / \ @ karaktereket.
custom_filters:
- number_of_records: Limit túllépve. Maximum 50 speciális szűrőt használhat egy fiók.
+ number_of_records: Limit túllépve. Maximum 1000 speciális szűrőt használhat egy fiók.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ hu:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'A beszélgetést lezárta %{user_name}'
contact_resolved: 'A beszélgetést megoldottra állította: %{contact_name}'
@@ -322,6 +327,8 @@ hu:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Keress a bejegyzések címében és tartalmában...
diff --git a/config/locales/hy.yml b/config/locales/hy.yml
index 3119df585..063db291d 100644
--- a/config/locales/hy.yml
+++ b/config/locales/hy.yml
@@ -62,6 +62,9 @@ hy:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ hy:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ hy:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ hy:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/id.yml b/config/locales/id.yml
index ffe340f74..41383befc 100644
--- a/config/locales/id.yml
+++ b/config/locales/id.yml
@@ -62,6 +62,9 @@ id:
invalid: Email tidak valid
phone_number:
invalid: harus dalam format e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: harus unik dalam kategori dan portal
@@ -86,7 +89,7 @@ id:
validations:
name: tidak boleh dimulai atau diakhiri dengan simbol, dan tidak boleh memiliki karakter < > / \ @.
custom_filters:
- number_of_records: Batas tercapai. Jumlah maksimum filter ubahsuaian yang diizinkan untuk satu pengguna per akun adalah 50.
+ number_of_records: Batas tercapai. Jumlah maksimum filter ubahsuaian yang diizinkan untuk satu pengguna per akun adalah 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ id:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Percakapan ditandai selesai oleh %{user_name}'
contact_resolved: 'Percakapan diselesaikan oleh %{contact_name}'
@@ -322,6 +327,8 @@ id:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Telusuri artikel menurut judul atau isi...
diff --git a/config/locales/is.yml b/config/locales/is.yml
index c9b96fd74..7f671ac54 100644
--- a/config/locales/is.yml
+++ b/config/locales/is.yml
@@ -62,6 +62,9 @@ is:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: ætti að vera einstakt í flokki og gátt
@@ -86,7 +89,7 @@ is:
validations:
name: ætti ekki að byrja eða enda á táknum, og það ætti ekki að hafa < > / \ @ táknin.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ is:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Samtal var merkt sem leyst af %{user_name}'
contact_resolved: 'Samtal var leyst af %{contact_name}'
@@ -322,6 +327,8 @@ is:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/it.yml b/config/locales/it.yml
index 2d2720acd..a92071f9d 100644
--- a/config/locales/it.yml
+++ b/config/locales/it.yml
@@ -20,19 +20,19 @@ it:
hello: 'Ciao mondo'
inbox:
reauthorization:
- success: 'Channel reauthorized successfully'
- not_required: 'Reauthorization is not required for this inbox'
- invalid_channel: 'Invalid channel type for reauthorization'
+ success: 'Canale riautorizzato con successo'
+ not_required: 'Riautorizzazione non richiesta per questo canale'
+ invalid_channel: 'Canale non valido per riautorizzazione'
auth:
saml:
invalid_email: 'Inserisci un indirizzo email valido'
- authentication_failed: 'Authentication failed. Please check your credentials and try again.'
+ authentication_failed: 'Autenticazione fallita. Verifica le credenziali e riprova.'
messages:
reset_password_success: Woot! Richiesta di reimpostazione della password riuscita. Controlla la tua mail per le istruzioni.
reset_password_failure: Uh ho! Non siamo riusciti a trovare alcun utente con l'email specificata.
- reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
- login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
- saml_not_available: SAML authentication is not available in this installation.
+ reset_password_saml_user: Questo account utilizza autenticazione SAML. Non è possibile resettare la password. Contatta il tuo amministratore.
+ login_saml_user: Questo account utilizza autenticazione SAML. Effettua il login dal portale SAML della tua organizzazione.
+ saml_not_available: Autenticazione SAML non disponibile in questa installazione.
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
errors:
validations:
@@ -47,9 +47,9 @@ it:
invalid_params: 'Invalid, please check the signup paramters and try again'
failed: Registrazione non riuscita
assignment_policy:
- not_found: Assignment policy not found
+ not_found: Policy di assegnazione non trovata
saml:
- feature_not_enabled: SAML feature not enabled for this account
+ feature_not_enabled: Funzionalità SAML non attiva per questo account
data_import:
data_type:
invalid: Tipo di dato non valido
@@ -62,6 +62,9 @@ it:
invalid: Email non valida
phone_number:
invalid: dovrebbe essere nel formato e164
+ companies:
+ domain:
+ invalid: deve essere un dominio valido
categories:
locale:
unique: dovrebbe essere unico nella categoria e nel portale
@@ -70,12 +73,12 @@ it:
slack:
invalid_channel_id: 'Invalid slack channel. Please try again'
whatsapp:
- token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
- invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
- phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ token_exchange_failed: 'Impossibile scambiare il codice con il token di accesso. Riprova.'
+ invalid_token_permissions: 'Il token di accesso non dispone dei permessi richiesti per WhatsApp.'
+ phone_info_fetch_failed: 'Impossibile recuperare le informazioni sul numero di telefono. Riprova.'
reauthorization:
- generic: 'Failed to reauthorize WhatsApp. Please try again.'
- not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
+ generic: 'Impossibile reautorizzare WhatsApp. Riprova.'
+ not_supported: 'La riautorizzazione non è supportata per questo tipo di canale WhatsApp.'
inboxes:
imap:
socket_error: Controlla la connessione di rete, l'indirizzo IMAP e riprova.
@@ -86,7 +89,7 @@ it:
validations:
name: non dovrebbe iniziare o terminare con i simboli, e non dovrebbe avere < > / \ @ caratteri.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -94,19 +97,19 @@ it:
custom_attribute_definition:
key_conflict: The provided key is not allowed as it might conflict with default attributes.
mfa:
- already_enabled: MFA is already enabled
- not_enabled: MFA is not enabled
- invalid_code: Invalid verification code
- invalid_backup_code: Invalid backup code
- invalid_token: Invalid or expired MFA token
- invalid_credentials: Invalid credentials or verification code
- feature_unavailable: MFA feature is not available. Please configure encryption keys.
+ already_enabled: MFA già attiva
+ not_enabled: MFA non attiva
+ invalid_code: Codice di verifica non valido
+ invalid_backup_code: Codice di backup non valido
+ invalid_token: Token MFA non valido o scaduto
+ invalid_credentials: Credenziali o codice di verifica non validi
+ feature_unavailable: MFA non disponibile. Configura le chiavi di cifratura.
profile:
mfa:
- enabled: MFA enabled successfully
- disabled: MFA disabled successfully
+ enabled: MFA attivata con successo
+ disabled: MFA disattivata con successo
account_saml_settings:
- invalid_certificate: must be a valid X.509 certificate in PEM format
+ invalid_certificate: deve essere un certificato X.509 valido in formato PEM
reports:
period: Periodo di segnalazione da %{since} a %{until}
utc_warning: The report generated is in UTC timezone
@@ -172,7 +175,7 @@ it:
no_content: 'No content'
conversations:
captain:
- handoff: 'Trasferimento ad un altro agente per ulteriore assistenza.'
+ handoff: 'Trasferendo ad un altro operatore per ulteriore assistenza.'
messages:
instagram_story_content: '%{story_sender} ti ha menzionato nella storia: '
instagram_deleted_story_content: Questa storia non è più disponibile.
@@ -184,7 +187,9 @@ it:
activity:
captain:
resolved: 'La conversazione è stata segnata risolta da %{user_name} a causa di inattività'
- open: 'Conversation was marked open by %{user_name}'
+ open: 'La conversazione è stata riaperta da %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'La conversazione è stata riaperta dal sistema a causa di un errore con il Bot Agente.'
status:
resolved: 'La conversazione è stata contrassegnata come risolta da %{user_name}'
contact_resolved: 'La conversazione è stata risolta da %{contact_name}'
@@ -192,8 +197,8 @@ it:
pending: 'La conversazione è stata contrassegnata come in attesa da %{user_name}'
snoozed: 'La conversazione è stata posticipata da %{user_name}'
auto_resolved_days: 'La conversazione è stata contrassegnata come risolta dal sistema a causa di %{count} giorni d''inattività'
- auto_resolved_hours: 'Conversation was marked resolved by system due to %{count} hours of inactivity'
- auto_resolved_minutes: 'Conversation was marked resolved by system due to %{count} minutes of inactivity'
+ auto_resolved_hours: 'La conversazione è stata contrassegnata come risolta dal sistema a causa di %{count} ore d''inattività'
+ auto_resolved_minutes: 'La conversazione è stata contrassegnata come risolta dal sistema a causa di %{count} minuti d''inattività'
system_auto_open: System reopened the conversation due to a new incoming message.
priority:
added: '%{user_name} set the priority to %{new_priority}'
@@ -218,9 +223,9 @@ it:
issue_linked: 'Issue Linear %{issue_id} è stata collegata da %{user_name}'
issue_unlinked: 'Issue Linear %{issue_id} è stata scollegata da %{user_name}'
csat:
- not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
+ not_sent_due_to_messaging_window: 'Sondaggio CSAT non inviato a causa di restrizioni sui messaggi in uscita'
auto_resolve:
- not_sent_due_to_messaging_window: 'Auto-resolve message not sent due to outgoing message restrictions'
+ not_sent_due_to_messaging_window: 'Messaggio di auto-risoluzione non inviato a causa di restrizioni sui messaggi in uscita'
muted: '%{user_name} ha silenziato la conversazione'
unmuted: '%{user_name} ha riattivato l''audio della conversazione'
auto_resolution_message: 'Resolving the conversation as it has been inactive for a while. Please start a new conversation if you need further assistance.'
@@ -255,27 +260,27 @@ it:
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:
name: 'Dyte'
- short_description: 'Start video/voice calls with customers directly from Chatwoot.'
+ short_description: 'Avvia chiamate/videochiamate con i clienti direttamente da 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'
slack:
name: 'Slack'
- short_description: 'Receive notifications and respond to conversations directly in Slack.'
+ short_description: 'Ricevi notifiche e rispondi alle conversazioni direttamente 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."
webhooks:
name: 'Webhook'
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:
name: 'Dialogflow'
- short_description: 'Build chatbots to handle initial queries before transferring to agents.'
+ short_description: 'Configura chatbot per gestire le domande iniziali prima di trasferire agli operatori.'
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:
name: 'Google Translate'
- short_description: 'Automatically translate customer messages for agents.'
+ short_description: 'Traduci automaticamente i messaggi dei clienti per gli operatori.'
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:
name: 'OpenAI'
- short_description: 'AI-powered reply suggestions, summarization, and message enhancement.'
+ short_description: 'Suggerimenti di risposta, riassunti, e miglioramento dei messaggi tramite AI.'
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:
name: 'Linear'
@@ -287,41 +292,43 @@ it:
description: 'Collega il tuo spazio di lavoro Notion per consentire al Captain di accedere e generare risposte intelligenti utilizzando i contenuti dai tuoi database, documenti, e pagine per fornire più assistenza clienti contestuale.'
shopify:
name: 'Shopify'
- short_description: 'Access order details and customer data from your Shopify store.'
+ short_description: 'Integra ordini e clienti dal tuo store Shopify.'
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:
name: 'LeadSquared'
- short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
- description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
+ short_description: 'Sincronizza contatti e conversazioni con LeadSquared CRM.'
+ description: 'Sincronizza i contatti e conversazioni con il CRM LeadSquared. Questa integrazione crea automaticamente i lead in LeadSquared quando vengono aggiunti nuovi contatti e registra l''attività di conversazione per fornire al team di vendita un contesto completo.'
captain:
- copilot_message_required: Il messaggio è obbligatorio
+ copilot_message_required: Messaggio richiesto
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
copilot:
- using_tool: 'Using tool %{function_name}'
- completed_tool_call: 'Completed %{function_name} tool call'
+ using_tool: 'Utilizzando tool %{function_name}'
+ completed_tool_call: 'Tool %{function_name} utilizzato'
invalid_tool_call: 'Chiamata strumento non valida'
tool_not_available: 'Strumento non disponibile'
documents:
- limit_exceeded: 'Document limit exceeded'
- pdf_format_error: 'must be a PDF file'
- pdf_size_error: 'must be less than 10MB'
- pdf_upload_failed: 'Failed to upload PDF to OpenAI'
- pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
- pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
- pdf_processing_success: 'Successfully processed PDF document %{document_id}'
- faq_generation_complete: 'FAQ generation complete. Total FAQs created: %{count}'
- using_paginated_faq: 'Using paginated FAQ generation for document %{document_id}'
- using_standard_faq: 'Using standard FAQ generation for document %{document_id}'
- response_creation_error: 'Error in creating response document: %{error}'
- missing_openai_file_id: 'Document must have openai_file_id for paginated processing'
- openai_api_error: 'OpenAI API Error: %{error}'
- starting_paginated_faq: 'Starting paginated FAQ generation (%{pages_per_chunk} pages per chunk)'
- stopping_faq_generation: 'Stopping processing. Reason: %{reason}'
- paginated_faq_complete: 'Paginated generation complete. Total FAQs: %{total_faqs}, Pages processed: %{pages_processed}'
- processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
- chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
- page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ limit_exceeded: 'Raggiunto limite documenti'
+ pdf_format_error: 'deve essere un file PDF'
+ pdf_size_error: 'deve essere inferiore a 10MB'
+ pdf_upload_failed: 'Caricamento PDF a OpenAI fallito'
+ pdf_upload_success: 'PDF caricato con successo (file_id: %{file_id})'
+ pdf_processing_failed: 'Errore nel processare il documento PDF %{document_id}: %{error}'
+ pdf_processing_success: 'Documento PDF %{document_id} processato correttamente'
+ faq_generation_complete: 'Generazione FAQ completata. Totale FAQ generate: %{count}'
+ using_paginated_faq: 'Uso della generazione di FAQ paginate per il documento %{document_id}'
+ using_standard_faq: 'Uso della generazione di FAQ standard per il documento %{document_id}'
+ response_creation_error: 'Errore nella creazione del documento di risposta: %{error}'
+ missing_openai_file_id: 'Il documento deve avere openai_file_id per l''elaborazione paginata'
+ openai_api_error: 'Errore API OpenAI: %{error}'
+ starting_paginated_faq: 'Avvio della generazione di FAQ paginate (%{pages_per_chunk} pagine per chunk)'
+ stopping_faq_generation: 'Elaborazione interrotta. Motivo: %{reason}'
+ paginated_faq_complete: 'Generazione paginata completa. FAQ totali: %{total_faqs}, Pagine elaborate: %{pages_processed}'
+ processing_pages: 'Elaborazione pagine %{start}-%{end} (iterazione %{iteration})'
+ chunk_generated: 'Il chunk ha generato %{chunk_faqs} FAQ. Totale finora: %{total_faqs}'
+ page_processing_error: 'Errore nell''elaborazione delle pagine %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Impossibile generare slug univoco dopo 5 tentativi'
public_portal:
search:
search_placeholder: Search for article by title or body...
@@ -383,33 +390,33 @@ it:
automation:
system_name: 'Sistema di Automazione'
crm:
- no_message: 'No messages in conversation'
- attachment: '[Attachment: %{type}]'
+ no_message: 'Nessun messaggio nella conversazione'
+ attachment: '[Allegato: %{type}]'
no_content: '[No content]'
created_activity: |
- New conversation started on %{brand_name}
+ Nuova conversazione iniziata su %{brand_name}
- Channel: %{channel_info}
- Created: %{formatted_creation_time}
- Conversation ID: %{display_id}
- View in %{brand_name}: %{url}
+ Canale: %{channel_info}
+ Creato: %{formatted_creation_time}
+ ID Conversazione: %{display_id}
+ Visualizza in %{brand_name}: %{url}
transcript_activity: |
- Conversation Transcript from %{brand_name}
+ Trascrizione della conversazione da %{brand_name}
- Channel: %{channel_info}
- Conversation ID: %{display_id}
- View in %{brand_name}: %{url}
+ Canale: %{channel_info}
+ ID Conversazione: %{display_id}
+ Visualizza in %{brand_name}: %{url}
- Transcript:
+ Trascrizione:
%{format_messages}
agent_capacity_policy:
- inbox_already_assigned: 'Inbox has already been assigned to this policy'
+ inbox_already_assigned: 'La Inbox è già stata assegnata a questa policy'
portals:
send_instructions:
email_required: 'L''email è obbligatoria'
- invalid_email_format: 'Invalid email format'
- custom_domain_not_configured: 'Custom domain is not configured'
- instructions_sent_successfully: 'Instructions sent successfully'
- subject: 'Finish setting up %{custom_domain}'
+ invalid_email_format: 'Formato email non valido'
+ custom_domain_not_configured: 'Dominio personalizzato non configurato'
+ instructions_sent_successfully: 'Istruzioni inviate con successo'
+ subject: 'Termina la configurazione di %{custom_domain}'
ssl_status:
- custom_domain_not_configured: 'Custom domain is not configured'
+ custom_domain_not_configured: 'Dominio personalizzato non configurato'
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 5e44bda34..eb205d1f5 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -62,6 +62,9 @@ ja:
invalid: 無効なEメールです
phone_number:
invalid: e164形式である必要があります
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: カテゴリとポータルで一意である必要があります
@@ -86,7 +89,7 @@ ja:
validations:
name: 記号で開始または終了しないでください。< > / \ @ を使用しないでください。
custom_filters:
- number_of_records: 制限に達しました。1つのアカウントにつき、ユーザーごとに許可されるカスタムフィルターの最大数は 50 です。
+ number_of_records: 制限に達しました。1つのアカウントにつき、ユーザーごとに許可されるカスタムフィルターの最大数は 1000 です。
invalid_attribute: 無効な属性キー - [%{key}]。キーは[%{allowed_keys}]のいずれかである必要があります。または、アカウント内で定義されたカスタム属性でなければなりません。
invalid_operator: 無効な演算子です。%{attribute_name} に許可されている演算子は [%{allowed_keys}] です。
invalid_query_operator: クエリ演算子は "AND" または "OR" でなければなりません。
@@ -185,6 +188,8 @@ ja:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: '%{user_name} によって会話は解決済みになりました'
contact_resolved: '%{contact_name} によって会話が解決されました'
@@ -322,6 +327,8 @@ ja:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: タイトルまたは本文で記事を検索...
diff --git a/config/locales/ka.yml b/config/locales/ka.yml
index cb3d5637b..df7caec35 100644
--- a/config/locales/ka.yml
+++ b/config/locales/ka.yml
@@ -62,6 +62,9 @@ ka:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ ka:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ ka:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ ka:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index 2a655fcce..8200fe4cf 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -62,6 +62,9 @@ ko:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ ko:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ ko:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ ko:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: 게시물을 제목이나 내용으로 검색하세요...
diff --git a/config/locales/lt.yml b/config/locales/lt.yml
index 51018d3aa..4fca6066f 100644
--- a/config/locales/lt.yml
+++ b/config/locales/lt.yml
@@ -62,6 +62,9 @@ lt:
invalid: Neteisingas el. paštas
phone_number:
invalid: turėtų būti e164 formato
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: turėtų būti unikalūs kategorijoje ir portale
@@ -86,7 +89,7 @@ lt:
validations:
name: neturėtų prasidėti ar baigtis simboliais ir jame neturėtų būti simbolių < > / \ @.
custom_filters:
- number_of_records: Pasiekta riba. Didžiausias leistinas personalizuotų filtrų skaičius vienam vartotojui yra 50.
+ number_of_records: Pasiekta riba. Didžiausias leistinas personalizuotų filtrų skaičius vienam vartotojui yra 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ lt:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Pokalbį pažymėjo %{user_name} kaip baigtą'
contact_resolved: 'Pokalbį užbaigė %{contact_name}'
@@ -322,6 +327,8 @@ lt:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Ieškokite straipsnio pagal pavadinimą arba turinį...
diff --git a/config/locales/lv.yml b/config/locales/lv.yml
index 639194239..baac1bbfe 100644
--- a/config/locales/lv.yml
+++ b/config/locales/lv.yml
@@ -62,6 +62,9 @@ lv:
invalid: Nederīga e-pasta adrese
phone_number:
invalid: vajadzētu būt E.164 formātā
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: vajadzētu būt unikālai, kategorijā un portālā
@@ -86,7 +89,7 @@ lv:
validations:
name: nevajadzētu sākties vai beigties ar simboliem, un nevajadzētu saturēt <> / \ @ rakstzīmes.
custom_filters:
- number_of_records: Sasniegts limits. Maksimālais atļauto pielāgoto filtru skaits vienam lietotājam ir 50.
+ number_of_records: Sasniegts limits. Maksimālais atļauto pielāgoto filtru skaits vienam lietotājam ir 1000.
invalid_attribute: Nederīga atribūta atslēga - [%{key}]. Atslēgai ir jābūt vienai no [%{allowed_keys}] vai pielāgotam atribūtam, kas definēts kontā.
invalid_operator: Nederīgs operators. Atļautie operatori priekš %{attribute_name} ir [%{allowed_keys}].
invalid_query_operator: Vaicājuma operatoram ir jābūt "UN" vai "VAI".
@@ -185,6 +188,8 @@ lv:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: '%{user_name} sarunu atzīmēja kā atrisinātu'
contact_resolved: '%{contact_name} atrisināja sarunu'
@@ -322,6 +327,8 @@ lv:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Meklēt rakstu pēc nosaukuma vai pamatteksta...
diff --git a/config/locales/ml.yml b/config/locales/ml.yml
index 2f411ea56..ef716ae26 100644
--- a/config/locales/ml.yml
+++ b/config/locales/ml.yml
@@ -62,6 +62,9 @@ ml:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ ml:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ ml:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'സംഭാഷണം %{user_name} പരിഹരിച്ചതായി അടയാളപ്പെടുത്തി'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ ml:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/ms.yml b/config/locales/ms.yml
index 7a1902b8e..8dc3b396d 100644
--- a/config/locales/ms.yml
+++ b/config/locales/ms.yml
@@ -62,6 +62,9 @@ ms:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ ms:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ ms:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ ms:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/ne.yml b/config/locales/ne.yml
index 2690a7d47..6a1d0cdb0 100644
--- a/config/locales/ne.yml
+++ b/config/locales/ne.yml
@@ -62,6 +62,9 @@ ne:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ ne:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ ne:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ ne:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index 641516129..75d536a5c 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -62,6 +62,9 @@ nl:
invalid: Ongeldig email
phone_number:
invalid: moet in e164-formaat zijn
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: moet uniek zijn in de categorie en portal
@@ -86,7 +89,7 @@ nl:
validations:
name: mag niet beginnen of eindigen met symbolen, en mag geen < > / \ @ karakters hebben.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ nl:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Gesprek werd gemarkeerd door %{user_name}'
contact_resolved: 'Gesprek werd opgelost door %{contact_name}'
@@ -322,6 +327,8 @@ nl:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/no.yml b/config/locales/no.yml
index c1b736fb3..e45fe8b88 100644
--- a/config/locales/no.yml
+++ b/config/locales/no.yml
@@ -62,6 +62,9 @@
invalid: Ugyldig epost
phone_number:
invalid: skal være i e164-format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: må være unikt i kategorien og portalen
@@ -86,7 +89,7 @@
validations:
name: ikke kan starte eller slutte med symboler, og den kan ikke ha < > / \ @ tegn.
custom_filters:
- number_of_records: Grense nådd. Maksimalt antall tillatte filtre for en bruker per konto er 50.
+ number_of_records: Grense nådd. Maksimalt antall tillatte filtre for en bruker per konto er 1000.
invalid_attribute: Ugyldig attributtnøkkel - [%{key}]. Nøkkelen bør være en av [%{allowed_keys}] eller en egendefinert attributt definert på kontoen.
invalid_operator: Ugyldig operatør. De tillatte operatørene for %{attribute_name} er [%{allowed_keys}].
invalid_query_operator: Spørrings-operatør må være enten "AND" eller "OR".
@@ -185,6 +188,8 @@
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Samtale ble løst av %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/pl.yml b/config/locales/pl.yml
index c21d42d0f..863bd6671 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -62,6 +62,9 @@ pl:
invalid: Nieprawidłowy adres e-mail
phone_number:
invalid: powinno być w formacie e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: powinno być unikalne w kategorii i portalu
@@ -86,7 +89,7 @@ pl:
validations:
name: nie powinno zaczynać się ani kończyć symbolami i nie powinno zawierać znaków < > / \ @.
custom_filters:
- number_of_records: Osiągnięto limit. Maksymalna liczba dozwolonych filtrów niestandardowych dla użytkownika na konto wynosi 50.
+ number_of_records: Osiągnięto limit. Maksymalna liczba dozwolonych filtrów niestandardowych dla użytkownika na konto wynosi 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ pl:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Rozmowa została oznaczona przez %{user_name}'
contact_resolved: 'Rozmowa została rozwiązana przez %{contact_name}'
@@ -322,6 +327,8 @@ pl:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Wyszukaj artykuł według tytułu lub treści...
diff --git a/config/locales/pt.yml b/config/locales/pt.yml
index 8b78ee036..cc5f4b940 100644
--- a/config/locales/pt.yml
+++ b/config/locales/pt.yml
@@ -62,6 +62,9 @@ pt:
invalid: Email inválido
phone_number:
invalid: deve estar no formato e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: deve ser único na categoria e no portal
@@ -86,7 +89,7 @@ pt:
validations:
name: não deve iniciar ou terminar com símbolos, nem deve ter < > / \ @ caracteres.
custom_filters:
- number_of_records: Limite atingido. O número máximo de filtros personalizados permitidos para um utilizador por conta é de 50.
+ number_of_records: Limite atingido. O número máximo de filtros personalizados permitidos para um utilizador por conta é de 1000.
invalid_attribute: Chave de atributo inválida - [%{key}]. A chave deve ser uma das [%{allowed_keys}] ou um atributo personalizado definido na conta.
invalid_operator: Operador inválido. Os operadores permitidos para %{attribute_name} são [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ pt:
captain:
resolved: 'A conversa foi marcada como resolvida por %{user_name} devido à inatividade'
open: 'A conversa foi marcada como aberta por %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversa foi marcada como resolvida por %{user_name}'
contact_resolved: 'Conversa foi resolvida por %{contact_name}'
@@ -322,6 +327,8 @@ pt:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Pesquisar artigo por título ou corpo...
diff --git a/config/locales/pt_BR.yml b/config/locales/pt_BR.yml
index 80c4eb574..52a0b4226 100644
--- a/config/locales/pt_BR.yml
+++ b/config/locales/pt_BR.yml
@@ -62,6 +62,9 @@ pt_BR:
invalid: E-mail inválido
phone_number:
invalid: deve estar no formato e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: deve ser único na categoria e no portal
@@ -86,7 +89,7 @@ pt_BR:
validations:
name: 'não deve iniciar ou terminar com símbolos e não deve ter os caracteres: < > / \ @.'
custom_filters:
- number_of_records: Limite atingido. O número máximo de filtros personalizados permitidos para um usuário por conta é de 50.
+ number_of_records: Limite atingido. O número máximo de filtros personalizados permitidos para um usuário por conta é de 1000.
invalid_attribute: Chave de atributo inválido - [%{key}]. A chave deve ser uma das [%{allowed_keys}] ou um atributo personalizado definido na conta.
invalid_operator: Operador inválido. Os operadores permitidos para %{attribute_name} são [%{allowed_keys}].
invalid_query_operator: Operador de consulta deve ser "E" ou "OU".
@@ -185,6 +188,8 @@ pt_BR:
captain:
resolved: 'A conversa foi marcada como resolvida por %{user_name} por inatividade'
open: 'A conversa foi aberta por %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversa foi marcada como resolvida por %{user_name}'
contact_resolved: 'A conversa foi resolvida por %{contact_name}'
@@ -322,6 +327,8 @@ pt_BR:
processing_pages: 'Processando páginas %{start}-%{end} (iteração %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Erro ao processar as páginas %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Pesquisar por artigo por título ou corpo...
diff --git a/config/locales/ro.yml b/config/locales/ro.yml
index bdf8f6237..b998feb84 100644
--- a/config/locales/ro.yml
+++ b/config/locales/ro.yml
@@ -62,6 +62,9 @@ ro:
invalid: E-mail invalid
phone_number:
invalid: ar trebui să fie în format e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: ar trebui să fie unic în categorie și portal
@@ -86,7 +89,7 @@ ro:
validations:
name: nu ar trebui să înceapă sau să se termine cu simboluri și nu ar trebui să aibă < > / \ @ caractere.
custom_filters:
- number_of_records: Limita atinsă. Numărul maxim de filtre personalizate permise pentru un utilizator per cont este de 50.
+ number_of_records: Limita atinsă. Numărul maxim de filtre personalizate permise pentru un utilizator per cont este de 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ ro:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversația a fost marcată de %{user_name}'
contact_resolved: 'Conversația a fost rezolvată de %{contact_name}'
@@ -322,6 +327,8 @@ ro:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Căutați articol după titlu sau corp...
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index ee104a126..2484b30b4 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -20,19 +20,19 @@ ru:
hello: 'Привет мир'
inbox:
reauthorization:
- success: 'Channel reauthorized successfully'
- not_required: 'Reauthorization is not required for this inbox'
- invalid_channel: 'Invalid channel type for reauthorization'
+ success: 'Канал успешно повторно авторизирован'
+ not_required: 'Повторная авторизация не требуется для этого ящика'
+ invalid_channel: 'Неверный тип канала для повторной авторизации'
auth:
saml:
invalid_email: 'Пожалуйста, введите действительный адрес электронной почты'
- authentication_failed: 'Authentication failed. Please check your credentials and try again.'
+ authentication_failed: 'Ошибка аутентификации. Пожалуйста, проверьте ваши учетные данные и повторите попытку.'
messages:
reset_password_success: Круто! Запрос на сброс пароля удался. Проверьте почту для получения инструкций.
reset_password_failure: Ой! Мы не смогли найти пользователя с указанным email.
- reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
- login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
- saml_not_available: SAML authentication is not available in this installation.
+ reset_password_saml_user: Эта учетная запись использует SAML-аутентификацию. Сброс пароля недоступен. Пожалуйста, обратитесь к администратору.
+ login_saml_user: Эта учетная запись использует SAML аутентификацию. Пожалуйста, войдите через SAML провайдера вашей организации.
+ saml_not_available: SAML аутентификация не доступна в этой установке.
inbox_deletetion_response: Ваш запрос на удаление входящих сообщений будет обработан через некоторое время.
errors:
validations:
@@ -47,9 +47,9 @@ ru:
invalid_params: 'Неверно, проверьте параметры регистрации и повторите попытку'
failed: Ошибка регистрации
assignment_policy:
- not_found: Assignment policy not found
+ not_found: Политика назначения не найдена
saml:
- feature_not_enabled: SAML feature not enabled for this account
+ feature_not_enabled: Функция SAML не включена для этой учетной записи
data_import:
data_type:
invalid: Недопустимый тип данных
@@ -62,6 +62,9 @@ ru:
invalid: Неверный email
phone_number:
invalid: должен иметь формат e164
+ companies:
+ domain:
+ invalid: должно быть корректным доменным именем
categories:
locale:
unique: Должны быть уникальными в категории и портале
@@ -70,12 +73,12 @@ ru:
slack:
invalid_channel_id: 'Неправильный канал slack - попробуйте еще раз'
whatsapp:
- token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
- invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
- phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
+ token_exchange_failed: 'Не удалось обменять код на токена доступа. Пожалуйста, попробуйте еще раз.'
+ invalid_token_permissions: 'Токен доступа не имеет необходимых прав для WhatsApp.'
+ phone_info_fetch_failed: 'Не удалось получить информацию о номере телефона. Пожалуйста, попробуйте еще раз.'
reauthorization:
- generic: 'Failed to reauthorize WhatsApp. Please try again.'
- not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
+ generic: 'Не удалось повторно авторизовать WhatsApp. Пожалуйста, попробуйте еще раз.'
+ not_supported: 'Повторная авторизация не поддерживается для данного типа канала WhatsApp.'
inboxes:
imap:
socket_error: Пожалуйста, проверьте сетевое подключение, адрес IMAP и повторите попытку.
@@ -86,7 +89,7 @@ ru:
validations:
name: Не должен начинаться или заканчиваться символами, и у него Не должно быть < > / \ @ символов.
custom_filters:
- number_of_records: Достигнут лимит. Максимальное количество разрешенных пользовательских фильтров для каждого пользователя - 50.
+ number_of_records: Достигнут лимит. Максимальное количество разрешенных пользовательских фильтров для каждого пользователя - 1000.
invalid_attribute: Недопустимый ключ атрибута - [%{key}]. Ключ должен быть одним из [%{allowed_keys}] или пользовательским атрибутом, указанным в учетной записи.
invalid_operator: Неверный оператор. Допустимыми операторами для %{attribute_name} являются [%{allowed_keys}].
invalid_query_operator: Оператор запроса должен быть "AND" или "OR".
@@ -94,19 +97,19 @@ ru:
custom_attribute_definition:
key_conflict: Предоставленный ключ не разрешён, так как он может конфликтовать со стандартными атрибутами.
mfa:
- already_enabled: MFA is already enabled
- not_enabled: MFA is not enabled
- invalid_code: Invalid verification code
- invalid_backup_code: Invalid backup code
- invalid_token: Invalid or expired MFA token
- invalid_credentials: Invalid credentials or verification code
- feature_unavailable: MFA feature is not available. Please configure encryption keys.
+ already_enabled: MFA уже включен
+ not_enabled: MFA не включена
+ invalid_code: Неверный код подтверждения
+ invalid_backup_code: Неверный резервный код
+ invalid_token: Недопустимый или просроченный MFA токен
+ invalid_credentials: Неверные учетные данные или проверочный код
+ feature_unavailable: Функция MFA недоступна. Пожалуйста, настройте ключи шифрования.
profile:
mfa:
- enabled: MFA enabled successfully
- disabled: MFA disabled successfully
+ enabled: MFA успешно включен
+ disabled: MFA успешно отключен
account_saml_settings:
- invalid_certificate: must be a valid X.509 certificate in PEM format
+ invalid_certificate: должен быть действительным сертификатом X.509 в формате PEM
reports:
period: Отчётный период с %{since} по %{until}
utc_warning: Отчёт создан в часовом поясе UTC
@@ -128,7 +131,7 @@ ru:
conversations_count: Количество диалогов
avg_first_response_time: Среднее время первого ответа
avg_resolution_time: Среднее время завершения
- avg_reply_time: Avg reply time
+ avg_reply_time: Среднее время ответа
resolution_count: Количество завершенных
team_csv:
team_name: Название команды
@@ -172,19 +175,21 @@ ru:
no_content: 'Нет содержимого'
conversations:
captain:
- handoff: 'Transferring to another agent for further assistance.'
+ handoff: 'Передача другому агенту для дальнейшей помощи.'
messages:
instagram_story_content: '%{story_sender} упомянул Вас в истории: '
instagram_deleted_story_content: Эта история больше недоступна.
deleted: Это сообщение было удалено
whatsapp:
- list_button_label: 'Choose an item'
+ list_button_label: 'Выберите элемент'
delivery_status:
error_code: 'Код ошибки: %{error_code}'
activity:
captain:
- resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
- open: 'Conversation was marked open by %{user_name}'
+ resolved: 'Диалог был решен %{user_name} из-за бездействия'
+ open: 'Диалог был открыт %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Диалог был открыт системой из-за ошибки бота агента.'
status:
resolved: '%{user_name} завершил диалог'
contact_resolved: 'Разговор был закрыт %{contact_name}'
@@ -192,8 +197,8 @@ ru:
pending: 'Разговор был помечен как ожидающий %{user_name}'
snoozed: 'Разговор был помечен как отложенный %{user_name}'
auto_resolved_days: 'Разговор был помечен системой решённым из-за неактивности в течение %{count} дней'
- auto_resolved_hours: 'Conversation was marked resolved by system due to %{count} hours of inactivity'
- auto_resolved_minutes: 'Conversation was marked resolved by system due to %{count} minutes of inactivity'
+ auto_resolved_hours: 'Диалог был решен системой из-за %{count} часов бездействия'
+ auto_resolved_minutes: 'Диалог был решён системой из-за %{count} минут бездействия'
system_auto_open: Система переоткрыла разговор из-за нового входящего сообщения.
priority:
added: '%{user_name} установил приоритет на %{new_priority}'
@@ -214,11 +219,11 @@ ru:
added: '%{user_name} добавил политику SLA %{sla_name}'
removed: '%{user_name} удалил политику SLA %{sla_name}'
linear:
- issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
- issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
- issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
+ issue_created: 'Линейная задача %{issue_id} была создана %{user_name}'
+ issue_linked: 'Линейная задача %{issue_id} была связана с %{user_name}'
+ issue_unlinked: 'Линейная задача %{issue_id} была отвязана от %{user_name}'
csat:
- not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
+ not_sent_due_to_messaging_window: 'Опрос CSAT не отправлен из-за ограничений исходящих сообщений'
auto_resolve:
not_sent_due_to_messaging_window: 'Сообщение автозавершения не отправлено из-за ограничений исходящих сообщений'
muted: '%{user_name} заглушил(а) этот разговор'
@@ -255,73 +260,75 @@ ru:
description: 'Панель приложений позволяет вам создавать и вставлять приложения, отображающие информацию о пользователе, заказы или историю платежей, обеспечивая больший контекст для агентов поддержки.'
dyte:
name: 'Dyte'
- short_description: 'Start video/voice calls with customers directly from Chatwoot.'
+ short_description: 'Начните видео/голосовые звонки с клиентов прямо из Chatwoot.'
description: 'Dyte - это продукт, который интегрирует функции аудио и видео в ваше приложение. С помощью этой интеграции ваши агенты могут начать видео/голосовые звонки с вашими клиентами прямо из Chatwoot.'
meeting_name: '%{agent_name} приступил к встрече'
slack:
name: 'Slack'
- short_description: 'Receive notifications and respond to conversations directly in Slack.'
+ short_description: 'Получать уведомления и отвечать на разговоры прямо в Slack.'
description: "Интегрируйте Chatwoot с Slack для синхронизации команды. Эта интеграция позволяет получать уведомления о новых разговорах и отвечать на них непосредственно в интерфейсе Slack."
webhooks:
name: 'Webhooks'
description: 'События Webhook предоставляют обновления об активности в вашем аккаунте Chatwoot в режиме реального времени. Вы можете подписаться на ваши предпочтительные события, и Chatwoot будет отправлять вам HTTP-ответы с обновлениями.'
dialogflow:
name: 'Диалог'
- short_description: 'Build chatbots to handle initial queries before transferring to agents.'
+ short_description: 'Постройте чат ботов для обработки начальных запросов перед передачей агентам.'
description: 'Создайте чатботов с помощью Dialogflow и легко интегрируйте их в ваш источник. Эти боты могут обрабатывать начальные запросы, прежде чем передавать их агенту поддержки.'
google_translate:
name: 'Google Перевод'
- short_description: 'Automatically translate customer messages for agents.'
+ short_description: 'Автоматически переводить сообщения клиентов для агентов.'
description: "Интегрируйте Google Translate, чтобы помочь агентам легко переводить сообщения клиентов. Эта интеграция автоматически определяет язык и преобразует его в язык, предпочтительный для агента или администратора."
openai:
name: 'OpenAI'
- short_description: 'AI-powered reply suggestions, summarization, and message enhancement.'
+ short_description: 'Предложение ответов, краткое изложение и повышение эффективности сообщений с помощью ИИ.'
description: 'Используйте LLM OpenAI с такими функциями, как предложение ответов, резюмирование, перефразирование сообщений, проверка орфографии и подстановка категорий.'
linear:
name: 'Linear'
- short_description: 'Create and link Linear issues directly from conversations.'
+ short_description: 'Создать и связать Линейные задачи непосредственно из диалогов.'
description: 'Создавайте или прикрепляйте уже существующие задачи в Linear непосредственно из окна диалога для более упорядоченного и эффективного процесса отслеживания проблем.'
notion:
name: 'Notion'
- short_description: 'Integrate databases, documents and pages directly with Captain.'
- description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.'
+ short_description: 'Интеграция баз данных, документов и страниц напрямую с Captain.'
+ description: 'Подключите ваше рабочее пространство Notion, чтобы включить Captain для доступа к Ии ответам, используя содержимое из вашей базы данных, документов и страниц, чтобы обеспечить более точную поддержку клиентов.'
shopify:
name: 'Shopify'
- short_description: 'Access order details and customer data from your Shopify store.'
+ short_description: 'Доступ к информации о заказе и данным о клиентах из магазина Shopify.'
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:
name: 'LeadSquared'
- short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
- description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
+ short_description: 'Синхронизация контактов и диалогов с LeadSquared CRM.'
+ description: 'Синхронизация контактов и диалогов с LeadSquared CRM. Эта интеграция автоматически создает лиды в LeadSquared при добавлении новых контактов, и ведет разговор активности, чтобы обеспечить вашу команду продаж полным контекстом.'
captain:
copilot_message_required: Необходимо ввести сообщение
copilot_error: 'Пожалуйста, подключите ассистента к этому источнику входящих для использования Copilot'
copilot_limit: 'У вас закончились кредиты для Copilot. Вы можете купить дополнительные кредиты в разделе биллинга.'
copilot:
- using_tool: 'Using tool %{function_name}'
- completed_tool_call: 'Completed %{function_name} tool call'
- invalid_tool_call: 'Invalid tool call'
- tool_not_available: 'Tool not available'
+ using_tool: 'Использование инструмента %{function_name}'
+ completed_tool_call: 'Вызов инструмента %{function_name}'
+ invalid_tool_call: 'Неверный вызов инструмента'
+ tool_not_available: 'Инструмент недоступен'
documents:
- limit_exceeded: 'Document limit exceeded'
- pdf_format_error: 'must be a PDF file'
- pdf_size_error: 'must be less than 10MB'
- pdf_upload_failed: 'Failed to upload PDF to OpenAI'
- pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
- pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
- pdf_processing_success: 'Successfully processed PDF document %{document_id}'
- faq_generation_complete: 'FAQ generation complete. Total FAQs created: %{count}'
- using_paginated_faq: 'Using paginated FAQ generation for document %{document_id}'
- using_standard_faq: 'Using standard FAQ generation for document %{document_id}'
- response_creation_error: 'Error in creating response document: %{error}'
- missing_openai_file_id: 'Document must have openai_file_id for paginated processing'
- openai_api_error: 'OpenAI API Error: %{error}'
- starting_paginated_faq: 'Starting paginated FAQ generation (%{pages_per_chunk} pages per chunk)'
- stopping_faq_generation: 'Stopping processing. Reason: %{reason}'
- paginated_faq_complete: 'Paginated generation complete. Total FAQs: %{total_faqs}, Pages processed: %{pages_processed}'
- processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
- chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
- page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ limit_exceeded: 'Превышен лимит документов'
+ pdf_format_error: 'должен быть PDF файлом'
+ pdf_size_error: 'должно быть меньше 10МБ'
+ pdf_upload_failed: 'Не удалось загрузить PDF в OpenAI'
+ pdf_upload_success: 'PDF успешно загружен с file_id: %{file_id}'
+ pdf_processing_failed: 'Не удалось обработать PDF документ %{document_id}: %{error}'
+ pdf_processing_success: 'Документ PDF %{document_id} успешно обработан'
+ faq_generation_complete: 'Генерация FAQ завершено. Всего FAQ: %{count}'
+ using_paginated_faq: 'Использование нумерованной генерации FAQ для документа %{document_id}'
+ using_standard_faq: 'Использование стандартной генерации FAQ для документа %{document_id}'
+ response_creation_error: 'Ошибка при создании документа: %{error}'
+ missing_openai_file_id: 'Документ должен иметь openai_file_id для обработки страницы'
+ openai_api_error: 'Ошибка OpenAI API: %{error}'
+ starting_paginated_faq: 'Запуск постраничной генерации FAQ (%{pages_per_chunk} страниц на документ)'
+ stopping_faq_generation: 'Обработка остановлена. Причина: %{reason}'
+ paginated_faq_complete: 'Постраничная генерация завершена. Всего FAQ: %{total_faqs}, обработанных страниц: %{pages_processed}'
+ processing_pages: 'Обработка страниц %{start}-%{end} (итерация %{iteration})'
+ chunk_generated: 'Страниц сгенерировано %{chunk_faqs} FAQs. Всего на данный момент: %{total_faqs}'
+ page_processing_error: 'Ошибка при обработке страниц %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Невозможно сгенерировать уникальный slug после 5 попыток'
public_portal:
search:
search_placeholder: Поиск статьи по названию или содержанию...
@@ -347,7 +354,7 @@ ru:
made_with: Сделано с
header:
go_to_homepage: Сайт
- visit_website: Visit website
+ visit_website: Посетить сайт
appearance:
system: Система
light: Светлая
@@ -389,35 +396,35 @@ ru:
many: '%{count} секунд'
other: '%{count} секунд'
automation:
- system_name: 'Automation System'
+ system_name: 'Система автоматизации'
crm:
- no_message: 'No messages in conversation'
- attachment: '[Attachment: %{type}]'
+ no_message: 'В диалоге нет сообщений'
+ attachment: '[Вложение: %{type}]'
no_content: '[Нет содержимого]'
created_activity: |
- New conversation started on %{brand_name}
+ Новый диалог начался на %{brand_name}
- Channel: %{channel_info}
- Created: %{formatted_creation_time}
- Conversation ID: %{display_id}
- View in %{brand_name}: %{url}
+ Канал: %{channel_info}
+ Создано: %{formatted_creation_time}
+ ID разговора: %{display_id}
+ Просмотрено в %{brand_name}: %{url}
transcript_activity: |
- Conversation Transcript from %{brand_name}
+ Расшифровка разговора из %{brand_name}
- Channel: %{channel_info}
- Conversation ID: %{display_id}
- View in %{brand_name}: %{url}
+ Канал: %{channel_info}
+ ID разговора: %{display_id}
+ Просмотр %{brand_name}: %{url}
- Transcript:
+ Расшифровка:
%{format_messages}
agent_capacity_policy:
- inbox_already_assigned: 'Inbox has already been assigned to this policy'
+ inbox_already_assigned: 'Входящие уже были назначены на эту политику'
portals:
send_instructions:
email_required: 'Необходимо указать Email'
- invalid_email_format: 'Invalid email format'
- custom_domain_not_configured: 'Custom domain is not configured'
- instructions_sent_successfully: 'Instructions sent successfully'
- subject: 'Finish setting up %{custom_domain}'
+ invalid_email_format: 'Неправильный формат email'
+ custom_domain_not_configured: 'Пользовательский домен не настроен'
+ instructions_sent_successfully: 'Инструкции успешно отправлены'
+ subject: 'Завершить настройку %{custom_domain}'
ssl_status:
- custom_domain_not_configured: 'Custom domain is not configured'
+ custom_domain_not_configured: 'Пользовательский домен не настроен'
diff --git a/config/locales/sh.yml b/config/locales/sh.yml
index e4598bccf..612db81a8 100644
--- a/config/locales/sh.yml
+++ b/config/locales/sh.yml
@@ -62,6 +62,9 @@ sh:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ sh:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ sh:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ sh:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/sk.yml b/config/locales/sk.yml
index 09067eed7..149af73cd 100644
--- a/config/locales/sk.yml
+++ b/config/locales/sk.yml
@@ -62,6 +62,9 @@ sk:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ sk:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ sk:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ sk:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/sl.yml b/config/locales/sl.yml
index 1e4c32133..40333e298 100644
--- a/config/locales/sl.yml
+++ b/config/locales/sl.yml
@@ -62,6 +62,9 @@ sl:
invalid: Napačen e-poštni naslov
phone_number:
invalid: mora biti v formatu e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: mora biti edinstven v kategoriji in portalu
@@ -86,7 +89,7 @@ sl:
validations:
name: se ne sme začeti ali končati s simboli in ne sme vsebovati znakov < > / \ @.
custom_filters:
- number_of_records: Omejitev dosežena. Največje dovoljeno število filtrov po meri za uporabnika na račun je 50.
+ number_of_records: Omejitev dosežena. Največje dovoljeno število filtrov po meri za uporabnika na račun je 1000.
invalid_attribute: Neveljaven ključ atributa - [%{key}]. Ključ mora biti eden od [%{allowed_keys}] ali atribut po meri, določen v računu.
invalid_operator: Neveljaven operater. Dovoljeni operaterji za %{attribute_name} so [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ sl:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: '%{user_name} je pogovor označil za rešenega'
contact_resolved: 'Pogovor je razrešil %{contact_name}'
@@ -322,6 +327,8 @@ sl:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Iskanje članka po naslovu ali telesu ...
diff --git a/config/locales/sq.yml b/config/locales/sq.yml
index 8717b39fa..ac7d5e4b9 100644
--- a/config/locales/sq.yml
+++ b/config/locales/sq.yml
@@ -62,6 +62,9 @@ sq:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ sq:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ sq:
captain:
resolved: 'Biseda u shënua si e zgjidhur nga %{user_name} për shkak të mungesës së aktivitetit'
open: 'Biseda u shënua si e hapur nga %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ sq:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/sr.yml b/config/locales/sr.yml
index 99140fa02..685b736be 100644
--- a/config/locales/sr.yml
+++ b/config/locales/sr.yml
@@ -62,6 +62,9 @@ sr-Latn:
invalid: Neispravna e-pošta
phone_number:
invalid: treba biti u e164 formatu
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: treba biti jedinstvena u kategoriji i portalu
@@ -86,7 +89,7 @@ sr-Latn:
validations:
name: ne treba početi ili se završiti sa simbolima i ne treba da sadrži < > / \ @ karaktere.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ sr-Latn:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Razgovor je označen kao rešen od strane %{user_name}'
contact_resolved: 'Razgovor je rešen od strane %{contact_name}'
@@ -322,6 +327,8 @@ sr-Latn:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/sv.yml b/config/locales/sv.yml
index fc0170c3a..5c0c18b22 100644
--- a/config/locales/sv.yml
+++ b/config/locales/sv.yml
@@ -62,6 +62,9 @@ sv:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ sv:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ sv:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Konversationen har markerats som löst av %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ sv:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Sök efter artikel baserat på rubrik eller brödtext...
diff --git a/config/locales/ta.yml b/config/locales/ta.yml
index 2323ab68e..752ec6e6c 100644
--- a/config/locales/ta.yml
+++ b/config/locales/ta.yml
@@ -62,6 +62,9 @@ ta:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ ta:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ ta:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'உரையாடலுக்கு %{user_name} தீர்வு வழங்கியுள்ளார்'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ ta:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/th.yml b/config/locales/th.yml
index 137cfd4fd..1ad957730 100644
--- a/config/locales/th.yml
+++ b/config/locales/th.yml
@@ -62,6 +62,9 @@ th:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ th:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ th:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ th:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/tl.yml b/config/locales/tl.yml
index 947ca15f9..4ccbe3f16 100644
--- a/config/locales/tl.yml
+++ b/config/locales/tl.yml
@@ -62,6 +62,9 @@ tl:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ tl:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ tl:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ tl:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/tr.yml b/config/locales/tr.yml
index ab3fce25d..e0c86304a 100644
--- a/config/locales/tr.yml
+++ b/config/locales/tr.yml
@@ -62,6 +62,9 @@ tr:
invalid: Hatalı e-posta
phone_number:
invalid: e164 formatında olmalı
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: kategori ve portalde tekil olmalı
@@ -86,7 +89,7 @@ tr:
validations:
name: sembollerle başlamamalı veya bitmemeli, < > / \ @ karakterlerini içermemeli.
custom_filters:
- number_of_records: Limit aşıldı. Bir kullanıcının bir hesap için izin verilen özel filtre sayısı 50'dir.
+ number_of_records: Limit aşıldı. Bir kullanıcının bir hesap için izin verilen özel filtre sayısı 1000'dir.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ tr:
captain:
resolved: 'Sohbet, %{user_name} tarafından etkinlik olmadığı için çözüldü olarak işaretlendi'
open: 'Sohbet, %{user_name} tarafından açık olarak işaretlendi'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Konuşma %{user_name} tarafından çözümlendi olarak işaretlendi'
contact_resolved: 'Konuşma %{contact_name} tarafından çözümlendi olarak işaretlendi'
@@ -322,6 +327,8 @@ tr:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Başlık veya içerikle makale arayın...
diff --git a/config/locales/uk.yml b/config/locales/uk.yml
index da1f328ac..543241725 100644
--- a/config/locales/uk.yml
+++ b/config/locales/uk.yml
@@ -62,6 +62,9 @@ uk:
invalid: Невірний email
phone_number:
invalid: має бути у форматі e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: має бути унікальним на категорії і порталі
@@ -86,7 +89,7 @@ uk:
validations:
name: не повинно починатись або закінчуватися символами, і він не повинен мати < > / \ @ символів.
custom_filters:
- number_of_records: Досягнуто ліміту. Максимальна кількість дозволених користувацьких фільтрів для користувача на рахунок становить 50.
+ number_of_records: Досягнуто ліміту. Максимальна кількість дозволених користувацьких фільтрів для користувача на рахунок становить 1000.
invalid_attribute: Некоректний ключ атрибута - [%{key}]. Ключ повинен бути одним з [%{allowed_keys}] або налаштованим атрибутом, визначеним в обліковому записі.
invalid_operator: Некоректний оператор. Дозволені оператори для %{attribute_name} є [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ uk:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Розмова була відмічена як вирішена %{user_name}'
contact_resolved: 'Діалог був закритий %{contact_name}'
@@ -322,6 +327,8 @@ uk:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Пошук статті за заголовком або змістом...
diff --git a/config/locales/ur.yml b/config/locales/ur.yml
index 5fd822348..859babbfa 100644
--- a/config/locales/ur.yml
+++ b/config/locales/ur.yml
@@ -62,6 +62,9 @@ ur:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ ur:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ ur:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ ur:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/ur_IN.yml b/config/locales/ur_IN.yml
index 3ed18d377..9a402fa1c 100644
--- a/config/locales/ur_IN.yml
+++ b/config/locales/ur_IN.yml
@@ -62,6 +62,9 @@ ur:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ ur:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ ur:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ ur:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/vi.yml b/config/locales/vi.yml
index 9f2597037..281b3bab4 100644
--- a/config/locales/vi.yml
+++ b/config/locales/vi.yml
@@ -62,6 +62,9 @@ vi:
invalid: Email không hợp lệ
phone_number:
invalid: nên theo đinh dạng e164
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: phải là duy nhất trong danh mục và cổng thông tin
@@ -86,7 +89,7 @@ vi:
validations:
name: không nên bắt đầu hoặc kết thúc bằng các ký hiệu và không nên có kí tự < > / \ @.
custom_filters:
- number_of_records: Đã đạt giới hạn. Số lượng tuỳ chọn lọc tối đa cho mỗi mỗi người dùng mỗi tài khoản là 50.
+ number_of_records: Đã đạt giới hạn. Số lượng tuỳ chọn lọc tối đa cho mỗi mỗi người dùng mỗi tài khoản là 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ vi:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Cuộc trò chuyện được đánh dấu là đã giải quyết bởi %{user_name}'
contact_resolved: 'Hội thoại đã được giải quyết bởi %{contact_name}'
@@ -322,6 +327,8 @@ vi:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Tìm bài viết theo tiêu đề hoặc nội dung...
diff --git a/config/locales/zh_CN.yml b/config/locales/zh_CN.yml
index 4702af460..b706cdca3 100644
--- a/config/locales/zh_CN.yml
+++ b/config/locales/zh_CN.yml
@@ -62,6 +62,9 @@ zh_CN:
invalid: 无效的电子邮件
phone_number:
invalid: 应该是e164格式
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: 在类别和门户中应该是唯一的
@@ -86,7 +89,7 @@ zh_CN:
validations:
name: 不应该以符号开头或结尾,它不应该有 < > / \ @ 字符。
custom_filters:
- number_of_records: 已达到上限。每个账户允许用户自定义过滤器的最大数目为50个。
+ number_of_records: 已达到上限。每个账户允许用户自定义过滤器的最大数目为1000个。
invalid_attribute: 无效的属性键 - [%{key}]。键应为 [%{allowed_keys}] 之一或帐户中定义的自定义属性。
invalid_operator: 无效的操作符。%{attribute_name} 允许的操作符为 [%{allowed_keys}]。
invalid_query_operator: 查询操作符必须为 "AND" 或 "OR"。
@@ -185,6 +188,8 @@ zh_CN:
captain:
resolved: '对话被系统标记为已解决, 原因是 %{user_name} 不活跃'
open: '对话被 %{user_name} 打开'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: '对话被标记由 %{user_name} 解决'
contact_resolved: '对话被 %{contact_name} 重新打开'
@@ -322,6 +327,8 @@ zh_CN:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: 搜索文章的标题或正文...
diff --git a/config/locales/zh_TW.yml b/config/locales/zh_TW.yml
index ad47d8333..f35ced777 100644
--- a/config/locales/zh_TW.yml
+++ b/config/locales/zh_TW.yml
@@ -62,6 +62,9 @@ zh_TW:
invalid: 無效的email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -86,7 +89,7 @@ zh_TW:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -185,6 +188,8 @@ zh_TW:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
+ agent_bot:
+ error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: '被%{user_name}標記的對話已解決。'
contact_resolved: 'Conversation was resolved by %{contact_name}'
@@ -322,6 +327,8 @@ zh_TW:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/routes.rb b/config/routes.rb
index b5d5dba94..232ba22fd 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -67,6 +67,7 @@ Rails.application.routes.draw do
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
end
+ resources :custom_tools
resources :documents, only: [:index, :show, :create, :destroy]
end
resource :saml_settings, only: [:show, :create, :update, :destroy]
@@ -152,6 +153,7 @@ Rails.application.routes.draw do
end
end
+ resources :companies, only: [:index, :show, :create, :update, :destroy]
resources :contacts, only: [:index, :show, :update, :create, :destroy] do
collection do
get :active
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 50a47a20b..138cf78b3 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -27,6 +27,7 @@
- purgable
- housekeeping
- async_database_migration
+ - bulk_reindex_low
- active_storage_analysis
- active_storage_purge
- action_mailbox_incineration
diff --git a/db/migrate/20250929105219_create_companies.rb b/db/migrate/20250929105219_create_companies.rb
new file mode 100644
index 000000000..10fa415c1
--- /dev/null
+++ b/db/migrate/20250929105219_create_companies.rb
@@ -0,0 +1,14 @@
+class CreateCompanies < ActiveRecord::Migration[7.1]
+ def change
+ create_table :companies do |t|
+ t.string :name, null: false
+ t.string :domain
+ t.text :description
+ t.references :account, null: false
+
+ t.timestamps
+ end
+ add_index :companies, [:name, :account_id]
+ add_index :companies, [:domain, :account_id]
+ end
+end
diff --git a/db/migrate/20250929132305_add_company_to_contacts.rb b/db/migrate/20250929132305_add_company_to_contacts.rb
new file mode 100644
index 000000000..e79de34b8
--- /dev/null
+++ b/db/migrate/20250929132305_add_company_to_contacts.rb
@@ -0,0 +1,5 @@
+class AddCompanyToContacts < ActiveRecord::Migration[7.1]
+ def change
+ add_reference :contacts, :company, null: true
+ end
+end
diff --git a/db/migrate/20251003091242_create_captain_custom_tools.rb b/db/migrate/20251003091242_create_captain_custom_tools.rb
new file mode 100644
index 000000000..8f63d826e
--- /dev/null
+++ b/db/migrate/20251003091242_create_captain_custom_tools.rb
@@ -0,0 +1,22 @@
+class CreateCaptainCustomTools < ActiveRecord::Migration[7.1]
+ def change
+ create_table :captain_custom_tools do |t|
+ t.references :account, null: false, index: true
+ t.string :slug, null: false
+ t.string :title, null: false
+ t.text :description
+ t.string :http_method, null: false, default: 'GET'
+ t.text :endpoint_url, null: false
+ t.text :request_template
+ t.text :response_template
+ t.string :auth_type, default: 'none'
+ t.jsonb :auth_config, default: {}
+ t.jsonb :param_schema, default: []
+ t.boolean :enabled, default: true, null: false
+
+ t.timestamps
+ end
+
+ add_index :captain_custom_tools, [:account_id, :slug], unique: true
+ end
+end
diff --git a/db/migrate/20251022152158_add_index_to_conversations_identifier.rb b/db/migrate/20251022152158_add_index_to_conversations_identifier.rb
new file mode 100644
index 000000000..e7d02b53d
--- /dev/null
+++ b/db/migrate/20251022152158_add_index_to_conversations_identifier.rb
@@ -0,0 +1,6 @@
+class AddIndexToConversationsIdentifier < ActiveRecord::Migration[7.1]
+ disable_ddl_transaction!
+ def change
+ add_index :conversations, [:identifier, :account_id], name: 'index_conversations_on_identifier_and_account_id', algorithm: :concurrently
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index d5f0c244c..022a0101e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
+ActiveRecord::Schema[7.1].define(version: 2025_10_22_152158) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -323,6 +323,25 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
end
+ create_table "captain_custom_tools", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.string "slug", null: false
+ t.string "title", null: false
+ t.text "description"
+ t.string "http_method", default: "GET", null: false
+ t.text "endpoint_url", null: false
+ t.text "request_template"
+ t.text "response_template"
+ t.string "auth_type", default: "none"
+ t.jsonb "auth_config", default: {}
+ t.jsonb "param_schema", default: []
+ t.boolean "enabled", default: true, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "slug"], name: "index_captain_custom_tools_on_account_id_and_slug", unique: true
+ t.index ["account_id"], name: "index_captain_custom_tools_on_account_id"
+ end
+
create_table "captain_documents", force: :cascade do |t|
t.string "name"
t.string "external_link", null: false
@@ -551,6 +570,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true
end
+ create_table "companies", force: :cascade do |t|
+ t.string "name", null: false
+ t.string "domain"
+ t.text "description"
+ t.bigint "account_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_companies_on_account_id"
+ t.index ["domain", "account_id"], name: "index_companies_on_domain_and_account_id"
+ t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
+ end
+
create_table "contact_inboxes", force: :cascade do |t|
t.bigint "contact_id"
t.bigint "inbox_id"
@@ -583,6 +614,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
t.string "location", default: ""
t.string "country_code", default: ""
t.boolean "blocked", default: false, null: false
+ t.bigint "company_id"
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
@@ -590,6 +622,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
t.index ["account_id"], name: "index_contacts_on_account_id"
t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["blocked"], name: "index_contacts_on_blocked"
+ t.index ["company_id"], name: "index_contacts_on_company_id"
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin
@@ -643,6 +676,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
t.index ["contact_id"], name: "index_conversations_on_contact_id"
t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id"
t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at"
+ t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id"
t.index ["inbox_id"], name: "index_conversations_on_inbox_id"
t.index ["priority"], name: "index_conversations_on_priority"
t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id"
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
index 21675bad0..ebeaaf67f 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
@@ -33,7 +33,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def tools
- @tools = Captain::Assistant.available_agent_tools
+ assistant = Captain::Assistant.new(account: Current.account)
+ @tools = assistant.available_agent_tools
end
private
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
new file mode 100644
index 000000000..3137ded09
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
@@ -0,0 +1,49 @@
+class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
+ before_action :current_account
+ before_action -> { check_authorization(Captain::CustomTool) }
+ before_action :set_custom_tool, only: [:show, :update, :destroy]
+
+ def index
+ @custom_tools = account_custom_tools.enabled
+ end
+
+ def show; end
+
+ def create
+ @custom_tool = account_custom_tools.create!(custom_tool_params)
+ end
+
+ def update
+ @custom_tool.update!(custom_tool_params)
+ end
+
+ def destroy
+ @custom_tool.destroy
+ head :no_content
+ end
+
+ private
+
+ def set_custom_tool
+ @custom_tool = account_custom_tools.find(params[:id])
+ end
+
+ def account_custom_tools
+ @account_custom_tools ||= Current.account.captain_custom_tools
+ end
+
+ def custom_tool_params
+ params.require(:custom_tool).permit(
+ :title,
+ :description,
+ :endpoint_url,
+ :http_method,
+ :request_template,
+ :response_template,
+ :auth_type,
+ :enabled,
+ auth_config: {},
+ param_schema: [:name, :type, :description, :required]
+ )
+ end
+end
diff --git a/enterprise/app/controllers/api/v1/accounts/companies_controller.rb b/enterprise/app/controllers/api/v1/accounts/companies_controller.rb
new file mode 100644
index 000000000..a33e4c6b2
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/companies_controller.rb
@@ -0,0 +1,40 @@
+class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAccountsController
+ before_action :check_authorization
+ before_action :fetch_company, only: [:show, :update, :destroy]
+
+ def index
+ @companies = Current.account.companies.ordered_by_name
+ end
+
+ def show; end
+
+ def create
+ @company = Current.account.companies.build(company_params)
+ @company.save!
+ end
+
+ def update
+ @company.update!(company_params)
+ end
+
+ def destroy
+ @company.destroy!
+ head :ok
+ end
+
+ private
+
+ def check_authorization
+ raise Pundit::NotAuthorizedError unless ChatwootApp.enterprise?
+
+ authorize(Company)
+ end
+
+ def fetch_company
+ @company = Current.account.companies.find(params[:id])
+ end
+
+ def company_params
+ params.require(:company).permit(:name, :domain, :description, :avatar)
+ end
+end
diff --git a/enterprise/app/controllers/api/v1/auth_controller.rb b/enterprise/app/controllers/api/v1/auth_controller.rb
index 091d4f8f8..b0d8e1366 100644
--- a/enterprise/app/controllers/api/v1/auth_controller.rb
+++ b/enterprise/app/controllers/api/v1/auth_controller.rb
@@ -5,7 +5,9 @@ class Api::V1::AuthController < Api::BaseController
def saml_login
return if @account.nil?
- saml_initiation_url = "/auth/saml?account_id=#{@account.id}"
+ relay_state = params[:target] || 'web'
+
+ saml_initiation_url = "/auth/saml?account_id=#{@account.id}&RelayState=#{relay_state}"
redirect_to saml_initiation_url, status: :temporary_redirect
end
@@ -44,7 +46,18 @@ class Api::V1::AuthController < Api::BaseController
end
def render_saml_error
- redirect_to sso_login_page_url(error: 'saml-authentication-failed')
+ error = 'saml-authentication-failed'
+
+ if mobile_target?
+ mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
+ redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
+ else
+ redirect_to sso_login_page_url(error: error)
+ end
+ end
+
+ def mobile_target?
+ params[:target]&.casecmp('mobile')&.zero?
end
def sso_login_page_url(error: nil)
diff --git a/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb
index 973f26650..4856ca443 100644
--- a/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb
+++ b/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb
@@ -32,17 +32,40 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
end
end
+ def omniauth_failure
+ return super unless params[:provider] == 'saml'
+
+ relay_state = saml_relay_state
+ error = params[:message] || 'authentication-failed'
+
+ if for_mobile?(relay_state)
+ redirect_to_mobile_error(error, relay_state)
+ else
+ redirect_to login_page_url(error: "saml-#{error}")
+ end
+ end
+
private
def handle_saml_auth
account_id = extract_saml_account_id
- return redirect_to login_page_url(error: 'saml-not-enabled') unless saml_enabled_for_account?(account_id)
+ relay_state = saml_relay_state
+
+ unless saml_enabled_for_account?(account_id)
+ return redirect_to_mobile_error('saml-not-enabled') if for_mobile?(relay_state)
+
+ return redirect_to login_page_url(error: 'saml-not-enabled')
+ end
@resource = SamlUserBuilder.new(auth_hash, account_id).perform
if @resource.persisted?
+ return sign_in_user_on_mobile if for_mobile?(relay_state)
+
sign_in_user
else
+ return redirect_to_mobile_error('saml-authentication-failed') if for_mobile?(relay_state)
+
redirect_to login_page_url(error: 'saml-authentication-failed')
end
end
@@ -51,6 +74,19 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id')
end
+ def saml_relay_state
+ session[:saml_relay_state] || request.env['omniauth.params']&.dig('RelayState')
+ end
+
+ def for_mobile?(relay_state)
+ relay_state.to_s.casecmp('mobile').zero?
+ end
+
+ def redirect_to_mobile_error(error)
+ mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
+ redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
+ end
+
def saml_enabled_for_account?(account_id)
return false if account_id.blank?
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index 7ede1201d..15f2ace56 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -49,10 +49,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.map do |message|
- {
+ message_hash = {
content: prepare_multimodal_message_content(message),
role: determine_role(message)
}
+
+ # Include agent_name if present in additional_attributes
+ message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present?
+
+ message_hash
end
end
@@ -79,25 +84,31 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def create_handoff_message
- create_outgoing_message(@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff'))
+ create_outgoing_message(
+ @assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
+ )
end
def create_messages
validate_message_content!(@response['response'])
- create_outgoing_message(@response['response'])
+ create_outgoing_message(@response['response'], agent_name: @response['agent_name'])
end
def validate_message_content!(content)
raise ArgumentError, 'Message content cannot be blank' if content.blank?
end
- def create_outgoing_message(message_content)
+ def create_outgoing_message(message_content, agent_name: nil)
+ additional_attrs = {}
+ additional_attrs[:agent_name] = agent_name if agent_name.present?
+
@conversation.messages.create!(
message_type: :outgoing,
account_id: account.id,
inbox_id: inbox.id,
sender: @assistant,
- content: message_content
+ content: message_content,
+ additional_attributes: additional_attrs
)
end
diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb
index 0423abf67..4f039d57a 100644
--- a/enterprise/app/models/captain/assistant.rb
+++ b/enterprise/app/models/captain/assistant.rb
@@ -50,6 +50,19 @@ class Captain::Assistant < ApplicationRecord
name
end
+ def available_agent_tools
+ tools = self.class.built_in_agent_tools.dup
+
+ custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata)
+ tools.concat(custom_tools)
+
+ tools
+ end
+
+ def available_tool_ids
+ available_agent_tools.pluck(:id)
+ end
+
def push_event_data
{
id: id,
@@ -75,7 +88,7 @@ class Captain::Assistant < ApplicationRecord
private
def agent_name
- name
+ name.parameterize(separator: '_')
end
def agent_tools
@@ -92,6 +105,7 @@ class Captain::Assistant < ApplicationRecord
product_name: config['product_name'] || 'this product',
scenarios: scenarios.enabled.map do |scenario|
{
+ title: scenario.title,
key: scenario.title.parameterize.underscore,
description: scenario.description
}
diff --git a/enterprise/app/models/captain/custom_tool.rb b/enterprise/app/models/captain/custom_tool.rb
new file mode 100644
index 000000000..bf3f351dd
--- /dev/null
+++ b/enterprise/app/models/captain/custom_tool.rb
@@ -0,0 +1,100 @@
+# == Schema Information
+#
+# Table name: captain_custom_tools
+#
+# id :bigint not null, primary key
+# auth_config :jsonb
+# auth_type :string default("none")
+# description :text
+# enabled :boolean default(TRUE), not null
+# endpoint_url :text not null
+# http_method :string default("GET"), not null
+# param_schema :jsonb
+# request_template :text
+# response_template :text
+# slug :string not null
+# title :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
+#
+# Indexes
+#
+# index_captain_custom_tools_on_account_id (account_id)
+# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE
+#
+class Captain::CustomTool < ApplicationRecord
+ include Concerns::Toolable
+ include Concerns::SafeEndpointValidatable
+
+ self.table_name = 'captain_custom_tools'
+
+ NAME_PREFIX = 'custom'.freeze
+ NAME_SEPARATOR = '_'.freeze
+ PARAM_SCHEMA_VALIDATION = {
+ 'type': 'array',
+ 'items': {
+ 'type': 'object',
+ 'properties': {
+ 'name': { 'type': 'string' },
+ 'type': { 'type': 'string' },
+ 'description': { 'type': 'string' },
+ 'required': { 'type': 'boolean' }
+ },
+ 'required': %w[name type description],
+ 'additionalProperties': false
+ }
+ }.to_json.freeze
+
+ belongs_to :account
+
+ enum :http_method, %w[GET POST].index_by(&:itself), validate: true
+ enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth
+
+ before_validation :generate_slug
+
+ validates :slug, presence: true, uniqueness: { scope: :account_id }
+ validates :title, presence: true
+ validates :endpoint_url, presence: true
+ validates_with JsonSchemaValidator,
+ schema: PARAM_SCHEMA_VALIDATION,
+ attribute_resolver: ->(record) { record.param_schema }
+
+ scope :enabled, -> { where(enabled: true) }
+
+ def to_tool_metadata
+ {
+ id: slug,
+ title: title,
+ description: description,
+ custom: true
+ }
+ end
+
+ private
+
+ def generate_slug
+ return if slug.present?
+ return if title.blank?
+
+ paramterized_title = title.parameterize(separator: NAME_SEPARATOR)
+
+ base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}"
+ self.slug = find_unique_slug(base_slug)
+ end
+
+ def find_unique_slug(base_slug)
+ return base_slug unless slug_exists?(base_slug)
+
+ 5.times do
+ slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
+ return slug_candidate unless slug_exists?(slug_candidate)
+ end
+
+ raise ActiveRecord::RecordNotUnique, I18n.t('captain.custom_tool.slug_generation_failed')
+ end
+
+ def slug_exists?(candidate)
+ self.class.exists?(account_id: account_id, slug: candidate)
+ end
+end
diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb
index aac7e2411..c43468804 100644
--- a/enterprise/app/models/captain/scenario.rb
+++ b/enterprise/app/models/captain/scenario.rb
@@ -38,7 +38,7 @@ class Captain::Scenario < ApplicationRecord
scope :enabled, -> { where(enabled: true) }
- delegate :temperature, :feature_faq, :feature_memory, :product_name, to: :assistant
+ delegate :temperature, :feature_faq, :feature_memory, :product_name, :response_guidelines, :guardrails, to: :assistant
before_save :resolve_tool_references
@@ -46,35 +46,48 @@ class Captain::Scenario < ApplicationRecord
{
title: title,
instructions: resolved_instructions,
- tools: resolved_tools
+ tools: resolved_tools,
+ assistant_name: assistant.name.downcase.gsub(/\s+/, '_'),
+ response_guidelines: response_guidelines || [],
+ guardrails: guardrails || []
}
end
private
def agent_name
- "#{title} Agent".titleize
+ "#{title} Agent".parameterize(separator: '_')
end
def agent_tools
- resolved_tools.map { |tool| self.class.resolve_tool_class(tool[:id]) }.map { |tool| tool.new(assistant) }
+ resolved_tools.map { |tool| resolve_tool_instance(tool) }
end
def resolved_instructions
- instruction.gsub(TOOL_REFERENCE_REGEX) do |match|
- "#{match} tool "
- end
+ instruction.gsub(TOOL_REFERENCE_REGEX, '`\1` tool')
end
def resolved_tools
return [] if tools.blank?
- available_tools = self.class.available_agent_tools
+ available_tools = assistant.available_agent_tools
tools.filter_map do |tool_id|
available_tools.find { |tool| tool[:id] == tool_id }
end
end
+ def resolve_tool_instance(tool_metadata)
+ tool_id = tool_metadata[:id]
+
+ if tool_metadata[:custom]
+ custom_tool = Captain::CustomTool.find_by(slug: tool_id, account_id: account_id, enabled: true)
+ custom_tool&.tool(assistant)
+ else
+ tool_class = self.class.resolve_tool_class(tool_id)
+ tool_class&.new(assistant)
+ end
+ end
+
# Validates that all tool references in the instruction are valid.
# Parses the instruction for tool references and checks if they exist
# in the available tools configuration.
@@ -95,8 +108,8 @@ class Captain::Scenario < ApplicationRecord
tool_ids = extract_tool_ids_from_text(instruction)
return if tool_ids.empty?
- available_tool_ids = self.class.available_tool_ids
- invalid_tools = tool_ids - available_tool_ids
+ all_available_tool_ids = assistant.available_tool_ids
+ invalid_tools = tool_ids - all_available_tool_ids
return unless invalid_tools.any?
diff --git a/enterprise/app/models/company.rb b/enterprise/app/models/company.rb
new file mode 100644
index 000000000..764cb2a9c
--- /dev/null
+++ b/enterprise/app/models/company.rb
@@ -0,0 +1,33 @@
+# == Schema Information
+#
+# Table name: companies
+#
+# id :bigint not null, primary key
+# description :text
+# domain :string
+# name :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
+#
+# Indexes
+#
+# index_companies_on_account_id (account_id)
+# index_companies_on_domain_and_account_id (domain,account_id)
+# index_companies_on_name_and_account_id (name,account_id)
+#
+class Company < ApplicationRecord
+ include Avatarable
+ validates :account_id, presence: true
+ validates :name, presence: true, length: { maximum: Limits::COMPANY_NAME_LENGTH_LIMIT }
+ validates :domain, allow_blank: true, format: {
+ with: /\A[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+\z/,
+ message: I18n.t('errors.companies.domain.invalid')
+ }
+ validates :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT }
+
+ belongs_to :account
+ has_many :contacts, dependent: :nullify
+
+ scope :ordered_by_name, -> { order(:name) }
+end
diff --git a/enterprise/app/models/concerns/captain_tools_helpers.rb b/enterprise/app/models/concerns/captain_tools_helpers.rb
index 5a660310c..34133aac2 100644
--- a/enterprise/app/models/concerns/captain_tools_helpers.rb
+++ b/enterprise/app/models/concerns/captain_tools_helpers.rb
@@ -8,12 +8,12 @@ module Concerns::CaptainToolsHelpers
TOOL_REFERENCE_REGEX = %r{\[[^\]]+\]\(tool://([^/)]+)\)}
class_methods do
- # Returns all available agent tools with their metadata.
+ # Returns all built-in agent tools with their metadata.
# Only includes tools that have corresponding class files and can be resolved.
#
# @return [Array] Array of tool hashes with :id, :title, :description, :icon
- def available_agent_tools
- @available_agent_tools ||= load_agent_tools
+ def built_in_agent_tools
+ @built_in_agent_tools ||= load_agent_tools
end
# Resolves a tool class from a tool ID.
@@ -26,12 +26,12 @@ module Concerns::CaptainToolsHelpers
class_name.safe_constantize
end
- # Returns an array of all available tool IDs.
- # Convenience method that extracts just the IDs from available_agent_tools.
+ # Returns an array of all built-in tool IDs.
+ # Convenience method that extracts just the IDs from built_in_agent_tools.
#
- # @return [Array] Array of available tool IDs
- def available_tool_ids
- @available_tool_ids ||= available_agent_tools.map { |tool| tool[:id] }
+ # @return [Array] Array of built-in tool IDs
+ def built_in_tool_ids
+ @built_in_tool_ids ||= built_in_agent_tools.map { |tool| tool[:id] }
end
private
diff --git a/enterprise/app/models/concerns/safe_endpoint_validatable.rb b/enterprise/app/models/concerns/safe_endpoint_validatable.rb
new file mode 100644
index 000000000..b151b10e7
--- /dev/null
+++ b/enterprise/app/models/concerns/safe_endpoint_validatable.rb
@@ -0,0 +1,84 @@
+module Concerns::SafeEndpointValidatable
+ extend ActiveSupport::Concern
+
+ FRONTEND_HOST = URI.parse(ENV.fetch('FRONTEND_URL', 'http://localhost:3000')).host.freeze
+ DISALLOWED_HOSTS = ['localhost', /\.local\z/i].freeze
+
+ included do
+ validate :validate_safe_endpoint_url
+ end
+
+ private
+
+ def validate_safe_endpoint_url
+ return if endpoint_url.blank?
+
+ uri = parse_endpoint_uri
+ return errors.add(:endpoint_url, 'must be a valid URL') unless uri
+
+ validate_endpoint_scheme(uri)
+ validate_endpoint_host(uri)
+ validate_not_ip_address(uri)
+ validate_no_unicode_chars(uri)
+ end
+
+ def parse_endpoint_uri
+ # Strip Liquid template syntax for validation
+ # Replace {{ variable }} with a placeholder value
+ sanitized_url = endpoint_url.gsub(/\{\{[^}]+\}\}/, 'placeholder')
+ URI.parse(sanitized_url)
+ rescue URI::InvalidURIError
+ nil
+ end
+
+ def validate_endpoint_scheme(uri)
+ return if uri.scheme == 'https'
+
+ errors.add(:endpoint_url, 'must use HTTPS protocol')
+ end
+
+ def validate_endpoint_host(uri)
+ if uri.host.blank?
+ errors.add(:endpoint_url, 'must have a valid hostname')
+ return
+ end
+
+ if uri.host == FRONTEND_HOST
+ errors.add(:endpoint_url, 'cannot point to the application itself')
+ return
+ end
+
+ DISALLOWED_HOSTS.each do |pattern|
+ matched = if pattern.is_a?(Regexp)
+ uri.host =~ pattern
+ else
+ uri.host.downcase == pattern
+ end
+
+ next unless matched
+
+ errors.add(:endpoint_url, 'cannot use disallowed hostname')
+ break
+ end
+ end
+
+ def validate_not_ip_address(uri)
+ # Check for IPv4
+ if /\A\d+\.\d+\.\d+\.\d+\z/.match?(uri.host)
+ errors.add(:endpoint_url, 'cannot be an IP address, must be a hostname')
+ return
+ end
+
+ # Check for IPv6
+ return unless uri.host.include?(':')
+
+ errors.add(:endpoint_url, 'cannot be an IP address, must be a hostname')
+ end
+
+ def validate_no_unicode_chars(uri)
+ return unless uri.host
+ return if /\A[\x00-\x7F]+\z/.match?(uri.host)
+
+ errors.add(:endpoint_url, 'hostname cannot contain non-ASCII characters')
+ end
+end
diff --git a/enterprise/app/models/concerns/toolable.rb b/enterprise/app/models/concerns/toolable.rb
new file mode 100644
index 000000000..ad047e8f8
--- /dev/null
+++ b/enterprise/app/models/concerns/toolable.rb
@@ -0,0 +1,91 @@
+module Concerns::Toolable
+ extend ActiveSupport::Concern
+
+ def tool(assistant)
+ custom_tool_record = self
+ # Convert slug to valid Ruby constant name (replace hyphens with underscores, then camelize)
+ class_name = custom_tool_record.slug.underscore.camelize
+
+ # Always create a fresh class to reflect current metadata
+ tool_class = Class.new(Captain::Tools::HttpTool) do
+ description custom_tool_record.description
+
+ custom_tool_record.param_schema.each do |param_def|
+ param param_def['name'].to_sym,
+ type: param_def['type'],
+ desc: param_def['description'],
+ required: param_def.fetch('required', true)
+ end
+ end
+
+ # Register the dynamically created class as a constant in the Captain::Tools namespace.
+ # This is required because RubyLLM's Tool base class derives the tool name from the class name
+ # (via Class#name). Anonymous classes created with Class.new have no name and return empty strings,
+ # which causes "Invalid 'tools[].function.name': empty string" errors from the LLM API.
+ # By setting it as a constant, the class gets a proper name (e.g., "Captain::Tools::CatFactLookup")
+ # which RubyLLM extracts and normalizes to "cat-fact-lookup" for the LLM API.
+ # We refresh the constant on each call to ensure tool metadata changes are reflected.
+ Captain::Tools.send(:remove_const, class_name) if Captain::Tools.const_defined?(class_name, false)
+ Captain::Tools.const_set(class_name, tool_class)
+
+ tool_class.new(assistant, self)
+ end
+
+ def build_request_url(params)
+ return endpoint_url if endpoint_url.blank? || endpoint_url.exclude?('{{')
+
+ render_template(endpoint_url, params)
+ end
+
+ def build_request_body(params)
+ return nil if request_template.blank?
+
+ render_template(request_template, params)
+ end
+
+ def build_auth_headers
+ return {} if auth_none?
+
+ case auth_type
+ when 'bearer'
+ { 'Authorization' => "Bearer #{auth_config['token']}" }
+ when 'api_key'
+ if auth_config['location'] == 'header'
+ { auth_config['name'] => auth_config['key'] }
+ else
+ {}
+ end
+ else
+ {}
+ end
+ end
+
+ def build_basic_auth_credentials
+ return nil unless auth_type == 'basic'
+
+ [auth_config['username'], auth_config['password']]
+ end
+
+ def format_response(raw_response_body)
+ return raw_response_body if response_template.blank?
+
+ response_data = parse_response_body(raw_response_body)
+ render_template(response_template, { 'response' => response_data, 'r' => response_data })
+ end
+
+ private
+
+ def render_template(template, context)
+ liquid_template = Liquid::Template.parse(template, error_mode: :strict)
+ liquid_template.render(context.deep_stringify_keys, registers: {}, strict_variables: true, strict_filters: true)
+ rescue Liquid::SyntaxError, Liquid::UndefinedVariable, Liquid::UndefinedFilter => e
+ Rails.logger.error("Liquid template error: #{e.message}")
+ raise "Template rendering failed: #{e.message}"
+ end
+
+ def parse_response_body(body)
+ JSON.parse(body)
+ rescue JSON::ParserError
+ body
+ end
+end
diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb
index b52ac4b3e..cae32e86c 100644
--- a/enterprise/app/models/enterprise/concerns/account.rb
+++ b/enterprise/app/models/enterprise/concerns/account.rb
@@ -10,8 +10,10 @@ module Enterprise::Concerns::Account
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
+ has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
has_many :copilot_threads, dependent: :destroy_async
+ has_many :companies, dependent: :destroy_async
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
diff --git a/enterprise/app/models/enterprise/concerns/contact.rb b/enterprise/app/models/enterprise/concerns/contact.rb
new file mode 100644
index 000000000..9139fc67e
--- /dev/null
+++ b/enterprise/app/models/enterprise/concerns/contact.rb
@@ -0,0 +1,6 @@
+module Enterprise::Concerns::Contact
+ extend ActiveSupport::Concern
+ included do
+ belongs_to :company, optional: true
+ end
+end
diff --git a/enterprise/app/policies/captain/custom_tool_policy.rb b/enterprise/app/policies/captain/custom_tool_policy.rb
new file mode 100644
index 000000000..b88a23860
--- /dev/null
+++ b/enterprise/app/policies/captain/custom_tool_policy.rb
@@ -0,0 +1,21 @@
+class Captain::CustomToolPolicy < ApplicationPolicy
+ def index?
+ true
+ end
+
+ def show?
+ true
+ end
+
+ def create?
+ @account_user.administrator?
+ end
+
+ def update?
+ @account_user.administrator?
+ end
+
+ def destroy?
+ @account_user.administrator?
+ end
+end
diff --git a/enterprise/app/policies/company_policy.rb b/enterprise/app/policies/company_policy.rb
new file mode 100644
index 000000000..1c252967c
--- /dev/null
+++ b/enterprise/app/policies/company_policy.rb
@@ -0,0 +1,21 @@
+class CompanyPolicy < ApplicationPolicy
+ def index?
+ true
+ end
+
+ def show?
+ true
+ end
+
+ def create?
+ true
+ end
+
+ def update?
+ true
+ end
+
+ def destroy?
+ @account_user.administrator?
+ end
+end
diff --git a/enterprise/app/policies/enterprise/conversation_policy.rb b/enterprise/app/policies/enterprise/conversation_policy.rb
new file mode 100644
index 000000000..d956db2d7
--- /dev/null
+++ b/enterprise/app/policies/enterprise/conversation_policy.rb
@@ -0,0 +1,42 @@
+module Enterprise::ConversationPolicy
+ def show?
+ return false unless super
+ return true unless custom_role_permissions?
+
+ permissions = custom_role_permissions
+ return true if manage_all_conversations?(permissions)
+ return true if permits_unassigned_manage?(permissions)
+
+ permits_participating?(permissions)
+ end
+
+ private
+
+ def manage_all_conversations?(permissions)
+ permissions.include?('conversation_manage')
+ end
+
+ def permits_unassigned_manage?(permissions)
+ return false unless permissions.include?('conversation_unassigned_manage')
+
+ unassigned_conversation? || assigned_to_user?
+ end
+
+ def permits_participating?(permissions)
+ return false unless permissions.include?('conversation_participating_manage')
+
+ assigned_to_user? || participant?
+ end
+
+ def unassigned_conversation?
+ record.assignee_id.nil?
+ end
+
+ def custom_role_permissions?
+ account_user&.custom_role_id.present?
+ end
+
+ def custom_role_permissions
+ account_user&.custom_role&.permissions || []
+ end
+end
diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb
index 7a35e6d07..9c4e56841 100644
--- a/enterprise/app/services/captain/assistant/agent_runner_service.rb
+++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb
@@ -23,7 +23,7 @@ class Captain::Assistant::AgentRunnerService
message_to_process = extract_last_user_message(message_history)
runner = Agents::Runner.with_agents(*agents)
runner = add_callbacks_to_runner(runner) if @callbacks.any?
- result = runner.run(message_to_process, context: context)
+ result = runner.run(message_to_process, context: context, max_turns: 100)
process_agent_result(result)
rescue StandardError => e
@@ -74,7 +74,12 @@ class Captain::Assistant::AgentRunnerService
# Response formatting methods
def process_agent_result(result)
Rails.logger.info "[Captain V2] Agent result: #{result.inspect}"
- format_response(result.output)
+ response = format_response(result.output)
+
+ # Extract agent name from context
+ response['agent_name'] = result.context&.dig(:current_agent)
+
+ response
end
def format_response(output)
diff --git a/enterprise/app/services/captain/onboarding/website_analyzer_service.rb b/enterprise/app/services/captain/onboarding/website_analyzer_service.rb
new file mode 100644
index 000000000..d3e7d4983
--- /dev/null
+++ b/enterprise/app/services/captain/onboarding/website_analyzer_service.rb
@@ -0,0 +1,129 @@
+class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseOpenAiService
+ MAX_CONTENT_LENGTH = 8000
+
+ def initialize(website_url)
+ super()
+ @website_url = normalize_url(website_url)
+ @website_content = nil
+ @favicon_url = nil
+ end
+
+ def analyze
+ fetch_website_content
+ return error_response('Failed to fetch website content') unless @website_content
+
+ extract_business_info
+ rescue StandardError => e
+ Rails.logger.error "[Captain Onboarding] Website analysis error: #{e.message}"
+ error_response(e.message)
+ end
+
+ private
+
+ def normalize_url(url)
+ return url if url.match?(%r{\Ahttps?://})
+
+ "https://#{url}"
+ end
+
+ def fetch_website_content
+ crawler = Captain::Tools::SimplePageCrawlService.new(@website_url)
+
+ text_content = crawler.body_text_content
+ page_title = crawler.page_title
+ meta_description = crawler.meta_description
+
+ if page_title.blank? && meta_description.blank? && text_content.blank?
+ Rails.logger.error "[Captain Onboarding] Failed to fetch #{@website_url}: No content found"
+ return false
+ end
+
+ combined_content = []
+ combined_content << "Title: #{page_title}" if page_title.present?
+ combined_content << "Description: #{meta_description}" if meta_description.present?
+ combined_content << text_content
+
+ @website_content = clean_and_truncate_content(combined_content.join("\n\n"))
+ @favicon_url = crawler.favicon_url
+ true
+ rescue StandardError => e
+ Rails.logger.error "[Captain Onboarding] Failed to fetch #{@website_url}: #{e.message}"
+ false
+ end
+
+ def clean_and_truncate_content(content)
+ cleaned = content.gsub(/\s+/, ' ').strip
+ cleaned.length > MAX_CONTENT_LENGTH ? cleaned[0...MAX_CONTENT_LENGTH] : cleaned
+ end
+
+ def extract_business_info
+ prompt = build_analysis_prompt
+
+ response = client.chat(
+ parameters: {
+ model: model,
+ messages: [{ role: 'user', content: prompt }],
+ response_format: { type: 'json_object' },
+ temperature: 0.1,
+ max_tokens: 1000
+ }
+ )
+
+ parse_llm_response(response.dig('choices', 0, 'message', 'content'))
+ end
+
+ def build_analysis_prompt
+ <<~PROMPT
+ Analyze the following website content and extract business information. Return a JSON response with the following structure:
+
+ {
+ "business_name": "The company or business name",
+ "suggested_assistant_name": "A friendly assistant name (e.g., 'Captain Assistant', 'Support Genie', etc.)",
+ "description": "Persona of the assistant based on the business type"
+ }
+
+ Guidelines:
+ - business_name: Extract the actual company/brand name from the content
+ - suggested_assistant_name: Create a friendly, professional name that customers would want to interact with
+ - description: Provide context about the business and what the assistant can help with. Keep it general and adaptable rather than overly specific. For example: "You specialize in helping customers with their orders and product questions" or "You assist customers with their account needs and general inquiries"
+
+ Website content:
+ #{@website_content}
+
+ Return only valid JSON, no additional text.
+ PROMPT
+ end
+
+ def parse_llm_response(response_text)
+ parsed_response = JSON.parse(response_text)
+
+ {
+ success: true,
+ data: {
+ business_name: parsed_response['business_name'],
+ suggested_assistant_name: parsed_response['suggested_assistant_name'],
+ description: parsed_response['description'],
+ website_url: @website_url,
+ favicon_url: @favicon_url
+ }
+ }
+ rescue JSON::ParserError => e
+ Rails.logger.error "[Captain Onboarding] JSON parsing error: #{e.message}"
+ Rails.logger.error "[Captain Onboarding] Raw response: #{response_text}"
+ error_response('Failed to parse business information from website')
+ end
+
+ def error_response(message)
+ {
+ success: false,
+ error: message,
+ data: {
+ business_name: '',
+ suggested_assistant_name: '',
+ description: '',
+ website_url: @website_url,
+ favicon_url: nil
+ }
+ }
+ end
+end
diff --git a/enterprise/app/services/captain/tools/simple_page_crawl_service.rb b/enterprise/app/services/captain/tools/simple_page_crawl_service.rb
index d6baa1ebe..65731ad90 100644
--- a/enterprise/app/services/captain/tools/simple_page_crawl_service.rb
+++ b/enterprise/app/services/captain/tools/simple_page_crawl_service.rb
@@ -19,6 +19,20 @@ class Captain::Tools::SimplePageCrawlService
ReverseMarkdown.convert @doc.at_xpath('//body'), unknown_tags: :bypass, github_flavored: true
end
+ def meta_description
+ meta_desc = @doc.at_css('meta[name="description"]')
+ return nil unless meta_desc && meta_desc['content']
+
+ meta_desc['content'].strip
+ end
+
+ def favicon_url
+ favicon_link = @doc.at_css('link[rel*="icon"]')
+ return nil unless favicon_link && favicon_link['href']
+
+ resolve_url(favicon_link['href'])
+ end
+
private
def sitemap?
@@ -35,4 +49,12 @@ class Captain::Tools::SimplePageCrawlService
absolute_url
end
end
+
+ def resolve_url(url)
+ return url if url.start_with?('http')
+
+ URI.join(@external_link, url).to_s
+ rescue StandardError
+ url
+ end
end
diff --git a/enterprise/app/services/llm/base_open_ai_service.rb b/enterprise/app/services/llm/base_open_ai_service.rb
index 2d3932246..e3e88453c 100644
--- a/enterprise/app/services/llm/base_open_ai_service.rb
+++ b/enterprise/app/services/llm/base_open_ai_service.rb
@@ -1,5 +1,6 @@
class Llm::BaseOpenAiService
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
+ attr_reader :client, :model
def initialize
@client = OpenAI::Client.new(
diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder
new file mode 100644
index 000000000..baf3cb3ac
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool
diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder
new file mode 100644
index 000000000..c57a92261
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder
@@ -0,0 +1,10 @@
+json.payload do
+ json.array! @custom_tools do |custom_tool|
+ json.partial! 'api/v1/models/captain/custom_tool', custom_tool: custom_tool
+ end
+end
+
+json.meta do
+ json.total_count @custom_tools.count
+ json.page 1
+end
diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder
new file mode 100644
index 000000000..baf3cb3ac
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool
diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder
new file mode 100644
index 000000000..baf3cb3ac
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool
diff --git a/enterprise/app/views/api/v1/accounts/companies/_company.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/_company.json.jbuilder
new file mode 100644
index 000000000..71c4d3b9b
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/_company.json.jbuilder
@@ -0,0 +1,7 @@
+json.id company.id
+json.name company.name
+json.domain company.domain
+json.description company.description
+json.avatar_url company.avatar_url
+json.created_at company.created_at
+json.updated_at company.updated_at
diff --git a/enterprise/app/views/api/v1/accounts/companies/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/create.json.jbuilder
new file mode 100644
index 000000000..b3bc80cfd
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/create.json.jbuilder
@@ -0,0 +1,3 @@
+json.payload do
+ json.partial! 'company', company: @company
+end
diff --git a/enterprise/app/views/api/v1/accounts/companies/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/index.json.jbuilder
new file mode 100644
index 000000000..e68bd8543
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/index.json.jbuilder
@@ -0,0 +1,5 @@
+json.payload do
+ json.array! @companies do |company|
+ json.partial! 'company', company: company
+ end
+end
diff --git a/enterprise/app/views/api/v1/accounts/companies/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/show.json.jbuilder
new file mode 100644
index 000000000..b3bc80cfd
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/show.json.jbuilder
@@ -0,0 +1,3 @@
+json.payload do
+ json.partial! 'company', company: @company
+end
diff --git a/enterprise/app/views/api/v1/accounts/companies/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/update.json.jbuilder
new file mode 100644
index 000000000..b3bc80cfd
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/update.json.jbuilder
@@ -0,0 +1,3 @@
+json.payload do
+ json.partial! 'company', company: @company
+end
diff --git a/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder
new file mode 100644
index 000000000..778b30061
--- /dev/null
+++ b/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder
@@ -0,0 +1,15 @@
+json.id custom_tool.id
+json.slug custom_tool.slug
+json.title custom_tool.title
+json.description custom_tool.description
+json.endpoint_url custom_tool.endpoint_url
+json.http_method custom_tool.http_method
+json.request_template custom_tool.request_template
+json.response_template custom_tool.response_template
+json.auth_type custom_tool.auth_type
+json.auth_config custom_tool.auth_config
+json.param_schema custom_tool.param_schema
+json.enabled custom_tool.enabled
+json.account_id custom_tool.account_id
+json.created_at custom_tool.created_at.to_i
+json.updated_at custom_tool.updated_at.to_i
diff --git a/enterprise/config/initializers/omniauth_saml.rb b/enterprise/config/initializers/omniauth_saml.rb
index f73e3a109..f39e9d511 100644
--- a/enterprise/config/initializers/omniauth_saml.rb
+++ b/enterprise/config/initializers/omniauth_saml.rb
@@ -9,18 +9,22 @@ SAML_SETUP_PROC = proc do |env|
account_id = request.params['account_id'] ||
request.session[:saml_account_id] ||
env['omniauth.params']&.dig('account_id')
+ relay_state = request.params['RelayState'] || ''
if account_id
# Store in session and omniauth params for callback
request.session[:saml_account_id] = account_id
+ request.session[:saml_relay_state] = relay_state
env['omniauth.params'] ||= {}
env['omniauth.params']['account_id'] = account_id
+ env['omniauth.params']['RelayState'] = relay_state
# Find SAML settings for this account
settings = AccountSamlSettings.find_by(account_id: account_id)
if settings
# Configure the strategy options dynamically
+ env['omniauth.strategy'].options[:idp_sso_service_url_runtime_params] = { RelayState: :RelayState }
env['omniauth.strategy'].options[:assertion_consumer_service_url] = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/omniauth/saml/callback?account_id=#{account_id}"
env['omniauth.strategy'].options[:sp_entity_id] = settings.sp_entity_id
env['omniauth.strategy'].options[:idp_entity_id] = settings.idp_entity_id
diff --git a/enterprise/lib/captain/prompts/assistant.liquid b/enterprise/lib/captain/prompts/assistant.liquid
index 69c967d73..0dc7d8577 100644
--- a/enterprise/lib/captain/prompts/assistant.liquid
+++ b/enterprise/lib/captain/prompts/assistant.liquid
@@ -2,12 +2,13 @@
You are part of Captain, a multi-agent AI system designed for seamless agent coordination and task execution. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses.
# Your Identity
-You are {{name}}, a helpful and knowledgeable assistant. Your role is to provide accurate information, assist with tasks, and ensure users get the help they need.
+You are {{name}}, a helpful and knowledgeable assistant. Your role is to primarily act as a orchestrator handling multiple scenarios by using handoff tools. Your job also involves providing accurate information, assisting with tasks, and ensuring the customer get the help they need.
{{ description }}
-Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the faq_lookup tool for this.
+Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the `captain--tools--faq_lookup` tool for this.
+{% if conversation || contact -%}
# Current Context
Here's the metadata we have about the current conversation and the contact associated with it:
@@ -19,12 +20,16 @@ Here's the metadata we have about the current conversation and the contact assoc
{% if contact -%}
{% render 'contact' %}
{% endif -%}
+{% endif -%}
{% if response_guidelines.size > 0 -%}
# Response Guidelines
Your responses should follow these guidelines:
{% for guideline in response_guidelines -%}
- {{ guideline }}
+- Be conversational but professional
+- Provide actionable information
+- Include relevant details from tool responses
{% endfor %}
{% endif -%}
@@ -45,30 +50,26 @@ First, understand what the user is asking:
- **Complexity**: Can you handle it or does it need specialized expertise?
## 2. Check for Specialized Scenarios First
-Before using any tools, check if the request matches any of these scenarios. If unclear, ask clarifying questions to determine if a scenario applies:
+
+Before using any tools, check if the request matches any of these scenarios. If it seems like a particular scenario matches, use the specific handoff tool to transfer the conversation to the specific agent. The following are the scenario agents that are available to you.
{% for scenario in scenarios -%}
-### handoff_to_{{ scenario.key }}
-{{ scenario.description }}
-{% endfor -%}
+- {{ scenario.title }}: {{ scenario.description }}, use the `handoff_to_{{ scenario.key }}` tool to transfer the conversation to the {{ scenario.title }} agent.
+{% endfor %}
+If unclear, ask clarifying questions to determine if a scenario applies:
## 3. Handle the Request
-If no specialized scenario clearly matches, handle it yourself:
+If no specialized scenario clearly matches, handle it yourself in the following way
### For Questions and Information Requests
-1. **First, check existing knowledge**: Use `faq_lookup` tool to search for relevant information
-2. **If not found in FAQs**: Provide your best answer based on available context
-3. **If unable to answer**: Use `handoff` tool to transfer to a human expert
+1. **First, check existing knowledge**: Use `captain--tools--faq_lookup` tool to search for relevant information
+2. **If not found in FAQs**: Try to ask clarifying questions to gather more information
+3. **If unable to answer**: Use `captain--tools--handoff` tool to transfer to a human expert
### For Complex or Unclear Requests
1. **Ask clarifying questions**: Gather more information if needed
2. **Break down complex tasks**: Handle step by step or hand off if too complex
-3. **Escalate when necessary**: Use `handoff` tool for issues beyond your capabilities
-
-## Response Best Practices
-- Be conversational but professional
-- Provide actionable information
-- Include relevant details from tool responses
+3. **Escalate when necessary**: Use `captain--tools--handoff` tool for issues beyond your capabilities
# Human Handoff Protocol
Transfer to a human agent when:
@@ -77,4 +78,4 @@ Transfer to a human agent when:
- The issue requires specialized knowledge or permissions you don't have
- Multiple attempts to help have been unsuccessful
-When using the `handoff` tool, provide a clear reason that helps the human agent understand the context.
+When using the `captain--tools--handoff` tool, provide a clear reason that helps the human agent understand the context.
diff --git a/enterprise/lib/captain/prompts/scenario.liquid b/enterprise/lib/captain/prompts/scenario.liquid
index 339820b83..1148a7c3a 100644
--- a/enterprise/lib/captain/prompts/scenario.liquid
+++ b/enterprise/lib/captain/prompts/scenario.liquid
@@ -1,20 +1,44 @@
# System context
-You are part of a multi-agent system where you've been handed off a conversation to handle a specific task.
-The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
+You are part of a multi-agent system where you've been handed off a conversation to handle a specific task. The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
# Your Role
-You are a specialized agent called {{ title }}, your task is to handle the following scenario:
+You are a specialized agent called "{{ title }}", your task is to handle the following scenario:
{{ instructions }}
+If you believe the user's request is not within the scope of your role, you can assign this conversation back to the orchestrator agent using the `handoff_to_{{ assistant_name }}` tool
+
+{% if conversation || contact %}
+# Current Context
+
+Here's the metadata we have about the current conversation and the contact associated with it:
+
{% if conversation -%}
{% render 'conversation' %}
+{% endif -%}
{% if contact -%}
{% render 'contact' %}
{% endif -%}
{% endif -%}
+
+{% if response_guidelines.size > 0 -%}
+# Response Guidelines
+Your responses should follow these guidelines:
+{% for guideline in response_guidelines -%}
+- {{ guideline }}
+{% endfor %}
+{% endif -%}
+
+{% if guardrails.size > 0 -%}
+# Guardrails
+Always respect these boundaries:
+{% for guardrail in guardrails -%}
+- {{ guardrail }}
+{% endfor %}
+{% endif -%}
+
{% if tools.size > 0 -%}
# Available Tools
You have access to these tools:
diff --git a/enterprise/lib/captain/tools/http_tool.rb b/enterprise/lib/captain/tools/http_tool.rb
new file mode 100644
index 000000000..b634de04e
--- /dev/null
+++ b/enterprise/lib/captain/tools/http_tool.rb
@@ -0,0 +1,105 @@
+require 'agents'
+
+class Captain::Tools::HttpTool < Agents::Tool
+ def initialize(assistant, custom_tool)
+ @assistant = assistant
+ @custom_tool = custom_tool
+ super()
+ end
+
+ def active?
+ @custom_tool.enabled?
+ end
+
+ def perform(_tool_context, **params)
+ url = @custom_tool.build_request_url(params)
+ body = @custom_tool.build_request_body(params)
+
+ response = execute_http_request(url, body)
+ @custom_tool.format_response(response.body)
+ rescue StandardError => e
+ Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
+ 'An error occurred while executing the request'
+ end
+
+ private
+
+ PRIVATE_IP_RANGES = [
+ IPAddr.new('127.0.0.0/8'), # IPv4 Loopback
+ IPAddr.new('10.0.0.0/8'), # IPv4 Private network
+ IPAddr.new('172.16.0.0/12'), # IPv4 Private network
+ IPAddr.new('192.168.0.0/16'), # IPv4 Private network
+ IPAddr.new('169.254.0.0/16'), # IPv4 Link-local
+ IPAddr.new('::1'), # IPv6 Loopback
+ IPAddr.new('fc00::/7'), # IPv6 Unique local addresses
+ IPAddr.new('fe80::/10') # IPv6 Link-local
+ ].freeze
+
+ # Limit response size to prevent memory exhaustion and match LLM token limits
+ # 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
+ MAX_RESPONSE_SIZE = 1.megabyte
+
+ def execute_http_request(url, body)
+ uri = URI.parse(url)
+
+ # Check if resolved IP is private
+ check_private_ip!(uri.host)
+
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = uri.scheme == 'https'
+ http.read_timeout = 30
+ http.open_timeout = 10
+ http.max_retries = 0 # Disable redirects
+
+ request = build_http_request(uri, body)
+ apply_authentication(request)
+
+ response = http.request(request)
+
+ raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
+
+ validate_response!(response)
+
+ response
+ end
+
+ def check_private_ip!(hostname)
+ ip_address = IPAddr.new(Resolv.getaddress(hostname))
+
+ raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) }
+ rescue Resolv::ResolvError, SocketError => e
+ raise "DNS resolution failed: #{e.message}"
+ end
+
+ def validate_response!(response)
+ content_length = response['content-length']&.to_i
+ if content_length && content_length > MAX_RESPONSE_SIZE
+ raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
+ end
+
+ return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE
+
+ raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
+ end
+
+ def build_http_request(uri, body)
+ if @custom_tool.http_method == 'POST'
+ request = Net::HTTP::Post.new(uri.request_uri)
+ if body
+ request.body = body
+ request['Content-Type'] = 'application/json'
+ end
+ else
+ request = Net::HTTP::Get.new(uri.request_uri)
+ end
+ request
+ end
+
+ def apply_authentication(request)
+ headers = @custom_tool.build_auth_headers
+ headers.each { |key, value| request[key] = value }
+
+ credentials = @custom_tool.build_basic_auth_credentials
+ request.basic_auth(*credentials) if credentials
+ end
+end
diff --git a/lib/integrations/slack/slack_message_helper.rb b/lib/integrations/slack/slack_message_helper.rb
index 52ec4caad..0ee328fb3 100644
--- a/lib/integrations/slack/slack_message_helper.rb
+++ b/lib/integrations/slack/slack_message_helper.rb
@@ -70,7 +70,9 @@ module Integrations::Slack::SlackMessageHelper
case attachment[:filetype]
when 'png', 'jpeg', 'gif', 'bmp', 'tiff', 'jpg'
:image
- when 'pdf'
+ when 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'
+ :video
+ else
:file
end
end
diff --git a/lib/limits.rb b/lib/limits.rb
index 7a2371207..c0fc03806 100644
--- a/lib/limits.rb
+++ b/lib/limits.rb
@@ -6,6 +6,9 @@ module Limits
GREETING_MESSAGE_MAX_LENGTH = 10_000
CATEGORIES_PER_PAGE = 1000
AUTO_ASSIGNMENT_BULK_LIMIT = 100
+ COMPANY_NAME_LENGTH_LIMIT = 100
+ COMPANY_DESCRIPTION_LENGTH_LIMIT = 1000
+ MAX_CUSTOM_FILTERS_PER_USER = 1000
def self.conversation_message_per_minute_limit
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
diff --git a/lib/seeders/reports/conversation_creator.rb b/lib/seeders/reports/conversation_creator.rb
index b6259de7d..1cd11ef33 100644
--- a/lib/seeders/reports/conversation_creator.rb
+++ b/lib/seeders/reports/conversation_creator.rb
@@ -16,8 +16,11 @@ class Seeders::Reports::ConversationCreator
@priorities = [nil, 'urgent', 'high', 'medium', 'low']
end
+ # rubocop:disable Metrics/MethodLength
def create_conversation(created_at:)
conversation = nil
+ should_resolve = false
+ resolution_time = nil
ActiveRecord::Base.transaction do
travel_to(created_at) do
@@ -26,14 +29,35 @@ class Seeders::Reports::ConversationCreator
add_labels_to_conversation(conversation)
create_messages_for_conversation(conversation)
- resolve_conversation_if_needed(conversation)
+
+ # Determine if should resolve but don't update yet
+ should_resolve = rand > 0.3
+ if should_resolve
+ resolution_delay = rand((30.minutes)..(24.hours))
+ resolution_time = created_at + resolution_delay
+ end
end
travel_back
end
+ # Now resolve outside of time travel if needed
+ if should_resolve && resolution_time
+ # rubocop:disable Rails/SkipsModelValidations
+ conversation.update_column(:status, :resolved)
+ conversation.update_column(:updated_at, resolution_time)
+ # rubocop:enable Rails/SkipsModelValidations
+
+ # Trigger the event with proper timestamp
+ travel_to(resolution_time) do
+ trigger_conversation_resolved_event(conversation)
+ end
+ travel_back
+ end
+
conversation
end
+ # rubocop:enable Metrics/MethodLength
private
@@ -85,16 +109,6 @@ class Seeders::Reports::ConversationCreator
message_creator.create_messages
end
- def resolve_conversation_if_needed(conversation)
- return unless rand < 0.7
-
- resolution_delay = rand((30.minutes)..(24.hours))
- travel(resolution_delay)
- conversation.update!(status: :resolved)
-
- trigger_conversation_resolved_event(conversation)
- end
-
def trigger_conversation_resolved_event(conversation)
event_data = { conversation: conversation }
diff --git a/lib/tasks/captain_chat.rake b/lib/tasks/captain_chat.rake
index cfe257196..6dfb37211 100644
--- a/lib/tasks/captain_chat.rake
+++ b/lib/tasks/captain_chat.rake
@@ -118,7 +118,7 @@ class CaptainChatSession
end
def show_available_tools
- available_tools = Captain::Assistant.available_tool_ids
+ available_tools = @assistant.available_tool_ids
if available_tools.any?
puts "🔧 Available Tools (#{available_tools.count}): #{available_tools.join(', ')}"
else
diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb
index 41b3a415d..95c399d54 100644
--- a/lib/webhooks/trigger.rb
+++ b/lib/webhooks/trigger.rb
@@ -31,14 +31,33 @@ class Webhooks::Trigger
end
def handle_error(error)
- return unless should_handle_error?
+ return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
return unless message
- update_message_status(error)
+ case @webhook_type
+ when :agent_bot_webhook
+ conversation = message.conversation
+ return unless conversation&.pending?
+
+ conversation.open!
+ create_agent_bot_error_activity(conversation)
+ when :api_inbox_webhook
+ update_message_status(error)
+ end
end
- def should_handle_error?
- @webhook_type == :api_inbox_webhook && SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
+ def create_agent_bot_error_activity(conversation)
+ content = I18n.t('conversations.activity.agent_bot.error_moved_to_open')
+ Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content))
+ end
+
+ def activity_message_params(conversation, content)
+ {
+ account_id: conversation.account_id,
+ inbox_id: conversation.inbox_id,
+ message_type: :activity,
+ content: content
+ }
end
def update_message_status(error)
diff --git a/package.json b/package.json
index 38053b2c3..6fb7156cb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
- "version": "4.6.0",
+ "version": "4.7.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -139,7 +139,7 @@
"prosemirror-model": "^1.22.3",
"size-limit": "^8.2.4",
"tailwindcss": "^3.4.13",
- "vite": "^5.4.20",
+ "vite": "^5.4.21",
"vite-plugin-ruby": "^5.0.0",
"vitest": "3.0.5"
},
@@ -155,7 +155,7 @@
"pnpm": {
"overrides": {
"vite-node": "2.0.1",
- "vite": "5.4.20",
+ "vite": "5.4.21",
"vitest": "3.0.5"
}
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f4ae568c5..22c662d89 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6,7 +6,7 @@ settings:
overrides:
vite-node: 2.0.1
- vite: 5.4.20
+ vite: 5.4.21
vitest: 3.0.5
importers:
@@ -69,7 +69,7 @@ importers:
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
'@vitejs/plugin-vue':
specifier: ^5.1.4
- version: 5.1.4(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
+ version: 5.1.4(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
'@vue/compiler-sfc':
specifier: ^3.5.8
version: 3.5.8
@@ -241,7 +241,7 @@ importers:
version: 1.8.1(tailwindcss@3.4.13)
'@histoire/plugin-vue':
specifier: 0.17.15
- version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
+ version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
'@iconify-json/logos':
specifier: ^1.2.3
version: 1.2.3
@@ -304,7 +304,7 @@ importers:
version: 6.0.0
histoire:
specifier: 0.17.15
- version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
husky:
specifier: ^7.0.0
version: 7.0.4
@@ -333,11 +333,11 @@ importers:
specifier: ^3.4.13
version: 3.4.13
vite:
- specifier: 5.4.20
- version: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ specifier: 5.4.21
+ version: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite-plugin-ruby:
specifier: ^5.0.0
- version: 5.0.0(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ version: 5.0.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
vitest:
specifier: 3.0.5
version: 3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0)
@@ -863,7 +863,7 @@ packages:
'@histoire/shared@0.17.17':
resolution: {integrity: sha512-ueGtURysonT0MujCObPCR57+mgZluMEXCrbc2FBgKAD/DoAt38tNwSGsmLldk2O6nTr7lr6ClbVSgWrLwgY6Xw==}
peerDependencies:
- vite: 5.4.20
+ vite: 5.4.21
'@histoire/vendors@0.17.17':
resolution: {integrity: sha512-QZvmffdoJlLuYftPIkOU5Q2FPAdG2JjMuQ5jF7NmEl0n1XnmbMqtRkdYTZ4eF6CO1KLZ0Zyf6gBQvoT1uWNcjA==}
@@ -978,8 +978,8 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
- '@jridgewell/trace-mapping@0.3.30':
- resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kurkle/color@0.3.2':
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
@@ -1047,108 +1047,113 @@ packages:
'@rails/ujs@7.1.400':
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
- '@rollup/rollup-android-arm-eabi@4.50.1':
- resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==}
+ '@rollup/rollup-android-arm-eabi@4.52.5':
+ resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==}
cpu: [arm]
os: [android]
- '@rollup/rollup-android-arm64@4.50.1':
- resolution: {integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==}
+ '@rollup/rollup-android-arm64@4.52.5':
+ resolution: {integrity: sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==}
cpu: [arm64]
os: [android]
- '@rollup/rollup-darwin-arm64@4.50.1':
- resolution: {integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==}
+ '@rollup/rollup-darwin-arm64@4.52.5':
+ resolution: {integrity: sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==}
cpu: [arm64]
os: [darwin]
- '@rollup/rollup-darwin-x64@4.50.1':
- resolution: {integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==}
+ '@rollup/rollup-darwin-x64@4.52.5':
+ resolution: {integrity: sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==}
cpu: [x64]
os: [darwin]
- '@rollup/rollup-freebsd-arm64@4.50.1':
- resolution: {integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==}
+ '@rollup/rollup-freebsd-arm64@4.52.5':
+ resolution: {integrity: sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==}
cpu: [arm64]
os: [freebsd]
- '@rollup/rollup-freebsd-x64@4.50.1':
- resolution: {integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==}
+ '@rollup/rollup-freebsd-x64@4.52.5':
+ resolution: {integrity: sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==}
cpu: [x64]
os: [freebsd]
- '@rollup/rollup-linux-arm-gnueabihf@4.50.1':
- resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==}
+ '@rollup/rollup-linux-arm-gnueabihf@4.52.5':
+ resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==}
cpu: [arm]
os: [linux]
- '@rollup/rollup-linux-arm-musleabihf@4.50.1':
- resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==}
+ '@rollup/rollup-linux-arm-musleabihf@4.52.5':
+ resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==}
cpu: [arm]
os: [linux]
- '@rollup/rollup-linux-arm64-gnu@4.50.1':
- resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==}
+ '@rollup/rollup-linux-arm64-gnu@4.52.5':
+ resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==}
cpu: [arm64]
os: [linux]
- '@rollup/rollup-linux-arm64-musl@4.50.1':
- resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==}
+ '@rollup/rollup-linux-arm64-musl@4.52.5':
+ resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==}
cpu: [arm64]
os: [linux]
- '@rollup/rollup-linux-loongarch64-gnu@4.50.1':
- resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==}
+ '@rollup/rollup-linux-loong64-gnu@4.52.5':
+ resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==}
cpu: [loong64]
os: [linux]
- '@rollup/rollup-linux-ppc64-gnu@4.50.1':
- resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==}
+ '@rollup/rollup-linux-ppc64-gnu@4.52.5':
+ resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==}
cpu: [ppc64]
os: [linux]
- '@rollup/rollup-linux-riscv64-gnu@4.50.1':
- resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==}
+ '@rollup/rollup-linux-riscv64-gnu@4.52.5':
+ resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==}
cpu: [riscv64]
os: [linux]
- '@rollup/rollup-linux-riscv64-musl@4.50.1':
- resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==}
+ '@rollup/rollup-linux-riscv64-musl@4.52.5':
+ resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==}
cpu: [riscv64]
os: [linux]
- '@rollup/rollup-linux-s390x-gnu@4.50.1':
- resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==}
+ '@rollup/rollup-linux-s390x-gnu@4.52.5':
+ resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==}
cpu: [s390x]
os: [linux]
- '@rollup/rollup-linux-x64-gnu@4.50.1':
- resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==}
+ '@rollup/rollup-linux-x64-gnu@4.52.5':
+ resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==}
cpu: [x64]
os: [linux]
- '@rollup/rollup-linux-x64-musl@4.50.1':
- resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==}
+ '@rollup/rollup-linux-x64-musl@4.52.5':
+ resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==}
cpu: [x64]
os: [linux]
- '@rollup/rollup-openharmony-arm64@4.50.1':
- resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==}
+ '@rollup/rollup-openharmony-arm64@4.52.5':
+ resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==}
cpu: [arm64]
os: [openharmony]
- '@rollup/rollup-win32-arm64-msvc@4.50.1':
- resolution: {integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==}
+ '@rollup/rollup-win32-arm64-msvc@4.52.5':
+ resolution: {integrity: sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==}
cpu: [arm64]
os: [win32]
- '@rollup/rollup-win32-ia32-msvc@4.50.1':
- resolution: {integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==}
+ '@rollup/rollup-win32-ia32-msvc@4.52.5':
+ resolution: {integrity: sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==}
cpu: [ia32]
os: [win32]
- '@rollup/rollup-win32-x64-msvc@4.50.1':
- resolution: {integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==}
+ '@rollup/rollup-win32-x64-gnu@4.52.5':
+ resolution: {integrity: sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.52.5':
+ resolution: {integrity: sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==}
cpu: [x64]
os: [win32]
@@ -1283,7 +1288,7 @@ packages:
resolution: {integrity: sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==}
engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies:
- vite: 5.4.20
+ vite: 5.4.21
vue: ^3.2.25
'@vitest/coverage-v8@3.0.5':
@@ -1302,7 +1307,7 @@ packages:
resolution: {integrity: sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==}
peerDependencies:
msw: ^2.4.9
- vite: 5.4.20
+ vite: 5.4.21
peerDependenciesMeta:
msw:
optional: true
@@ -2600,7 +2605,7 @@ packages:
resolution: {integrity: sha512-DiRMSIgj340z+zikqf0f3Pj0CTv2/xtdBMBIAO1EARat+QXxMwumbfK41Gi7f9IIBr+UVmomNcwFxVY2EM/vrw==}
hasBin: true
peerDependencies:
- vite: 5.4.20
+ vite: 5.4.21
hotkeys-js@3.8.7:
resolution: {integrity: sha512-ckAx3EkUr5XjDwjEHDorHxRO2Kb7z6Z2Sxul4MbBkN8Nho7XDslQsgMJT+CiJ5Z4TgRxxvKHEpuLE3imzqy4Lg==}
@@ -3872,8 +3877,8 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
- rollup@4.50.1:
- resolution: {integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==}
+ rollup@4.52.5:
+ resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -4374,10 +4379,10 @@ packages:
vite-plugin-ruby@5.0.0:
resolution: {integrity: sha512-c8PjTp21Ah/ttgnNUyu0qvCXZI08Jr9I24oUKg3TRIRhF5GcOZ++6wtlTCrNFd9COEQbpXHxlRIXd/MEg0iZJw==}
peerDependencies:
- vite: 5.4.20
+ vite: 5.4.21
- vite@5.4.20:
- resolution: {integrity: sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==}
+ vite@5.4.21:
+ resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -5198,10 +5203,10 @@ snapshots:
highlight.js: 11.10.0
vue: 3.5.12(typescript@5.6.2)
- '@histoire/app@0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
+ '@histoire/app@0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
- '@histoire/controls': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
- '@histoire/shared': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/controls': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/shared': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
'@types/flexsearch': 0.7.6
flexsearch: 0.7.21
@@ -5209,7 +5214,7 @@ snapshots:
transitivePeerDependencies:
- vite
- '@histoire/controls@0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
+ '@histoire/controls@0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@codemirror/commands': 6.7.0
'@codemirror/lang-json': 6.0.1
@@ -5218,26 +5223,26 @@ snapshots:
'@codemirror/state': 6.4.1
'@codemirror/theme-one-dark': 6.1.2
'@codemirror/view': 6.34.1
- '@histoire/shared': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/shared': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
transitivePeerDependencies:
- vite
- '@histoire/plugin-vue@0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
+ '@histoire/plugin-vue@0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
dependencies:
- '@histoire/controls': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
- '@histoire/shared': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/controls': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/shared': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
change-case: 4.1.2
globby: 13.2.2
- histoire: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ histoire: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
launch-editor: 2.9.1
pathe: 1.1.2
vue: 3.5.12(typescript@5.6.2)
transitivePeerDependencies:
- vite
- '@histoire/shared@0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
+ '@histoire/shared@0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@histoire/vendors': 0.17.17
'@types/fs-extra': 9.0.13
@@ -5245,7 +5250,7 @@ snapshots:
chokidar: 3.6.0
pathe: 1.1.2
picocolors: 1.1.0
- vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
'@histoire/vendors@0.17.17': {}
@@ -5362,7 +5367,7 @@ snapshots:
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
- '@jridgewell/trace-mapping': 0.3.30
+ '@jridgewell/trace-mapping': 0.3.31
optional: true
'@jridgewell/gen-mapping@0.3.5':
@@ -5387,7 +5392,7 @@ snapshots:
'@jridgewell/source-map@0.3.11':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
- '@jridgewell/trace-mapping': 0.3.30
+ '@jridgewell/trace-mapping': 0.3.31
optional: true
'@jridgewell/sourcemap-codec@1.5.0': {}
@@ -5400,7 +5405,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.1
'@jridgewell/sourcemap-codec': 1.5.0
- '@jridgewell/trace-mapping@0.3.30':
+ '@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
@@ -5466,67 +5471,70 @@ snapshots:
'@rails/ujs@7.1.400': {}
- '@rollup/rollup-android-arm-eabi@4.50.1':
+ '@rollup/rollup-android-arm-eabi@4.52.5':
optional: true
- '@rollup/rollup-android-arm64@4.50.1':
+ '@rollup/rollup-android-arm64@4.52.5':
optional: true
- '@rollup/rollup-darwin-arm64@4.50.1':
+ '@rollup/rollup-darwin-arm64@4.52.5':
optional: true
- '@rollup/rollup-darwin-x64@4.50.1':
+ '@rollup/rollup-darwin-x64@4.52.5':
optional: true
- '@rollup/rollup-freebsd-arm64@4.50.1':
+ '@rollup/rollup-freebsd-arm64@4.52.5':
optional: true
- '@rollup/rollup-freebsd-x64@4.50.1':
+ '@rollup/rollup-freebsd-x64@4.52.5':
optional: true
- '@rollup/rollup-linux-arm-gnueabihf@4.50.1':
+ '@rollup/rollup-linux-arm-gnueabihf@4.52.5':
optional: true
- '@rollup/rollup-linux-arm-musleabihf@4.50.1':
+ '@rollup/rollup-linux-arm-musleabihf@4.52.5':
optional: true
- '@rollup/rollup-linux-arm64-gnu@4.50.1':
+ '@rollup/rollup-linux-arm64-gnu@4.52.5':
optional: true
- '@rollup/rollup-linux-arm64-musl@4.50.1':
+ '@rollup/rollup-linux-arm64-musl@4.52.5':
optional: true
- '@rollup/rollup-linux-loongarch64-gnu@4.50.1':
+ '@rollup/rollup-linux-loong64-gnu@4.52.5':
optional: true
- '@rollup/rollup-linux-ppc64-gnu@4.50.1':
+ '@rollup/rollup-linux-ppc64-gnu@4.52.5':
optional: true
- '@rollup/rollup-linux-riscv64-gnu@4.50.1':
+ '@rollup/rollup-linux-riscv64-gnu@4.52.5':
optional: true
- '@rollup/rollup-linux-riscv64-musl@4.50.1':
+ '@rollup/rollup-linux-riscv64-musl@4.52.5':
optional: true
- '@rollup/rollup-linux-s390x-gnu@4.50.1':
+ '@rollup/rollup-linux-s390x-gnu@4.52.5':
optional: true
- '@rollup/rollup-linux-x64-gnu@4.50.1':
+ '@rollup/rollup-linux-x64-gnu@4.52.5':
optional: true
- '@rollup/rollup-linux-x64-musl@4.50.1':
+ '@rollup/rollup-linux-x64-musl@4.52.5':
optional: true
- '@rollup/rollup-openharmony-arm64@4.50.1':
+ '@rollup/rollup-openharmony-arm64@4.52.5':
optional: true
- '@rollup/rollup-win32-arm64-msvc@4.50.1':
+ '@rollup/rollup-win32-arm64-msvc@4.52.5':
optional: true
- '@rollup/rollup-win32-ia32-msvc@4.50.1':
+ '@rollup/rollup-win32-ia32-msvc@4.52.5':
optional: true
- '@rollup/rollup-win32-x64-msvc@4.50.1':
+ '@rollup/rollup-win32-x64-gnu@4.52.5':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.52.5':
optional: true
'@rtsao/scc@1.1.0': {}
@@ -5677,9 +5685,9 @@ snapshots:
global: 4.4.0
is-function: 1.0.2
- '@vitejs/plugin-vue@5.1.4(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
+ '@vitejs/plugin-vue@5.1.4(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
dependencies:
- vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vue: 3.5.12(typescript@5.6.2)
'@vitest/coverage-v8@3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0))':
@@ -5707,13 +5715,13 @@ snapshots:
chai: 5.1.2
tinyrainbow: 2.0.0
- '@vitest/mocker@3.0.5(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
+ '@vitest/mocker@3.0.5(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
dependencies:
'@vitest/spy': 3.0.5
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
- vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
'@vitest/pretty-format@3.0.5':
dependencies:
@@ -7285,12 +7293,12 @@ snapshots:
highlight.js@11.10.0: {}
- histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
+ histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
dependencies:
'@akryum/tinypool': 0.3.1
- '@histoire/app': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
- '@histoire/controls': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
- '@histoire/shared': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/app': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/controls': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@histoire/shared': 0.17.17(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@histoire/vendors': 0.17.17
'@types/flexsearch': 0.7.6
'@types/markdown-it': 12.2.3
@@ -7317,7 +7325,7 @@ snapshots:
sade: 1.8.1
shiki-es: 0.2.0
sirv: 2.0.4
- vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
- '@types/node'
@@ -8682,31 +8690,32 @@ snapshots:
dependencies:
glob: 7.2.3
- rollup@4.50.1:
+ rollup@4.52.5:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
- '@rollup/rollup-android-arm-eabi': 4.50.1
- '@rollup/rollup-android-arm64': 4.50.1
- '@rollup/rollup-darwin-arm64': 4.50.1
- '@rollup/rollup-darwin-x64': 4.50.1
- '@rollup/rollup-freebsd-arm64': 4.50.1
- '@rollup/rollup-freebsd-x64': 4.50.1
- '@rollup/rollup-linux-arm-gnueabihf': 4.50.1
- '@rollup/rollup-linux-arm-musleabihf': 4.50.1
- '@rollup/rollup-linux-arm64-gnu': 4.50.1
- '@rollup/rollup-linux-arm64-musl': 4.50.1
- '@rollup/rollup-linux-loongarch64-gnu': 4.50.1
- '@rollup/rollup-linux-ppc64-gnu': 4.50.1
- '@rollup/rollup-linux-riscv64-gnu': 4.50.1
- '@rollup/rollup-linux-riscv64-musl': 4.50.1
- '@rollup/rollup-linux-s390x-gnu': 4.50.1
- '@rollup/rollup-linux-x64-gnu': 4.50.1
- '@rollup/rollup-linux-x64-musl': 4.50.1
- '@rollup/rollup-openharmony-arm64': 4.50.1
- '@rollup/rollup-win32-arm64-msvc': 4.50.1
- '@rollup/rollup-win32-ia32-msvc': 4.50.1
- '@rollup/rollup-win32-x64-msvc': 4.50.1
+ '@rollup/rollup-android-arm-eabi': 4.52.5
+ '@rollup/rollup-android-arm64': 4.52.5
+ '@rollup/rollup-darwin-arm64': 4.52.5
+ '@rollup/rollup-darwin-x64': 4.52.5
+ '@rollup/rollup-freebsd-arm64': 4.52.5
+ '@rollup/rollup-freebsd-x64': 4.52.5
+ '@rollup/rollup-linux-arm-gnueabihf': 4.52.5
+ '@rollup/rollup-linux-arm-musleabihf': 4.52.5
+ '@rollup/rollup-linux-arm64-gnu': 4.52.5
+ '@rollup/rollup-linux-arm64-musl': 4.52.5
+ '@rollup/rollup-linux-loong64-gnu': 4.52.5
+ '@rollup/rollup-linux-ppc64-gnu': 4.52.5
+ '@rollup/rollup-linux-riscv64-gnu': 4.52.5
+ '@rollup/rollup-linux-riscv64-musl': 4.52.5
+ '@rollup/rollup-linux-s390x-gnu': 4.52.5
+ '@rollup/rollup-linux-x64-gnu': 4.52.5
+ '@rollup/rollup-linux-x64-musl': 4.52.5
+ '@rollup/rollup-openharmony-arm64': 4.52.5
+ '@rollup/rollup-win32-arm64-msvc': 4.52.5
+ '@rollup/rollup-win32-ia32-msvc': 4.52.5
+ '@rollup/rollup-win32-x64-gnu': 4.52.5
+ '@rollup/rollup-win32-x64-msvc': 4.52.5
fsevents: 2.3.3
rope-sequence@1.3.2: {}
@@ -9285,7 +9294,7 @@ snapshots:
debug: 4.4.0
pathe: 1.1.2
picocolors: 1.1.1
- vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
- '@types/node'
- less
@@ -9297,19 +9306,19 @@ snapshots:
- supports-color
- terser
- vite-plugin-ruby@5.0.0(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
+ vite-plugin-ruby@5.0.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
dependencies:
debug: 4.3.5
fast-glob: 3.3.2
- vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
- supports-color
- vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
+ vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.6
- rollup: 4.50.1
+ rollup: 4.52.5
optionalDependencies:
'@types/node': 22.7.0
fsevents: 2.3.3
@@ -9319,7 +9328,7 @@ snapshots:
vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0):
dependencies:
'@vitest/expect': 3.0.5
- '@vitest/mocker': 3.0.5(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
+ '@vitest/mocker': 3.0.5(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
'@vitest/pretty-format': 3.0.5
'@vitest/runner': 3.0.5
'@vitest/snapshot': 3.0.5
@@ -9335,7 +9344,7 @@ snapshots:
tinyexec: 0.3.2
tinypool: 1.0.2
tinyrainbow: 2.0.0
- vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
+ vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
why-is-node-running: 2.3.0
optionalDependencies:
diff --git a/script/bulk_reindex_messages.rb b/script/bulk_reindex_messages.rb
new file mode 100644
index 000000000..1e19f70a7
--- /dev/null
+++ b/script/bulk_reindex_messages.rb
@@ -0,0 +1,58 @@
+# Bulk reindex all messages with throttling to prevent DB overload
+# This creates jobs slowly to avoid overwhelming the database connection pool
+# Usage: RAILS_ENV=production POSTGRES_STATEMENT_TIMEOUT=6000s bundle exec rails runner script/bulk_reindex_messages.rb
+
+JOBS_PER_MINUTE = 50 # Adjust based on your DB capacity
+BATCH_SIZE = 1000 # Messages per job
+
+batch_count = 0
+total_batches = (Message.count / BATCH_SIZE.to_f).ceil
+start_time = Time.zone.now
+
+index_name = Message.searchkick_index.name
+
+puts '=' * 80
+puts "Bulk Reindex Started at #{start_time}"
+puts '=' * 80
+puts "Total messages: #{Message.count}"
+puts "Batch size: #{BATCH_SIZE}"
+puts "Total batches: #{total_batches}"
+puts "Index name: #{index_name}"
+puts "Rate: #{JOBS_PER_MINUTE} jobs/minute (#{JOBS_PER_MINUTE * BATCH_SIZE} messages/minute)"
+puts "Estimated time: #{(total_batches / JOBS_PER_MINUTE.to_f / 60).round(2)} hours"
+puts '=' * 80
+puts ''
+
+sleep(15)
+
+Message.find_in_batches(batch_size: BATCH_SIZE).with_index do |batch, index|
+ batch_count += 1
+
+ # Enqueue to low priority queue with proper format
+ Searchkick::BulkReindexJob.set(queue: :bulk_reindex_low).perform_later(
+ class_name: 'Message',
+ index_name: index_name,
+ batch_id: index,
+ record_ids: batch.map(&:id) # Keep as integers like Message.reindex does
+ )
+
+ # Throttle: wait after every N jobs
+ if (batch_count % JOBS_PER_MINUTE).zero?
+ elapsed = Time.zone.now - start_time
+ progress = (batch_count.to_f / total_batches * 100).round(2)
+ queue_size = Sidekiq::Queue.new('bulk_reindex_low').size
+
+ puts "[#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}] Progress: #{batch_count}/#{total_batches} (#{progress}%)"
+ puts " Queue size: #{queue_size}"
+ puts " Elapsed: #{(elapsed / 3600).round(2)} hours"
+ puts " ETA: #{((elapsed / batch_count * (total_batches - batch_count)) / 3600).round(2)} hours remaining"
+ puts ''
+
+ sleep(60)
+ end
+end
+
+puts '=' * 80
+puts "Done! Created #{batch_count} jobs"
+puts "Total time: #{((Time.zone.now - start_time) / 3600).round(2)} hours"
+puts '=' * 80
diff --git a/script/monitor_reindex.rb b/script/monitor_reindex.rb
new file mode 100644
index 000000000..6a2c1ee6c
--- /dev/null
+++ b/script/monitor_reindex.rb
@@ -0,0 +1,19 @@
+# Monitor bulk reindex progress
+# RAILS_ENV=production bundle exec rails runner script/monitor_reindex.rb
+
+puts 'Monitoring bulk reindex progress (Ctrl+C to stop)...'
+puts ''
+
+loop do
+ bulk_queue = Sidekiq::Queue.new('bulk_reindex_low')
+ prod_queue = Sidekiq::Queue.new('async_database_migration')
+ retry_set = Sidekiq::RetrySet.new
+
+ puts "[#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}]"
+ puts " Bulk Reindex Queue: #{bulk_queue.size} jobs"
+ puts " Production Queue: #{prod_queue.size} jobs"
+ puts " Retry Queue: #{retry_set.size} jobs"
+ puts " #{('-' * 60)}"
+
+ sleep(30)
+end
diff --git a/script/reindex_single_account.rb b/script/reindex_single_account.rb
new file mode 100644
index 000000000..cb7dd8c87
--- /dev/null
+++ b/script/reindex_single_account.rb
@@ -0,0 +1,58 @@
+# Reindex messages for a single account
+# Usage: bundle exec rails runner script/reindex_single_account.rb ACCOUNT_ID [DAYS_BACK]
+
+#account_id = ARGV[0]&.to_i
+days_back = (ARGV[1] || 30).to_i
+
+# if account_id.nil? || account_id.zero?
+# puts "Usage: bundle exec rails runner script/reindex_single_account.rb ACCOUNT_ID [DAYS_BACK]"
+# puts "Example: bundle exec rails runner script/reindex_single_account.rb 93293 30"
+# exit 1
+# end
+
+# account = Account.find(account_id)
+# puts "=" * 80
+# puts "Reindexing messages for: #{account.name} (ID: #{account.id})"
+# puts "=" * 80
+
+# Enable feature if not already enabled
+# unless account.feature_enabled?('advanced_search_indexing')
+# puts "Enabling advanced_search_indexing feature..."
+# account.enable_features(:advanced_search_indexing)
+# account.save!
+# end
+
+# Get messages to index
+# messages = Message.where(account_id: account.id)
+# .where(message_type: [0, 1]) # incoming/outgoing only
+# .where('created_at >= ?', days_back.days.ago)
+
+messages = Message.where('created_at >= ?', days_back.days.ago)
+
+puts "Found #{messages.count} messages to index (last #{days_back} days)"
+puts ''
+
+sleep(15)
+
+# Create bulk reindex jobs
+index_name = Message.searchkick_index.name
+batch_count = 0
+
+messages.find_in_batches(batch_size: 1000).with_index do |batch, index|
+ Searchkick::BulkReindexJob.set(queue: :bulk_reindex_low).perform_later(
+ class_name: 'Message',
+ index_name: index_name,
+ batch_id: index,
+ record_ids: batch.map(&:id)
+ )
+
+ batch_count += 1
+ print '.'
+ sleep(0.5) # Small delay
+end
+
+puts ''
+puts '=' * 80
+puts "Done! Created #{batch_count} bulk reindex jobs"
+puts 'Messages will be indexed shortly via the bulk_reindex_low queue'
+puts '=' * 80
diff --git a/spec/builders/messages/message_builder_spec.rb b/spec/builders/messages/message_builder_spec.rb
index 891f8eb02..2eb4dbf90 100644
--- a/spec/builders/messages/message_builder_spec.rb
+++ b/spec/builders/messages/message_builder_spec.rb
@@ -179,6 +179,63 @@ describe Messages::MessageBuilder do
expect(message.content_attributes[:cc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com']
expect(message.content_attributes[:bcc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com']
end
+
+ context 'when custom email content is provided' do
+ before do
+ account.enable_features('quoted_email_reply')
+ end
+
+ it 'creates message with custom HTML email content' do
+ params = ActionController::Parameters.new({
+ content: 'Regular message content',
+ email_html_content: 'Custom HTML content
'
+ })
+
+ message = described_class.new(user, conversation, params).perform
+
+ expect(message.content_attributes.dig('email', 'html_content', 'full')).to eq 'Custom HTML content
'
+ expect(message.content_attributes.dig('email', 'html_content', 'reply')).to eq 'Custom HTML content
'
+ expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular message content'
+ expect(message.content_attributes.dig('email', 'text_content', 'reply')).to eq 'Regular message content'
+ end
+
+ it 'does not process custom email content when quoted_email_reply feature is disabled' do
+ account.disable_features('quoted_email_reply')
+ params = ActionController::Parameters.new({
+ content: 'Regular message content',
+ email_html_content: 'Custom HTML content
'
+ })
+
+ message = described_class.new(user, conversation, params).perform
+
+ expect(message.content_attributes.dig('email', 'html_content')).to be_nil
+ expect(message.content_attributes.dig('email', 'text_content')).to be_nil
+ end
+
+ it 'does not process custom email content for private messages' do
+ params = ActionController::Parameters.new({
+ content: 'Regular message content',
+ email_html_content: 'Custom HTML content
',
+ private: true
+ })
+
+ message = described_class.new(user, conversation, params).perform
+
+ expect(message.content_attributes.dig('email', 'html_content')).to be_nil
+ expect(message.content_attributes.dig('email', 'text_content')).to be_nil
+ end
+
+ it 'falls back to default behavior when no custom email content is provided' do
+ params = ActionController::Parameters.new({
+ content: 'Regular **markdown** content'
+ })
+
+ message = described_class.new(user, conversation, params).perform
+
+ expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('markdown')
+ expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular **markdown** content'
+ end
+ end
end
end
end
diff --git a/spec/controllers/api/base_controller_spec.rb b/spec/controllers/api/base_controller_spec.rb
index ac2306d14..69e4f5cae 100644
--- a/spec/controllers/api/base_controller_spec.rb
+++ b/spec/controllers/api/base_controller_spec.rb
@@ -5,6 +5,29 @@ RSpec.describe 'API Base', type: :request do
let!(:user) { create(:user, account: account) }
describe 'request with api_access_token for user' do
+ context 'when accessing an account scoped resource' do
+ let!(:admin) { create(:user, :administrator, account: account) }
+ let!(:conversation) { create(:conversation, account: account) }
+
+ it 'sets Current attributes for the request and then returns the response' do
+ # expect Current.account_user is set to the admin's account_user
+ allow(Current).to receive(:user=).and_call_original
+ allow(Current).to receive(:account=).and_call_original
+ allow(Current).to receive(:account_user=).and_call_original
+
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
+ headers: { api_access_token: admin.access_token.token },
+ as: :json
+
+ expect(Current).to have_received(:user=).with(admin).at_least(:once)
+ expect(Current).to have_received(:account=).with(account).at_least(:once)
+ expect(Current).to have_received(:account_user=).with(admin.account_users.first).at_least(:once)
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['id']).to eq(conversation.display_id)
+ end
+ end
+
context 'when it is an invalid api_access_token' do
it 'returns unauthorized' do
get '/api/v1/profile',
diff --git a/spec/controllers/api/v1/accounts/custom_filters_controller_spec.rb b/spec/controllers/api/v1/accounts/custom_filters_controller_spec.rb
index 760100cf3..9c9469090 100644
--- a/spec/controllers/api/v1/accounts/custom_filters_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/custom_filters_controller_spec.rb
@@ -93,9 +93,9 @@ RSpec.describe 'Custom Filters API', type: :request do
expect(json_response['name']).to eq 'vip-customers'
end
- it 'gives the error for 51st record' do
+ it 'gives the error for 1001st record' do
CustomFilter.delete_all
- CustomFilter::MAX_FILTER_PER_USER.times do
+ Limits::MAX_CUSTOM_FILTERS_PER_USER.times do
create(:custom_filter, user: user, account: account)
end
@@ -107,7 +107,7 @@ RSpec.describe 'Custom Filters API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['message']).to include(
- 'Account Limit reached. The maximum number of allowed custom filters for a user per account is 50.'
+ 'Account Limit reached. The maximum number of allowed custom filters for a user per account is 1000.'
)
end
end
diff --git a/spec/controllers/api/v2/accounts/reports_controller_spec.rb b/spec/controllers/api/v2/accounts/reports_controller_spec.rb
index 4dbbcf406..2d505822f 100644
--- a/spec/controllers/api/v2/accounts/reports_controller_spec.rb
+++ b/spec/controllers/api/v2/accounts/reports_controller_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
context 'when authenticated and authorized' do
before do
# Create conversations across 24 hours at different times
- base_time = Time.utc(2024, 1, 15, 0, 0) # Start at midnight UTC
+ base_time = Time.utc(2024, 1, 14, 23, 0) # Start at 23:00 to span 2 days
# Create conversations every 4 hours across 24 hours
6.times do |i|
@@ -23,36 +23,38 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
end
it 'timezone_offset affects data grouping and timestamps correctly' do
- Time.use_zone('UTC') do
- base_time = Time.utc(2024, 1, 15, 0, 0)
- base_params = {
- metric: 'conversations_count',
- type: 'account',
- since: (base_time - 1.day).to_i.to_s,
- until: (base_time + 2.days).to_i.to_s,
- group_by: 'day'
- }
+ travel_to Time.utc(2024, 1, 15, 12, 0) do
+ Time.use_zone('UTC') do
+ base_time = Time.utc(2024, 1, 14, 23, 0) # Start at 23:00 to span 2 days
+ base_params = {
+ metric: 'conversations_count',
+ type: 'account',
+ since: (base_time - 1.day).to_i.to_s,
+ until: (base_time + 2.days).to_i.to_s,
+ group_by: 'day'
+ }
- responses = [0, -8, 9].map do |offset|
- get "/api/v2/accounts/#{account.id}/reports",
- params: base_params.merge(timezone_offset: offset),
- headers: admin.create_new_auth_token, as: :json
- response.parsed_body
+ responses = [0, -8, 9].map do |offset|
+ get "/api/v2/accounts/#{account.id}/reports",
+ params: base_params.merge(timezone_offset: offset),
+ headers: admin.create_new_auth_token, as: :json
+ response.parsed_body
+ end
+
+ data_entries = responses.map { |r| r.select { |e| e['value'] > 0 } }
+ totals = responses.map { |r| r.sum { |e| e['value'] } }
+ timestamps = responses.map { |r| r.map { |e| e['timestamp'] } }
+
+ # Data conservation and redistribution
+ expect(totals.uniq).to eq([6])
+ expect(data_entries[0].map { |e| e['value'] }).to eq([1, 5])
+ expect(data_entries[1].map { |e| e['value'] }).to eq([3, 3])
+ expect(data_entries[2].map { |e| e['value'] }).to eq([4, 2])
+
+ # Timestamp differences
+ expect(timestamps.uniq.size).to eq(3)
+ timestamps[0].zip(timestamps[1]).each { |utc, pst| expect(utc - pst).to eq(-28_800) }
end
-
- data_entries = responses.map { |r| r.select { |e| e['value'] > 0 } }
- totals = responses.map { |r| r.sum { |e| e['value'] } }
- timestamps = responses.map { |r| r.map { |e| e['timestamp'] } }
-
- # Data conservation and redistribution
- expect(totals.uniq).to eq([6])
- expect(data_entries[0].map { |e| e['value'] }).to eq([1, 5])
- expect(data_entries[1].map { |e| e['value'] }).to eq([3, 3])
- expect(data_entries[2].map { |e| e['value'] }).to eq([4, 2])
-
- # Timestamp differences
- expect(timestamps.uniq.size).to eq(3)
- timestamps[0].zip(timestamps[1]).each { |utc, pst| expect(utc - pst).to eq(-28_800) }
end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb
new file mode 100644
index 000000000..7a1526995
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb
@@ -0,0 +1,281 @@
+require 'rails_helper'
+
+RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+
+ def json_response
+ JSON.parse(response.body, symbolize_names: true)
+ end
+
+ describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools' do
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ get "/api/v1/accounts/#{account.id}/captain/custom_tools"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns success status' do
+ create_list(:captain_custom_tool, 3, account: account)
+ get "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:payload].length).to eq(3)
+ end
+ end
+
+ context 'when it is an admin' do
+ it 'returns success status and custom tools' do
+ create_list(:captain_custom_tool, 5, account: account)
+ get "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:payload].length).to eq(5)
+ end
+
+ it 'returns only enabled custom tools' do
+ create(:captain_custom_tool, account: account, enabled: true)
+ create(:captain_custom_tool, account: account, enabled: false)
+ get "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:payload].length).to eq(1)
+ expect(json_response[:payload].first[:enabled]).to be(true)
+ end
+ end
+ end
+
+ describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
+ let(:custom_tool) { create(:captain_custom_tool, account: account) }
+
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns success status and custom tool' do
+ get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:id]).to eq(custom_tool.id)
+ expect(json_response[:title]).to eq(custom_tool.title)
+ end
+ end
+
+ context 'when custom tool does not exist' do
+ it 'returns not found status' do
+ get "/api/v1/accounts/#{account.id}/captain/custom_tools/999999",
+ headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'POST /api/v1/accounts/{account.id}/captain/custom_tools' do
+ let(:valid_attributes) do
+ {
+ custom_tool: {
+ title: 'Fetch Order Status',
+ description: 'Fetches order status from external API',
+ endpoint_url: 'https://api.example.com/orders/{{ order_id }}',
+ http_method: 'GET',
+ enabled: true,
+ param_schema: [
+ { name: 'order_id', type: 'string', description: 'The order ID', required: true }
+ ]
+ }
+ }
+ end
+
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ post "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ params: valid_attributes
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns unauthorized status' do
+ post "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ params: valid_attributes,
+ headers: agent.create_new_auth_token
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an admin' do
+ it 'creates a new custom tool and returns success status' do
+ post "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ params: valid_attributes,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:title]).to eq('Fetch Order Status')
+ expect(json_response[:description]).to eq('Fetches order status from external API')
+ expect(json_response[:enabled]).to be(true)
+ expect(json_response[:slug]).to eq('custom_fetch_order_status')
+ expect(json_response[:param_schema]).to eq([
+ { name: 'order_id', type: 'string', description: 'The order ID', required: true }
+ ])
+ end
+
+ context 'with invalid parameters' do
+ let(:invalid_attributes) do
+ {
+ custom_tool: {
+ title: '',
+ endpoint_url: ''
+ }
+ }
+ end
+
+ it 'returns unprocessable entity status' do
+ post "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ params: invalid_attributes,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+
+ context 'with invalid endpoint URL' do
+ let(:invalid_url_attributes) do
+ {
+ custom_tool: {
+ title: 'Test Tool',
+ endpoint_url: 'http://localhost/api',
+ http_method: 'GET'
+ }
+ }
+ end
+
+ it 'returns unprocessable entity status' do
+ post "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ params: invalid_url_attributes,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+ end
+
+ describe 'PATCH /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
+ let(:custom_tool) { create(:captain_custom_tool, account: account) }
+ let(:update_attributes) do
+ {
+ custom_tool: {
+ title: 'Updated Tool Title',
+ enabled: false
+ }
+ }
+ end
+
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
+ params: update_attributes
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns unauthorized status' do
+ patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
+ params: update_attributes,
+ headers: agent.create_new_auth_token
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an admin' do
+ it 'updates the custom tool and returns success status' do
+ patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
+ params: update_attributes,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:title]).to eq('Updated Tool Title')
+ expect(json_response[:enabled]).to be(false)
+ end
+
+ context 'with invalid parameters' do
+ let(:invalid_attributes) do
+ {
+ custom_tool: {
+ title: ''
+ }
+ }
+ end
+
+ it 'returns unprocessable entity status' do
+ patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
+ params: invalid_attributes,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+ end
+
+ describe 'DELETE /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
+ let!(:custom_tool) { create(:captain_custom_tool, account: account) }
+
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns unauthorized status' do
+ delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
+ headers: agent.create_new_auth_token
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an admin' do
+ it 'deletes the custom tool and returns no content status' do
+ expect do
+ delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
+ headers: admin.create_new_auth_token
+ end.to change(Captain::CustomTool, :count).by(-1)
+
+ expect(response).to have_http_status(:no_content)
+ end
+
+ context 'when custom tool does not exist' do
+ it 'returns not found status' do
+ delete "/api/v1/accounts/#{account.id}/captain/custom_tools/999999",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb
new file mode 100644
index 000000000..f62991ad1
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb
@@ -0,0 +1,141 @@
+require 'rails_helper'
+
+RSpec.describe 'Companies API', type: :request do
+ let(:account) { create(:account) }
+
+ describe 'GET /api/v1/accounts/{account.id}/companies' do
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/companies"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated user' do
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let!(:company1) { create(:company, name: 'Company 1', account: account) }
+ let!(:company2) { create(:company, account: account) }
+
+ it 'returns all companies' do
+ get "/api/v1/accounts/#{account.id}/companies",
+ headers: admin.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:success)
+ response_body = response.parsed_body
+ expect(response_body['payload'].size).to eq(2)
+ expect(response_body['payload'].map { |c| c['name'] }).to contain_exactly(company1.name, company2.name)
+ end
+ end
+ end
+
+ describe 'GET /api/v1/accounts/{account.id}/companies/{id}' do
+ context 'when it is an authenticated user' do
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:company) { create(:company, account: account) }
+
+ it 'returns the company' do
+ get "/api/v1/accounts/#{account.id}/companies/#{company.id}",
+ headers: admin.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:success)
+ response_body = response.parsed_body
+ expect(response_body['payload']['name']).to eq(company.name)
+ expect(response_body['payload']['id']).to eq(company.id)
+ end
+ end
+ end
+
+ describe 'POST /api/v1/accounts/{account.id}/companies' do
+ context 'when it is an authenticated user' do
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:valid_params) do
+ {
+ company: {
+ name: 'New Company',
+ domain: 'newcompany.com',
+ description: 'A new company'
+ }
+ }
+ end
+
+ it 'creates a new company' do
+ expect do
+ post "/api/v1/accounts/#{account.id}/companies",
+ params: valid_params,
+ headers: admin.create_new_auth_token,
+ as: :json
+ end.to change(Company, :count).by(1)
+
+ expect(response).to have_http_status(:success)
+ response_body = response.parsed_body
+ expect(response_body['payload']['name']).to eq('New Company')
+ expect(response_body['payload']['domain']).to eq('newcompany.com')
+ end
+
+ it 'returns error for invalid params' do
+ invalid_params = { company: { name: '' } }
+
+ post "/api/v1/accounts/#{account.id}/companies",
+ params: invalid_params,
+ headers: admin.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+
+ describe 'PATCH /api/v1/accounts/{account.id}/companies/{id}' do
+ context 'when it is an authenticated user' do
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:company) { create(:company, account: account) }
+ let(:update_params) do
+ {
+ company: {
+ name: 'Updated Company Name',
+ domain: 'updated.com'
+ }
+ }
+ end
+
+ it 'updates the company' do
+ patch "/api/v1/accounts/#{account.id}/companies/#{company.id}",
+ params: update_params,
+ headers: admin.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:success)
+ response_body = response.parsed_body
+ expect(response_body['payload']['name']).to eq('Updated Company Name')
+ expect(response_body['payload']['domain']).to eq('updated.com')
+ end
+ end
+ end
+
+ describe 'DELETE /api/v1/accounts/{account.id}/companies/{id}' do
+ context 'when it is an authenticated administrator' do
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:company) { create(:company, account: account) }
+
+ it 'deletes the company' do
+ company
+ expect do
+ delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
+ headers: admin.create_new_auth_token,
+ as: :json
+ end.to change(Company, :count).by(-1)
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ context 'when it is a regular agent' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:company) { create(:company, account: account) }
+
+ it 'returns unauthorized' do
+ delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb
index 4d5269cde..bbb3f1eb3 100644
--- a/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -30,5 +30,76 @@ RSpec.describe 'Conversations API', type: :request do
expect(response.parsed_body.keys).not_to include('applied_sla')
expect(response.parsed_body.keys).not_to include('sla_events')
end
+
+ context 'when agent has team access' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:team) { create(:team, account: account) }
+ let(:conversation) { create(:conversation, account: account, team: team) }
+
+ before do
+ create(:team_member, team: team, user: agent)
+ end
+
+ it 'allows accessing the conversation via team membership' do
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['id']).to eq(conversation.display_id)
+ end
+ end
+
+ context 'when agent has a custom role' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:conversation) { create(:conversation, account: account) }
+
+ before do
+ create(:inbox_member, user: agent, inbox: conversation.inbox)
+ end
+
+ it 'returns unauthorized for unassigned conversation without permission' do
+ custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
+ account.account_users.find_by(user_id: agent.id).update!(custom_role: custom_role)
+
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'returns the conversation when permission allows managing unassigned conversations, including when assigned to agent' do
+ custom_role = create(:custom_role, account: account, permissions: ['conversation_unassigned_manage'])
+ account_user = account.account_users.find_by(user_id: agent.id)
+ account_user.update!(custom_role: custom_role)
+ conversation.update!(assignee: agent)
+
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['id']).to eq(conversation.display_id)
+ end
+
+ it 'returns the conversation when permission allows managing assigned conversations' do
+ custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
+ account_user = account.account_users.find_by(user_id: agent.id)
+ account_user.update!(custom_role: custom_role)
+ conversation.update!(assignee: agent)
+
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['id']).to eq(conversation.display_id)
+ end
+
+ it 'returns the conversation when permission allows managing participating conversations' do
+ custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
+ account_user = account.account_users.find_by(user_id: agent.id)
+ account_user.update!(custom_role: custom_role)
+ create(:conversation_participant, conversation: conversation, account: account, user: agent)
+
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['id']).to eq(conversation.display_id)
+ end
+ end
end
end
diff --git a/spec/enterprise/controllers/api/v1/auth_controller_spec.rb b/spec/enterprise/controllers/api/v1/auth_controller_spec.rb
index f1e2ef1c7..767011a50 100644
--- a/spec/enterprise/controllers/api/v1/auth_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/auth_controller_spec.rb
@@ -36,6 +36,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end
+
+ it 'redirects to mobile deep link with error when target is mobile' do
+ post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com', target: 'mobile' }
+
+ expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed')
+ end
end
context 'when user exists but has no SAML enabled accounts' do
@@ -48,6 +54,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end
+
+ it 'redirects to mobile deep link with error when target is mobile' do
+ post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' }
+
+ expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed')
+ end
end
context 'when user has account without SAML feature enabled' do
@@ -65,6 +77,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end
+
+ it 'redirects to mobile deep link with error when target is mobile' do
+ post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' }
+
+ expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed')
+ end
end
context 'when user has valid SAML configuration' do
@@ -82,6 +100,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do
expect(response.location).to include("/auth/saml?account_id=#{account.id}")
end
+
+ it 'redirects to SAML initiation URL with mobile relay state' do
+ post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' }
+
+ expect(response.location).to include("/auth/saml?account_id=#{account.id}&RelayState=mobile")
+ end
end
context 'when user has multiple accounts with SAML' do
diff --git a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb
index 40f1ea294..1a8a5a342 100644
--- a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb
+++ b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb
@@ -1,8 +1,6 @@
require 'rails_helper'
RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
- include ActiveJob::TestHelper
-
let!(:inbox) { create(:inbox) }
let!(:resolvable_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 2.hours.ago, status: :pending) }
@@ -14,6 +12,7 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
before do
create(:captain_inbox, inbox: inbox, captain_assistant: captain_assistant)
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
+ inbox.reload
end
it 'queues the job' do
@@ -22,7 +21,7 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
end
it 'resolves only the eligible pending conversations' do
- perform_enqueued_jobs { described_class.perform_later(inbox) }
+ described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
expect(recent_pending_conversation.reload.status).to eq('pending')
@@ -34,7 +33,7 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
captain_assistant.update!(config: { 'resolution_message' => custom_message })
expect do
- perform_enqueued_jobs { described_class.perform_later(inbox) }
+ described_class.perform_now(inbox)
end.to change { resolvable_pending_conversation.messages.outgoing.reload.count }.by(1)
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
@@ -44,7 +43,7 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
it 'creates an outgoing message with default auto resolution message if not configured' do
captain_assistant.update!(config: {})
- perform_enqueued_jobs { described_class.perform_later(inbox) }
+ described_class.perform_now(inbox)
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
expect(outgoing_message.content).to eq(
I18n.t('conversations.activity.auto_resolution_message')
@@ -52,11 +51,17 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
end
it 'adds the correct activity message after resolution by Captain' do
- perform_enqueued_jobs { described_class.perform_later(inbox) }
- activity_message = resolvable_pending_conversation.messages.activity.last
- expect(activity_message).not_to be_nil
- expect(activity_message.content).to eq(
- I18n.t('conversations.activity.captain.resolved', user_name: captain_assistant.name)
- )
+ described_class.perform_now(inbox)
+ expected_content = I18n.t('conversations.activity.captain.resolved', user_name: captain_assistant.name)
+ expect(Conversations::ActivityMessageJob)
+ .to have_been_enqueued.with(
+ resolvable_pending_conversation,
+ {
+ account_id: resolvable_pending_conversation.account_id,
+ inbox_id: resolvable_pending_conversation.inbox_id,
+ message_type: :activity,
+ content: expected_content
+ }
+ )
end
end
diff --git a/spec/enterprise/lib/captain/tools/http_tool_spec.rb b/spec/enterprise/lib/captain/tools/http_tool_spec.rb
new file mode 100644
index 000000000..d48af2752
--- /dev/null
+++ b/spec/enterprise/lib/captain/tools/http_tool_spec.rb
@@ -0,0 +1,241 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Tools::HttpTool, type: :model do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:custom_tool) { create(:captain_custom_tool, account: account) }
+ let(:tool) { described_class.new(assistant, custom_tool) }
+ let(:tool_context) { Struct.new(:state).new({}) }
+
+ describe '#active?' do
+ it 'returns true when custom tool is enabled' do
+ custom_tool.update!(enabled: true)
+
+ expect(tool.active?).to be true
+ end
+
+ it 'returns false when custom tool is disabled' do
+ custom_tool.update!(enabled: false)
+
+ expect(tool.active?).to be false
+ end
+ end
+
+ describe '#perform' do
+ context 'with GET request' do
+ before do
+ custom_tool.update!(
+ http_method: 'GET',
+ endpoint_url: 'https://example.com/orders/123',
+ response_template: nil
+ )
+ stub_request(:get, 'https://example.com/orders/123')
+ .to_return(status: 200, body: '{"status": "success"}')
+ end
+
+ it 'executes GET request and returns response body' do
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('{"status": "success"}')
+ expect(WebMock).to have_requested(:get, 'https://example.com/orders/123')
+ end
+ end
+
+ context 'with POST request' do
+ before do
+ custom_tool.update!(
+ http_method: 'POST',
+ endpoint_url: 'https://example.com/orders',
+ request_template: '{"order_id": "{{ order_id }}"}',
+ response_template: nil
+ )
+ stub_request(:post, 'https://example.com/orders')
+ .with(body: '{"order_id": "123"}', headers: { 'Content-Type' => 'application/json' })
+ .to_return(status: 200, body: '{"created": true}')
+ end
+
+ it 'executes POST request with rendered body' do
+ result = tool.perform(tool_context, order_id: '123')
+
+ expect(result).to eq('{"created": true}')
+ expect(WebMock).to have_requested(:post, 'https://example.com/orders')
+ .with(body: '{"order_id": "123"}')
+ end
+ end
+
+ context 'with template variables in URL' do
+ before do
+ custom_tool.update!(
+ endpoint_url: 'https://example.com/orders/{{ order_id }}',
+ response_template: nil
+ )
+ stub_request(:get, 'https://example.com/orders/456')
+ .to_return(status: 200, body: '{"order_id": "456"}')
+ end
+
+ it 'renders URL template with params' do
+ result = tool.perform(tool_context, order_id: '456')
+
+ expect(result).to eq('{"order_id": "456"}')
+ expect(WebMock).to have_requested(:get, 'https://example.com/orders/456')
+ end
+ end
+
+ context 'with bearer token authentication' do
+ before do
+ custom_tool.update!(
+ auth_type: 'bearer',
+ auth_config: { 'token' => 'secret_bearer_token' },
+ endpoint_url: 'https://example.com/data',
+ response_template: nil
+ )
+ stub_request(:get, 'https://example.com/data')
+ .with(headers: { 'Authorization' => 'Bearer secret_bearer_token' })
+ .to_return(status: 200, body: '{"authenticated": true}')
+ end
+
+ it 'adds Authorization header with bearer token' do
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('{"authenticated": true}')
+ expect(WebMock).to have_requested(:get, 'https://example.com/data')
+ .with(headers: { 'Authorization' => 'Bearer secret_bearer_token' })
+ end
+ end
+
+ context 'with basic authentication' do
+ before do
+ custom_tool.update!(
+ auth_type: 'basic',
+ auth_config: { 'username' => 'user123', 'password' => 'pass456' },
+ endpoint_url: 'https://example.com/data',
+ response_template: nil
+ )
+ stub_request(:get, 'https://example.com/data')
+ .with(basic_auth: %w[user123 pass456])
+ .to_return(status: 200, body: '{"authenticated": true}')
+ end
+
+ it 'adds basic auth credentials' do
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('{"authenticated": true}')
+ expect(WebMock).to have_requested(:get, 'https://example.com/data')
+ .with(basic_auth: %w[user123 pass456])
+ end
+ end
+
+ context 'with API key authentication' do
+ before do
+ custom_tool.update!(
+ auth_type: 'api_key',
+ auth_config: { 'key' => 'api_key_123', 'location' => 'header', 'name' => 'X-API-Key' },
+ endpoint_url: 'https://example.com/data',
+ response_template: nil
+ )
+ stub_request(:get, 'https://example.com/data')
+ .with(headers: { 'X-API-Key' => 'api_key_123' })
+ .to_return(status: 200, body: '{"authenticated": true}')
+ end
+
+ it 'adds API key header' do
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('{"authenticated": true}')
+ expect(WebMock).to have_requested(:get, 'https://example.com/data')
+ .with(headers: { 'X-API-Key' => 'api_key_123' })
+ end
+ end
+
+ context 'with response template' do
+ before do
+ custom_tool.update!(
+ endpoint_url: 'https://example.com/orders/123',
+ response_template: 'Order status: {{ response.status }}, ID: {{ response.order_id }}'
+ )
+ stub_request(:get, 'https://example.com/orders/123')
+ .to_return(status: 200, body: '{"status": "shipped", "order_id": "123"}')
+ end
+
+ it 'formats response using template' do
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('Order status: shipped, ID: 123')
+ end
+ end
+
+ context 'when handling errors' do
+ it 'returns generic error message on network failure' do
+ custom_tool.update!(endpoint_url: 'https://example.com/data')
+ stub_request(:get, 'https://example.com/data').to_raise(SocketError.new('Failed to connect'))
+
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('An error occurred while executing the request')
+ end
+
+ it 'returns generic error message on timeout' do
+ custom_tool.update!(endpoint_url: 'https://example.com/data')
+ stub_request(:get, 'https://example.com/data').to_timeout
+
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('An error occurred while executing the request')
+ end
+
+ it 'returns generic error message on HTTP 404' do
+ custom_tool.update!(endpoint_url: 'https://example.com/data')
+ stub_request(:get, 'https://example.com/data').to_return(status: 404, body: 'Not found')
+
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('An error occurred while executing the request')
+ end
+
+ it 'returns generic error message on HTTP 500' do
+ custom_tool.update!(endpoint_url: 'https://example.com/data')
+ stub_request(:get, 'https://example.com/data').to_return(status: 500, body: 'Server error')
+
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('An error occurred while executing the request')
+ end
+
+ it 'logs error details' do
+ custom_tool.update!(endpoint_url: 'https://example.com/data')
+ stub_request(:get, 'https://example.com/data').to_raise(StandardError.new('Test error'))
+
+ expect(Rails.logger).to receive(:error).with(/HttpTool execution error.*Test error/)
+
+ tool.perform(tool_context)
+ end
+ end
+
+ context 'when integrating with Toolable methods' do
+ it 'correctly integrates URL rendering, body rendering, auth, and response formatting' do
+ custom_tool.update!(
+ http_method: 'POST',
+ endpoint_url: 'https://example.com/users/{{ user_id }}/orders',
+ request_template: '{"product": "{{ product }}", "quantity": {{ quantity }}}',
+ auth_type: 'bearer',
+ auth_config: { 'token' => 'integration_token' },
+ response_template: 'Created order #{{ response.order_number }} for {{ response.product }}'
+ )
+
+ stub_request(:post, 'https://example.com/users/42/orders')
+ .with(
+ body: '{"product": "Widget", "quantity": 5}',
+ headers: {
+ 'Authorization' => 'Bearer integration_token',
+ 'Content-Type' => 'application/json'
+ }
+ )
+ .to_return(status: 200, body: '{"order_number": "ORD-789", "product": "Widget"}')
+
+ result = tool.perform(tool_context, user_id: '42', product: 'Widget', quantity: 5)
+
+ expect(result).to eq('Created order #ORD-789 for Widget')
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/models/captain/custom_tool_spec.rb b/spec/enterprise/models/captain/custom_tool_spec.rb
new file mode 100644
index 000000000..5f6c7b19a
--- /dev/null
+++ b/spec/enterprise/models/captain/custom_tool_spec.rb
@@ -0,0 +1,388 @@
+require 'rails_helper'
+
+RSpec.describe Captain::CustomTool, type: :model do
+ describe 'associations' do
+ it { is_expected.to belong_to(:account) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:title) }
+ it { is_expected.to validate_presence_of(:endpoint_url) }
+ it { is_expected.to define_enum_for(:http_method).with_values('GET' => 'GET', 'POST' => 'POST').backed_by_column_of_type(:string) }
+
+ it {
+ expect(subject).to define_enum_for(:auth_type).with_values('none' => 'none', 'bearer' => 'bearer', 'basic' => 'basic',
+ 'api_key' => 'api_key').backed_by_column_of_type(:string).with_prefix(:auth)
+ }
+
+ describe 'slug uniqueness' do
+ let(:account) { create(:account) }
+
+ it 'validates uniqueness of slug scoped to account' do
+ create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
+ duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test_tool')
+
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:slug]).to include('has already been taken')
+ end
+
+ it 'allows same slug across different accounts' do
+ account2 = create(:account)
+ create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
+ different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test_tool')
+
+ expect(different_account_tool).to be_valid
+ end
+ end
+
+ describe 'param_schema validation' do
+ let(:account) { create(:account) }
+
+ it 'is valid with proper param_schema' do
+ tool = build(:captain_custom_tool, account: account, param_schema: [
+ { 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'required' => true }
+ ])
+
+ expect(tool).to be_valid
+ end
+
+ it 'is valid with empty param_schema' do
+ tool = build(:captain_custom_tool, account: account, param_schema: [])
+
+ expect(tool).to be_valid
+ end
+
+ it 'is invalid when param_schema is missing name' do
+ tool = build(:captain_custom_tool, account: account, param_schema: [
+ { 'type' => 'string', 'description' => 'Order ID' }
+ ])
+
+ expect(tool).not_to be_valid
+ end
+
+ it 'is invalid when param_schema is missing type' do
+ tool = build(:captain_custom_tool, account: account, param_schema: [
+ { 'name' => 'order_id', 'description' => 'Order ID' }
+ ])
+
+ expect(tool).not_to be_valid
+ end
+
+ it 'is invalid when param_schema is missing description' do
+ tool = build(:captain_custom_tool, account: account, param_schema: [
+ { 'name' => 'order_id', 'type' => 'string' }
+ ])
+
+ expect(tool).not_to be_valid
+ end
+
+ it 'is invalid with additional properties in param_schema' do
+ tool = build(:captain_custom_tool, account: account, param_schema: [
+ { 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'extra_field' => 'value' }
+ ])
+
+ expect(tool).not_to be_valid
+ end
+
+ it 'is valid when required field is omitted (defaults to optional param)' do
+ tool = build(:captain_custom_tool, account: account, param_schema: [
+ { 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID' }
+ ])
+
+ expect(tool).to be_valid
+ end
+ end
+ end
+
+ describe 'scopes' do
+ let(:account) { create(:account) }
+
+ describe '.enabled' do
+ it 'returns only enabled custom tools' do
+ enabled_tool = create(:captain_custom_tool, account: account, enabled: true)
+ disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
+
+ expect(described_class.enabled).to include(enabled_tool)
+ expect(described_class.enabled).not_to include(disabled_tool)
+ end
+ end
+ end
+
+ describe 'slug generation' do
+ let(:account) { create(:account) }
+
+ it 'generates slug from title on creation' do
+ tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status')
+
+ expect(tool.slug).to eq('custom_fetch_order_status')
+ end
+
+ it 'adds custom_ prefix to generated slug' do
+ tool = create(:captain_custom_tool, account: account, title: 'My Tool')
+
+ expect(tool.slug).to start_with('custom_')
+ end
+
+ it 'does not override manually set slug' do
+ tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual_slug')
+
+ expect(tool.slug).to eq('custom_manual_slug')
+ end
+
+ it 'handles slug collisions by appending random suffix' do
+ create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
+ tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool')
+
+ expect(tool2.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
+ end
+
+ it 'handles multiple slug collisions' do
+ create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
+ create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool_abc123')
+ tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool')
+
+ expect(tool3.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
+ expect(tool3.slug).not_to eq('custom_test_tool')
+ expect(tool3.slug).not_to eq('custom_test_tool_abc123')
+ end
+
+ it 'does not generate slug when title is blank' do
+ tool = build(:captain_custom_tool, account: account, title: nil)
+
+ expect(tool).not_to be_valid
+ expect(tool.errors[:title]).to include("can't be blank")
+ end
+
+ it 'parameterizes title correctly' do
+ tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!')
+
+ expect(tool.slug).to eq('custom_fetch_order_status_details')
+ end
+ end
+
+ describe 'factory' do
+ it 'creates a valid custom tool with default attributes' do
+ tool = create(:captain_custom_tool)
+
+ expect(tool).to be_valid
+ expect(tool.title).to be_present
+ expect(tool.slug).to be_present
+ expect(tool.endpoint_url).to be_present
+ expect(tool.http_method).to eq('GET')
+ expect(tool.auth_type).to eq('none')
+ expect(tool.enabled).to be true
+ end
+
+ it 'creates valid tool with POST trait' do
+ tool = create(:captain_custom_tool, :with_post)
+
+ expect(tool.http_method).to eq('POST')
+ expect(tool.request_template).to be_present
+ end
+
+ it 'creates valid tool with bearer auth trait' do
+ tool = create(:captain_custom_tool, :with_bearer_auth)
+
+ expect(tool.auth_type).to eq('bearer')
+ expect(tool.auth_config['token']).to eq('test_bearer_token_123')
+ end
+
+ it 'creates valid tool with basic auth trait' do
+ tool = create(:captain_custom_tool, :with_basic_auth)
+
+ expect(tool.auth_type).to eq('basic')
+ expect(tool.auth_config['username']).to eq('test_user')
+ expect(tool.auth_config['password']).to eq('test_pass')
+ end
+
+ it 'creates valid tool with api key trait' do
+ tool = create(:captain_custom_tool, :with_api_key)
+
+ expect(tool.auth_type).to eq('api_key')
+ expect(tool.auth_config['key']).to eq('test_api_key')
+ expect(tool.auth_config['location']).to eq('header')
+ end
+ end
+
+ describe 'Toolable concern' do
+ let(:account) { create(:account) }
+
+ describe '#build_request_url' do
+ it 'returns static URL when no template variables present' do
+ tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders')
+
+ expect(tool.build_request_url({})).to eq('https://api.example.com/orders')
+ end
+
+ it 'renders URL template with params' do
+ tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders/{{ order_id }}')
+
+ expect(tool.build_request_url({ order_id: '12345' })).to eq('https://api.example.com/orders/12345')
+ end
+
+ it 'handles multiple template variables' do
+ tool = create(:captain_custom_tool, account: account,
+ endpoint_url: 'https://api.example.com/{{ resource }}/{{ id }}?details={{ show_details }}')
+
+ result = tool.build_request_url({ resource: 'orders', id: '123', show_details: 'true' })
+ expect(result).to eq('https://api.example.com/orders/123?details=true')
+ end
+ end
+
+ describe '#build_request_body' do
+ it 'returns nil when request_template is blank' do
+ tool = create(:captain_custom_tool, account: account, request_template: nil)
+
+ expect(tool.build_request_body({})).to be_nil
+ end
+
+ it 'renders request body template with params' do
+ tool = create(:captain_custom_tool, account: account,
+ request_template: '{ "order_id": "{{ order_id }}", "source": "chatwoot" }')
+
+ result = tool.build_request_body({ order_id: '12345' })
+ expect(result).to eq('{ "order_id": "12345", "source": "chatwoot" }')
+ end
+ end
+
+ describe '#build_auth_headers' do
+ it 'returns empty hash for none auth type' do
+ tool = create(:captain_custom_tool, account: account, auth_type: 'none')
+
+ expect(tool.build_auth_headers).to eq({})
+ end
+
+ it 'returns bearer token header' do
+ tool = create(:captain_custom_tool, :with_bearer_auth, account: account)
+
+ expect(tool.build_auth_headers).to eq({ 'Authorization' => 'Bearer test_bearer_token_123' })
+ end
+
+ it 'returns API key header when location is header' do
+ tool = create(:captain_custom_tool, :with_api_key, account: account)
+
+ expect(tool.build_auth_headers).to eq({ 'X-API-Key' => 'test_api_key' })
+ end
+
+ it 'returns empty hash for API key when location is not header' do
+ tool = create(:captain_custom_tool, account: account, auth_type: 'api_key',
+ auth_config: { key: 'test_key', location: 'query', name: 'api_key' })
+
+ expect(tool.build_auth_headers).to eq({})
+ end
+
+ it 'returns empty hash for basic auth' do
+ tool = create(:captain_custom_tool, :with_basic_auth, account: account)
+
+ expect(tool.build_auth_headers).to eq({})
+ end
+ end
+
+ describe '#build_basic_auth_credentials' do
+ it 'returns nil for non-basic auth types' do
+ tool = create(:captain_custom_tool, account: account, auth_type: 'none')
+
+ expect(tool.build_basic_auth_credentials).to be_nil
+ end
+
+ it 'returns username and password array for basic auth' do
+ tool = create(:captain_custom_tool, :with_basic_auth, account: account)
+
+ expect(tool.build_basic_auth_credentials).to eq(%w[test_user test_pass])
+ end
+ end
+
+ describe '#format_response' do
+ it 'returns raw response when no response_template' do
+ tool = create(:captain_custom_tool, account: account, response_template: nil)
+
+ expect(tool.format_response('raw response')).to eq('raw response')
+ end
+
+ it 'renders response template with JSON response' do
+ tool = create(:captain_custom_tool, account: account,
+ response_template: 'Order status: {{ response.status }}')
+ raw_response = '{"status": "shipped", "tracking": "123ABC"}'
+
+ result = tool.format_response(raw_response)
+ expect(result).to eq('Order status: shipped')
+ end
+
+ it 'handles response template with multiple fields' do
+ tool = create(:captain_custom_tool, account: account,
+ response_template: 'Order {{ response.id }} is {{ response.status }}. Tracking: {{ response.tracking }}')
+ raw_response = '{"id": "12345", "status": "delivered", "tracking": "ABC123"}'
+
+ result = tool.format_response(raw_response)
+ expect(result).to eq('Order 12345 is delivered. Tracking: ABC123')
+ end
+
+ it 'handles non-JSON response' do
+ tool = create(:captain_custom_tool, account: account,
+ response_template: 'Response: {{ response }}')
+ raw_response = 'plain text response'
+
+ result = tool.format_response(raw_response)
+ expect(result).to eq('Response: plain text response')
+ end
+ end
+
+ describe '#to_tool_metadata' do
+ it 'returns tool metadata hash with custom flag' do
+ tool = create(:captain_custom_tool, account: account,
+ slug: 'custom_test-tool',
+ title: 'Test Tool',
+ description: 'A test tool')
+
+ metadata = tool.to_tool_metadata
+ expect(metadata).to eq({
+ id: 'custom_test-tool',
+ title: 'Test Tool',
+ description: 'A test tool',
+ custom: true
+ })
+ end
+ end
+
+ describe '#tool' do
+ let(:assistant) { create(:captain_assistant, account: account) }
+
+ it 'returns HttpTool instance' do
+ tool = create(:captain_custom_tool, account: account)
+
+ tool_instance = tool.tool(assistant)
+ expect(tool_instance).to be_a(Captain::Tools::HttpTool)
+ end
+
+ it 'sets description on the tool class' do
+ tool = create(:captain_custom_tool, account: account, description: 'Fetches order data')
+
+ tool_instance = tool.tool(assistant)
+ expect(tool_instance.description).to eq('Fetches order data')
+ end
+
+ it 'sets parameters on the tool class' do
+ tool = create(:captain_custom_tool, :with_params, account: account)
+
+ tool_instance = tool.tool(assistant)
+ params = tool_instance.parameters
+
+ expect(params.keys).to contain_exactly(:order_id, :include_details)
+ expect(params[:order_id].name).to eq(:order_id)
+ expect(params[:order_id].type).to eq('string')
+ expect(params[:order_id].description).to eq('The order ID')
+ expect(params[:order_id].required).to be true
+
+ expect(params[:include_details].name).to eq(:include_details)
+ expect(params[:include_details].required).to be false
+ end
+
+ it 'works with empty param_schema' do
+ tool = create(:captain_custom_tool, account: account, param_schema: [])
+
+ tool_instance = tool.tool(assistant)
+ expect(tool_instance.parameters).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/models/captain/scenario_spec.rb b/spec/enterprise/models/captain/scenario_spec.rb
index 7a39559c3..45009a3b0 100644
--- a/spec/enterprise/models/captain/scenario_spec.rb
+++ b/spec/enterprise/models/captain/scenario_spec.rb
@@ -48,9 +48,9 @@ RSpec.describe Captain::Scenario, type: :model do
before do
# Mock available tools
- allow(described_class).to receive(:available_tool_ids).and_return(%w[
- add_contact_note add_private_note update_priority
- ])
+ allow(described_class).to receive(:built_in_tool_ids).and_return(%w[
+ add_contact_note add_private_note update_priority
+ ])
end
describe 'validate_instruction_tools' do
@@ -102,6 +102,49 @@ RSpec.describe Captain::Scenario, type: :model do
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/)
end
+
+ it 'is valid with custom tool references' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
+ scenario = build(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
+
+ expect(scenario).to be_valid
+ end
+
+ it 'is invalid with custom tool from different account' do
+ other_account = create(:account)
+ create(:captain_custom_tool, account: other_account, slug: 'custom_fetch-order')
+ scenario = build(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
+
+ expect(scenario).not_to be_valid
+ expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order')
+ end
+
+ it 'is invalid with disabled custom tool' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false)
+ scenario = build(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
+
+ expect(scenario).not_to be_valid
+ expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order')
+ end
+
+ it 'is valid with mixed static and custom tool references' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
+ scenario = build(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
+
+ expect(scenario).to be_valid
+ end
end
describe 'resolve_tool_references' do
@@ -146,6 +189,140 @@ RSpec.describe Captain::Scenario, type: :model do
end
end
+ describe 'custom tool integration' do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+
+ before do
+ allow(described_class).to receive(:built_in_tool_ids).and_return(%w[add_contact_note])
+ allow(described_class).to receive(:built_in_agent_tools).and_return([
+ { id: 'add_contact_note', title: 'Add Contact Note',
+ description: 'Add a note' }
+ ])
+ end
+
+ describe '#resolved_tools' do
+ it 'includes custom tool metadata' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order',
+ title: 'Fetch Order', description: 'Gets order details')
+ scenario = create(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
+
+ resolved = scenario.send(:resolved_tools)
+ expect(resolved.length).to eq(1)
+ expect(resolved.first[:id]).to eq('custom_fetch-order')
+ expect(resolved.first[:title]).to eq('Fetch Order')
+ expect(resolved.first[:description]).to eq('Gets order details')
+ end
+
+ it 'includes both static and custom tools' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
+ scenario = create(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
+
+ resolved = scenario.send(:resolved_tools)
+ expect(resolved.length).to eq(2)
+ expect(resolved.map { |t| t[:id] }).to contain_exactly('add_contact_note', 'custom_fetch-order')
+ end
+
+ it 'excludes disabled custom tools' do
+ custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true)
+ scenario = create(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
+
+ custom_tool.update!(enabled: false)
+
+ resolved = scenario.send(:resolved_tools)
+ expect(resolved).to be_empty
+ end
+ end
+
+ describe '#resolve_tool_instance' do
+ it 'returns HttpTool instance for custom tools' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
+ scenario = create(:captain_scenario, assistant: assistant, account: account)
+
+ tool_metadata = { id: 'custom_fetch-order', custom: true }
+ tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
+ expect(tool_instance).to be_a(Captain::Tools::HttpTool)
+ end
+
+ it 'returns nil for disabled custom tools' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false)
+ scenario = create(:captain_scenario, assistant: assistant, account: account)
+
+ tool_metadata = { id: 'custom_fetch-order', custom: true }
+ tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
+ expect(tool_instance).to be_nil
+ end
+
+ it 'returns static tool instance for non-custom tools' do
+ scenario = create(:captain_scenario, assistant: assistant, account: account)
+ allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return(
+ Class.new do
+ def initialize(_assistant); end
+ end
+ )
+
+ tool_metadata = { id: 'add_contact_note' }
+ tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
+ expect(tool_instance).not_to be_nil
+ expect(tool_instance).not_to be_a(Captain::Tools::HttpTool)
+ end
+ end
+
+ describe '#agent_tools' do
+ it 'returns array of tool instances including custom tools' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
+ scenario = create(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
+
+ tools = scenario.send(:agent_tools)
+ expect(tools.length).to eq(1)
+ expect(tools.first).to be_a(Captain::Tools::HttpTool)
+ end
+
+ it 'excludes disabled custom tools from execution' do
+ custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true)
+ scenario = create(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
+
+ custom_tool.update!(enabled: false)
+
+ tools = scenario.send(:agent_tools)
+ expect(tools).to be_empty
+ end
+
+ it 'returns mixed static and custom tool instances' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
+ scenario = create(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
+
+ allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return(
+ Class.new do
+ def initialize(_assistant); end
+ end
+ )
+
+ tools = scenario.send(:agent_tools)
+ expect(tools.length).to eq(2)
+ expect(tools.last).to be_a(Captain::Tools::HttpTool)
+ end
+ end
+ end
+
describe 'factory' do
it 'creates a valid scenario with associations' do
account = create(:account)
diff --git a/spec/enterprise/models/company_spec.rb b/spec/enterprise/models/company_spec.rb
new file mode 100644
index 000000000..820e6ee7d
--- /dev/null
+++ b/spec/enterprise/models/company_spec.rb
@@ -0,0 +1,38 @@
+require 'rails_helper'
+
+RSpec.describe Company, type: :model do
+ context 'with validations' do
+ it { is_expected.to validate_presence_of(:account_id) }
+ it { is_expected.to validate_presence_of(:name) }
+ it { is_expected.to validate_length_of(:name).is_at_most(100) }
+ it { is_expected.to validate_length_of(:description).is_at_most(1000) }
+
+ describe 'domain validation' do
+ it { is_expected.to allow_value('example.com').for(:domain) }
+ it { is_expected.to allow_value('sub.example.com').for(:domain) }
+ it { is_expected.to allow_value('').for(:domain) }
+ it { is_expected.to allow_value(nil).for(:domain) }
+ it { is_expected.not_to allow_value('invalid-domain').for(:domain) }
+ it { is_expected.not_to allow_value('.example.com').for(:domain) }
+ end
+ end
+
+ context 'with associations' do
+ it { is_expected.to belong_to(:account) }
+ it { is_expected.to have_many(:contacts).dependent(:nullify) }
+ end
+
+ describe 'scopes' do
+ let(:account) { create(:account) }
+ let!(:company_b) { create(:company, name: 'B Company', account: account) }
+ let!(:company_a) { create(:company, name: 'A Company', account: account) }
+ let!(:company_c) { create(:company, name: 'C Company', account: account) }
+
+ describe '.ordered_by_name' do
+ it 'orders companies by name alphabetically' do
+ companies = described_class.where(account: account).ordered_by_name
+ expect(companies.map(&:name)).to eq([company_a.name, company_b.name, company_c.name])
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb
index afe482385..7e36e9006 100644
--- a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb
+++ b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb
@@ -42,58 +42,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
end
end
- describe '.available_agent_tools' do
- before do
- # Mock the YAML file loading
- allow(YAML).to receive(:load_file).and_return([
- {
- 'id' => 'add_contact_note',
- 'title' => 'Add Contact Note',
- 'description' => 'Add a note to a contact',
- 'icon' => 'note-add'
- },
- {
- 'id' => 'invalid_tool',
- 'title' => 'Invalid Tool',
- 'description' => 'This tool does not exist',
- 'icon' => 'invalid'
- }
- ])
-
- # Mock class resolution - only add_contact_note exists
- allow(test_class).to receive(:resolve_tool_class) do |tool_id|
- case tool_id
- when 'add_contact_note'
- Captain::Tools::AddContactNoteTool
- end
- end
- end
-
- it 'returns only resolvable tools' do
- tools = test_class.available_agent_tools
-
- expect(tools.length).to eq(1)
- expect(tools.first).to eq({
- id: 'add_contact_note',
- title: 'Add Contact Note',
- description: 'Add a note to a contact',
- icon: 'note-add'
- })
- end
-
- it 'logs warnings for unresolvable tools' do
- expect(Rails.logger).to receive(:warn).with('Tool class not found for ID: invalid_tool')
-
- test_class.available_agent_tools
- end
-
- it 'memoizes the result' do
- expect(YAML).to receive(:load_file).once.and_return([])
-
- 2.times { test_class.available_agent_tools }
- end
- end
-
describe '.resolve_tool_class' do
it 'resolves valid tool classes' do
# Mock the constantize to return a class
@@ -116,28 +64,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
end
end
- describe '.available_tool_ids' do
- before do
- allow(test_class).to receive(:available_agent_tools).and_return([
- { id: 'add_contact_note', title: 'Add Contact Note', description: '...',
- icon: 'note' },
- { id: 'update_priority', title: 'Update Priority', description: '...',
- icon: 'priority' }
- ])
- end
-
- it 'returns array of tool IDs' do
- ids = test_class.available_tool_ids
- expect(ids).to eq(%w[add_contact_note update_priority])
- end
-
- it 'memoizes the result' do
- expect(test_class).to receive(:available_agent_tools).once.and_return([])
-
- 2.times { test_class.available_tool_ids }
- end
- end
-
describe '#extract_tool_ids_from_text' do
it 'extracts tool IDs from text' do
text = 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)'
diff --git a/spec/enterprise/policies/company_policy_spec.rb b/spec/enterprise/policies/company_policy_spec.rb
new file mode 100644
index 000000000..9f7d9f0cc
--- /dev/null
+++ b/spec/enterprise/policies/company_policy_spec.rb
@@ -0,0 +1,33 @@
+require 'rails_helper'
+
+RSpec.describe CompanyPolicy, type: :policy do
+ subject(:company_policy) { described_class }
+
+ let(:account) { create(:account) }
+ let(:administrator) { create(:user, :administrator, account: account) }
+ let(:agent) { create(:user, account: account) }
+ let(:company) { create(:company, account: account) }
+
+ let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
+ let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
+
+ permissions :index?, :show?, :create?, :update? do
+ context 'when administrator' do
+ it { expect(company_policy).to permit(administrator_context, company) }
+ end
+
+ context 'when agent' do
+ it { expect(company_policy).to permit(agent_context, company) }
+ end
+ end
+
+ permissions :destroy? do
+ context 'when administrator' do
+ it { expect(company_policy).to permit(administrator_context, company) }
+ end
+
+ context 'when agent' do
+ it { expect(company_policy).not_to permit(agent_context, company) }
+ end
+ end
+end
diff --git a/spec/enterprise/policies/conversation_policy_spec.rb b/spec/enterprise/policies/conversation_policy_spec.rb
new file mode 100644
index 000000000..e48a84852
--- /dev/null
+++ b/spec/enterprise/policies/conversation_policy_spec.rb
@@ -0,0 +1,65 @@
+require 'rails_helper'
+
+RSpec.describe ConversationPolicy, type: :policy do
+ subject { described_class }
+
+ let(:account) { create(:account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:agent_account_user) { agent.account_users.find_by(account: account) }
+ let(:context) { { user: agent, account: account, account_user: agent_account_user } }
+
+ before do
+ create(:inbox_member, user: agent, inbox: inbox)
+ end
+
+ permissions :show? do
+ context 'when role grants conversation_unassigned_manage' do
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']) }
+
+ before do
+ agent_account_user.update!(role: :agent, custom_role: custom_role)
+ end
+
+ it 'allows access to conversations assigned to the agent' do
+ conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
+
+ expect(subject).to permit(context, conversation)
+ end
+
+ it 'denies access to conversations assigned to someone else' do
+ other_agent = create(:user, account: account, role: :agent)
+ conversation = create(:conversation, account: account, inbox: inbox, assignee: other_agent)
+
+ expect(subject).not_to permit(context, conversation)
+ end
+ end
+
+ context 'when role grants conversation_participating_manage' do
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_participating_manage']) }
+
+ before do
+ agent_account_user.update!(role: :agent, custom_role: custom_role)
+ end
+
+ it 'allows access to conversations assigned to the agent' do
+ conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
+
+ expect(subject).to permit(context, conversation)
+ end
+
+ it 'allows access to conversations where the agent is a participant' do
+ conversation = create(:conversation, account: account, inbox: inbox, assignee: nil)
+ create(:conversation_participant, conversation: conversation, account: account, user: agent)
+
+ expect(subject).to permit(context, conversation)
+ end
+
+ it 'denies access to unrelated conversations' do
+ conversation = create(:conversation, account: account, inbox: inbox, assignee: nil)
+
+ expect(subject).not_to permit(context, conversation)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
index f31177fc2..2c05860e2 100644
--- a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
+++ b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
let(:mock_runner) { instance_double(Agents::Runner) }
let(:mock_agent) { instance_double(Agents::Agent) }
let(:mock_scenario_agent) { instance_double(Agents::Agent) }
- let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }) }
+ let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }, context: nil) }
let(:message_history) do
[
@@ -90,7 +90,8 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
expect(mock_runner).to receive(:run).with(
'I need help with my account',
- context: expected_context
+ context: expected_context,
+ max_turns: 100
)
service.generate_response(message_history: message_history)
@@ -99,7 +100,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
it 'processes and formats agent result' do
result = service.generate_response(message_history: message_history)
- expect(result).to eq({ 'response' => 'Test response' })
+ expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil })
end
context 'when no scenarios are enabled' do
@@ -118,14 +119,15 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
end
context 'when agent result is a string' do
- let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response') }
+ let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response', context: nil) }
it 'formats string response correctly' do
result = service.generate_response(message_history: message_history)
expect(result).to eq({
'response' => 'Simple string response',
- 'reasoning' => 'Processed by agent'
+ 'reasoning' => 'Processed by agent',
+ 'agent_name' => nil
})
end
end
diff --git a/spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb b/spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb
new file mode 100644
index 000000000..a2735bd69
--- /dev/null
+++ b/spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb
@@ -0,0 +1,99 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
+ let(:website_url) { 'https://example.com' }
+ let(:service) { described_class.new(website_url) }
+ let(:mock_crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
+ let(:mock_client) { instance_double(OpenAI::Client) }
+
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ allow(Captain::Tools::SimplePageCrawlService).to receive(:new).and_return(mock_crawler)
+ allow(service).to receive(:client).and_return(mock_client)
+ allow(service).to receive(:model).and_return('gpt-3.5-turbo')
+ end
+
+ describe '#analyze' do
+ context 'when website content is available and OpenAI call is successful' do
+ let(:openai_response) do
+ {
+ 'choices' => [{
+ 'message' => {
+ 'content' => {
+ 'business_name' => 'Example Corp',
+ 'suggested_assistant_name' => 'Alex from Example Corp',
+ 'description' => 'You specialize in helping customers with business solutions and support'
+ }.to_json
+ }
+ }]
+ }
+ end
+
+ before do
+ allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
+ allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
+ allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
+ allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
+ allow(mock_client).to receive(:chat).and_return(openai_response)
+ end
+
+ it 'returns success' do
+ result = service.analyze
+
+ expect(result[:success]).to be true
+ expect(result[:data]).to include(
+ business_name: 'Example Corp',
+ suggested_assistant_name: 'Alex from Example Corp',
+ description: 'You specialize in helping customers with business solutions and support',
+ website_url: website_url,
+ favicon_url: 'https://example.com/favicon.ico'
+ )
+ end
+ end
+
+ context 'when website content is errored' do
+ before do
+ allow(mock_crawler).to receive(:body_text_content).and_raise(StandardError, 'Network error')
+ end
+
+ it 'returns error' do
+ result = service.analyze
+
+ expect(result[:success]).to be false
+ expect(result[:error]).to eq('Failed to fetch website content')
+ end
+ end
+
+ context 'when website content is unavailable' do
+ before do
+ allow(mock_crawler).to receive(:body_text_content).and_return('')
+ allow(mock_crawler).to receive(:page_title).and_return('')
+ allow(mock_crawler).to receive(:meta_description).and_return('')
+ end
+
+ it 'returns error' do
+ result = service.analyze
+
+ expect(result[:success]).to be false
+ expect(result[:error]).to eq('Failed to fetch website content')
+ end
+ end
+
+ context 'when OpenAI error' do
+ before do
+ allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
+ allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
+ allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
+ allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
+ allow(mock_client).to receive(:chat).and_raise(StandardError, 'API error')
+ end
+
+ it 'returns error' do
+ result = service.analyze
+
+ expect(result[:success]).to be false
+ expect(result[:error]).to eq('API error')
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/tools/simple_page_crawl_service_spec.rb b/spec/enterprise/services/captain/tools/simple_page_crawl_service_spec.rb
index 5868c0e22..5dfe7177d 100644
--- a/spec/enterprise/services/captain/tools/simple_page_crawl_service_spec.rb
+++ b/spec/enterprise/services/captain/tools/simple_page_crawl_service_spec.rb
@@ -125,4 +125,63 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
)
end
end
+
+ describe '#meta_description' do
+ context 'when meta description exists' do
+ before do
+ stub_request(:get, base_url)
+ .to_return(body: '')
+ end
+
+ it 'returns the meta description content' do
+ expect(service.meta_description).to eq('This is a test page description')
+ end
+ end
+
+ context 'when meta description does not exist' do
+ before do
+ stub_request(:get, base_url)
+ .to_return(body: 'Test')
+ end
+
+ it 'returns nil' do
+ expect(service.meta_description).to be_nil
+ end
+ end
+ end
+
+ describe '#favicon_url' do
+ context 'when favicon exists with relative URL' do
+ before do
+ stub_request(:get, base_url)
+ .to_return(body: '')
+ end
+
+ it 'returns the resolved absolute favicon URL' do
+ expect(service.favicon_url).to eq('https://example.com/favicon.ico')
+ end
+ end
+
+ context 'when favicon exists with absolute URL' do
+ before do
+ stub_request(:get, base_url)
+ .to_return(body: '')
+ end
+
+ it 'returns the absolute favicon URL' do
+ expect(service.favicon_url).to eq('https://cdn.example.com/favicon.ico')
+ end
+ end
+
+ context 'when favicon does not exist' do
+ before do
+ stub_request(:get, base_url)
+ .to_return(body: 'Test')
+ end
+
+ it 'returns nil' do
+ expect(service.favicon_url).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/factories/captain/custom_tool.rb b/spec/factories/captain/custom_tool.rb
new file mode 100644
index 000000000..2bfcbf360
--- /dev/null
+++ b/spec/factories/captain/custom_tool.rb
@@ -0,0 +1,51 @@
+FactoryBot.define do
+ factory :captain_custom_tool, class: 'Captain::CustomTool' do
+ sequence(:title) { |n| "Custom Tool #{n}" }
+ description { 'A custom HTTP tool for external API integration' }
+ endpoint_url { 'https://api.example.com/endpoint' }
+ http_method { 'GET' }
+ auth_type { 'none' }
+ auth_config { {} }
+ param_schema { [] }
+ enabled { true }
+ association :account
+
+ trait :with_post do
+ http_method { 'POST' }
+ request_template { '{ "key": "{{ value }}" }' }
+ end
+
+ trait :with_bearer_auth do
+ auth_type { 'bearer' }
+ auth_config { { token: 'test_bearer_token_123' } }
+ end
+
+ trait :with_basic_auth do
+ auth_type { 'basic' }
+ auth_config { { username: 'test_user', password: 'test_pass' } }
+ end
+
+ trait :with_api_key do
+ auth_type { 'api_key' }
+ auth_config { { key: 'test_api_key', location: 'header', name: 'X-API-Key' } }
+ end
+
+ trait :with_templates do
+ request_template { '{ "order_id": "{{ order_id }}", "source": "chatwoot" }' }
+ response_template { 'Order status: {{ response.status }}' }
+ end
+
+ trait :with_params do
+ param_schema do
+ [
+ { 'name' => 'order_id', 'type' => 'string', 'description' => 'The order ID', 'required' => true },
+ { 'name' => 'include_details', 'type' => 'boolean', 'description' => 'Include order details', 'required' => false }
+ ]
+ end
+ end
+
+ trait :disabled do
+ enabled { false }
+ end
+ end
+end
diff --git a/spec/factories/channel/twilio_sms.rb b/spec/factories/channel/twilio_sms.rb
index 94f632efd..1963a4f28 100644
--- a/spec/factories/channel/twilio_sms.rb
+++ b/spec/factories/channel/twilio_sms.rb
@@ -13,5 +13,9 @@ FactoryBot.define do
sequence(:phone_number) { |n| "+123456789#{n}1" }
messaging_service_sid { nil }
end
+
+ trait :whatsapp do
+ medium { :whatsapp }
+ end
end
end
diff --git a/spec/factories/companies.rb b/spec/factories/companies.rb
new file mode 100644
index 000000000..bdf7e9e9f
--- /dev/null
+++ b/spec/factories/companies.rb
@@ -0,0 +1,20 @@
+FactoryBot.define do
+ factory :company do
+ sequence(:name) { |n| "Company #{n}" }
+ sequence(:domain) { |n| "company#{n}.com" }
+ description { 'A sample company description' }
+ account
+
+ trait :without_domain do
+ domain { nil }
+ end
+
+ trait :with_avatar do
+ avatar { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') }
+ end
+
+ trait :with_long_description do
+ description { 'A' * 500 }
+ end
+ end
+end
diff --git a/spec/jobs/conversation_reply_email_job_spec.rb b/spec/jobs/conversation_reply_email_job_spec.rb
new file mode 100644
index 000000000..32758c48e
--- /dev/null
+++ b/spec/jobs/conversation_reply_email_job_spec.rb
@@ -0,0 +1,33 @@
+require 'rails_helper'
+
+RSpec.describe ConversationReplyEmailJob, type: :job do
+ let(:conversation) { create(:conversation) }
+ let(:mailer) { double }
+ let(:mailer_action) { double }
+
+ before do
+ allow(Conversation).to receive(:find).and_return(conversation)
+ allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
+ allow(mailer).to receive(:reply_with_summary).and_return(mailer_action)
+ allow(mailer).to receive(:reply_without_summary).and_return(mailer_action)
+ allow(mailer_action).to receive(:deliver_later).and_return(true)
+ end
+
+ it 'enqueues on mailers queue' do
+ ActiveJob::Base.queue_adapter = :test
+ expect do
+ described_class.perform_later(conversation.id, 123)
+ end.to have_enqueued_job(described_class).on_queue('mailers')
+ end
+
+ it 'calls reply_with_summary when last incoming message was not email' do
+ described_class.perform_now(conversation.id, 123)
+ expect(mailer).to have_received(:reply_with_summary)
+ end
+
+ it 'calls reply_without_summary when last incoming message was email' do
+ create(:message, conversation: conversation, message_type: :incoming, content_type: 'incoming_email')
+ described_class.perform_now(conversation.id, 123)
+ expect(mailer).to have_received(:reply_without_summary)
+ end
+end
diff --git a/spec/jobs/send_reply_job_spec.rb b/spec/jobs/send_reply_job_spec.rb
index f3c19c2f9..11d3f6b04 100644
--- a/spec/jobs/send_reply_job_spec.rb
+++ b/spec/jobs/send_reply_job_spec.rb
@@ -108,5 +108,32 @@ RSpec.describe SendReplyJob do
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
+
+ it 'calls ::Email::SendOnEmailService when its email message' do
+ email_channel = create(:channel_email)
+ message = create(:message, conversation: create(:conversation, inbox: email_channel.inbox))
+ allow(Email::SendOnEmailService).to receive(:new).with(message: message).and_return(process_service)
+ expect(Email::SendOnEmailService).to receive(:new).with(message: message)
+ expect(process_service).to receive(:perform)
+ described_class.perform_now(message.id)
+ end
+
+ it 'calls ::Messages::SendEmailNotificationService when its webwidget message' do
+ webwidget_channel = create(:channel_widget)
+ message = create(:message, conversation: create(:conversation, inbox: webwidget_channel.inbox))
+ allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service)
+ expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message)
+ expect(process_service).to receive(:perform)
+ described_class.perform_now(message.id)
+ end
+
+ it 'calls ::Messages::SendEmailNotificationService when its api channel message' do
+ api_channel = create(:channel_api)
+ message = create(:message, conversation: create(:conversation, inbox: api_channel.inbox))
+ allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service)
+ expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message)
+ expect(process_service).to receive(:perform)
+ described_class.perform_now(message.id)
+ end
end
end
diff --git a/spec/lib/integrations/slack/incoming_message_builder_spec.rb b/spec/lib/integrations/slack/incoming_message_builder_spec.rb
index 608324e8f..2ce206489 100644
--- a/spec/lib/integrations/slack/incoming_message_builder_spec.rb
+++ b/spec/lib/integrations/slack/incoming_message_builder_spec.rb
@@ -157,6 +157,19 @@ describe Integrations::Slack::IncomingMessageBuilder do
expect(conversation.messages.count).to eql(messages_count)
end
+
+ it 'handles different file types correctly' do
+ expect(hook).not_to be_nil
+ video_attachment_params = message_with_attachments.deep_dup
+ video_attachment_params[:event][:files][0][:filetype] = 'mp4'
+ video_attachment_params[:event][:files][0][:mimetype] = 'video/mp4'
+
+ builder = described_class.new(video_attachment_params)
+ allow(builder).to receive(:sender).and_return(nil)
+
+ expect { builder.perform }.not_to raise_error
+ expect(conversation.messages.last.attachments).to be_any
+ end
end
context 'when link shared' do
diff --git a/spec/lib/webhooks/trigger_spec.rb b/spec/lib/webhooks/trigger_spec.rb
index 8ff2a21a5..224a35e07 100644
--- a/spec/lib/webhooks/trigger_spec.rb
+++ b/spec/lib/webhooks/trigger_spec.rb
@@ -1,6 +1,8 @@
require 'rails_helper'
describe Webhooks::Trigger do
+ include ActiveJob::TestHelper
+
subject(:trigger) { described_class }
let!(:account) { create(:account) }
@@ -8,8 +10,18 @@ describe Webhooks::Trigger do
let!(:conversation) { create(:conversation, inbox: inbox) }
let!(:message) { create(:message, account: account, inbox: inbox, conversation: conversation) }
- let!(:webhook_type) { :api_inbox_webhook }
+ let(:webhook_type) { :api_inbox_webhook }
let!(:url) { 'https://test.com' }
+ let(:agent_bot_error_content) { I18n.t('conversations.activity.agent_bot.error_moved_to_open') }
+
+ before do
+ ActiveJob::Base.queue_adapter = :test
+ end
+
+ after do
+ clear_enqueued_jobs
+ clear_performed_jobs
+ end
describe '#execute' do
it 'triggers webhook' do
@@ -54,6 +66,57 @@ describe Webhooks::Trigger do
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed')
end
+
+ context 'when webhook type is agent bot' do
+ let(:webhook_type) { :agent_bot_webhook }
+
+ it 'reopens conversation and enqueues activity message if pending' do
+ conversation.update(status: :pending)
+ payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
+
+ expect(RestClient::Request).to receive(:execute)
+ .with(
+ method: :post,
+ url: url,
+ payload: payload.to_json,
+ headers: { content_type: :json, accept: :json },
+ timeout: 5
+ ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
+
+ expect do
+ perform_enqueued_jobs do
+ trigger.execute(url, payload, webhook_type)
+ end
+ end.not_to(change { message.reload.status })
+
+ expect(conversation.reload.status).to eq('open')
+
+ activity_message = conversation.reload.messages.order(:created_at).last
+ expect(activity_message.message_type).to eq('activity')
+ expect(activity_message.content).to eq(agent_bot_error_content)
+ end
+
+ it 'does not change message status or enqueue activity when conversation is not pending' do
+ payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
+
+ expect(RestClient::Request).to receive(:execute)
+ .with(
+ method: :post,
+ url: url,
+ payload: payload.to_json,
+ headers: { content_type: :json, accept: :json },
+ timeout: 5
+ ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
+
+ expect do
+ trigger.execute(url, payload, webhook_type)
+ end.not_to(change { message.reload.status })
+
+ expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
+
+ expect(conversation.reload.status).to eq('open')
+ end
+ end
end
it 'does not update message status if webhook fails for other events' do
diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb
index ecd97333e..2576361fa 100644
--- a/spec/mailers/conversation_reply_mailer_spec.rb
+++ b/spec/mailers/conversation_reply_mailer_spec.rb
@@ -243,8 +243,8 @@ RSpec.describe ConversationReplyMailer do
expect(mail.decoded).to include message.content
end
- it 'updates the source_id' do
- expect(mail.message_id).to eq message.source_id
+ it 'builds messageID properly' do
+ expect(mail.message_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}")
end
context 'when message is a CSAT survey' do
@@ -335,6 +335,118 @@ RSpec.describe ConversationReplyMailer do
expect(mail.body.encoded).not_to match(%r{]*>avatar\.png})
end
end
+
+ context 'with custom email content' do
+ it 'uses custom HTML content when available and creates multipart email' do
+ message_with_custom_content = create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'outgoing',
+ content: 'Regular message content',
+ content_attributes: {
+ email: {
+ html_content: {
+ reply: 'Custom HTML content for email
'
+ },
+ text_content: {
+ reply: 'Custom text content for email'
+ }
+ }
+ })
+
+ mail = described_class.email_reply(message_with_custom_content).deliver_now
+
+ # Check HTML part contains custom HTML content
+ html_part = mail.html_part || mail
+ expect(html_part.body.encoded).to include('Custom HTML content for email
')
+ expect(html_part.body.encoded).not_to include('Regular message content')
+
+ # Check text part contains custom text content
+ text_part = mail.text_part
+ if text_part
+ expect(text_part.body.encoded).to include('Custom text content for email')
+ expect(text_part.body.encoded).not_to include('Regular message content')
+ end
+ end
+
+ it 'falls back to markdown rendering when custom HTML content is not available' do
+ message_without_custom_content = create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'outgoing',
+ content: 'Regular **markdown** content')
+
+ mail = described_class.email_reply(message_without_custom_content).deliver_now
+
+ html_part = mail.html_part || mail
+ expect(html_part.body.encoded).to include('markdown')
+ expect(html_part.body.encoded).to include('Regular')
+ end
+
+ it 'handles empty custom HTML content gracefully' do
+ message_with_empty_content = create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'outgoing',
+ content: 'Regular **markdown** content',
+ content_attributes: {
+ email: {
+ html_content: {
+ reply: ''
+ }
+ }
+ })
+
+ mail = described_class.email_reply(message_with_empty_content).deliver_now
+
+ html_part = mail.html_part || mail
+ expect(html_part.body.encoded).to include('markdown')
+ expect(html_part.body.encoded).to include('Regular')
+ end
+
+ it 'handles nil custom HTML content gracefully' do
+ message_with_nil_content = create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'outgoing',
+ content: 'Regular **markdown** content',
+ content_attributes: {
+ email: {
+ html_content: {
+ reply: nil
+ }
+ }
+ })
+
+ mail = described_class.email_reply(message_with_nil_content).deliver_now
+
+ expect(mail.body.encoded).to include('markdown')
+ expect(mail.body.encoded).to include('Regular')
+ end
+
+ it 'uses custom text content in text part when only text is provided' do
+ message_with_text_only = create(:message,
+ conversation: conversation,
+ account: account,
+ message_type: 'outgoing',
+ content: 'Regular message content',
+ content_attributes: {
+ email: {
+ text_content: {
+ reply: 'Custom text content only'
+ }
+ }
+ })
+
+ mail = described_class.email_reply(message_with_text_only).deliver_now
+
+ text_part = mail.text_part
+ if text_part
+ expect(text_part.body.encoded).to include('Custom text content only')
+ expect(text_part.body.encoded).not_to include('Regular message content')
+ end
+ end
+ end
end
context 'when smtp enabled for email channel' do
diff --git a/spec/models/application_record_external_credentials_encryption_spec.rb b/spec/models/application_record_external_credentials_encryption_spec.rb
new file mode 100644
index 000000000..65c347434
--- /dev/null
+++ b/spec/models/application_record_external_credentials_encryption_spec.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe ApplicationRecord do
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_email,
+ attribute: :smtp_password,
+ value: 'smtp-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_email,
+ attribute: :imap_password,
+ value: 'imap-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_twilio_sms,
+ attribute: :auth_token,
+ value: 'twilio-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :integrations_hook,
+ attribute: :access_token,
+ value: 'hook-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_facebook_page,
+ attribute: :page_access_token,
+ value: 'fb-page-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_facebook_page,
+ attribute: :user_access_token,
+ value: 'fb-user-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_instagram,
+ attribute: :access_token,
+ value: 'ig-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_line,
+ attribute: :line_channel_secret,
+ value: 'line-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_line,
+ attribute: :line_channel_token,
+ value: 'line-token-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_telegram,
+ attribute: :bot_token,
+ value: 'telegram-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_twitter_profile,
+ attribute: :twitter_access_token,
+ value: 'twitter-access-secret'
+
+ it_behaves_like 'encrypted external credential',
+ factory: :channel_twitter_profile,
+ attribute: :twitter_access_token_secret,
+ value: 'twitter-secret-secret'
+
+ context 'when backfilling legacy plaintext' do
+ before do
+ skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
+ end
+
+ it 'reads existing plaintext and encrypts on update' do
+ account = create(:account)
+ channel = create(:channel_email, account: account, smtp_password: nil)
+
+ # Simulate legacy plaintext by updating the DB directly
+ sql = ActiveRecord::Base.send(
+ :sanitize_sql_array,
+ ['UPDATE channel_email SET smtp_password = ? WHERE id = ?', 'legacy-plain', channel.id]
+ )
+ ActiveRecord::Base.connection.execute(sql)
+
+ legacy_record = Channel::Email.find(channel.id)
+ expect(legacy_record.smtp_password).to eq('legacy-plain')
+
+ legacy_record.update!(smtp_password: 'encrypted-now')
+
+ stored_value = legacy_record.reload.read_attribute_before_type_cast(:smtp_password)
+ expect(stored_value).to be_present
+ expect(stored_value).not_to include('encrypted-now')
+ expect(legacy_record.smtp_password).to eq('encrypted-now')
+ end
+ end
+
+ context 'when looking up telegram legacy records' do
+ before do
+ skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
+ end
+
+ it 'finds plaintext records via fallback lookup' do
+ channel = create(:channel_telegram, bot_token: 'legacy-token')
+
+ # Simulate legacy plaintext by updating the DB directly
+ sql = ActiveRecord::Base.send(
+ :sanitize_sql_array,
+ ['UPDATE channel_telegram SET bot_token = ? WHERE id = ?', 'legacy-token', channel.id]
+ )
+ ActiveRecord::Base.connection.execute(sql)
+
+ found = Channel::Telegram.find_by(bot_token: 'legacy-token')
+ expect(found).to eq(channel)
+ end
+ end
+end
diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb
index a0bd48e39..9cc43d0fd 100644
--- a/spec/models/message_spec.rb
+++ b/spec/models/message_spec.rb
@@ -4,6 +4,12 @@ require 'rails_helper'
require Rails.root.join 'spec/models/concerns/liquidable_shared.rb'
RSpec.describe Message do
+ before do
+ # rubocop:disable RSpec/AnyInstance
+ allow_any_instance_of(described_class).to receive(:reindex_for_search).and_return(true)
+ # rubocop:enable RSpec/AnyInstance
+ end
+
context 'with validations' do
it { is_expected.to validate_presence_of(:inbox_id) }
it { is_expected.to validate_presence_of(:conversation_id) }
@@ -310,43 +316,52 @@ RSpec.describe Message do
end
context 'with conversation continuity' do
- it 'calls notify email method on after save for outgoing messages in website channel' do
- allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
- message.message_type = 'outgoing'
- message.save!
- expect(ConversationReplyEmailWorker).to have_received(:perform_in)
+ let(:inbox_with_continuity) do
+ create(:inbox, account: message.account,
+ channel: build(:channel_widget, account: message.account, continuity_via_email: true))
end
- it 'does not call notify email for website channel if continuity is disabled' do
- message.inbox = create(:inbox, account: message.account,
- channel: build(:channel_widget, account: message.account, continuity_via_email: false))
- allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
+ it 'schedules email notification for outgoing messages in website channel' do
+ message.inbox = inbox_with_continuity
+ message.conversation.update!(inbox: inbox_with_continuity)
+ message.conversation.contact.update!(email: 'test@example.com')
message.message_type = 'outgoing'
- message.save!
- expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
+
+ ActiveJob::Base.queue_adapter = :test
+ allow(Redis::Alfred).to receive(:set).and_return(true)
+ perform_enqueued_jobs(only: SendReplyJob) do
+ expect { message.save! }.to have_enqueued_job(ConversationReplyEmailJob).with(message.conversation.id, kind_of(Integer)).on_queue('mailers')
+ end
end
- it 'wont call notify email method for private notes' do
+ it 'does not schedule email for website channel if continuity is disabled' do
+ inbox_without_continuity = create(:inbox, account: message.account,
+ channel: build(:channel_widget, account: message.account, continuity_via_email: false))
+ message.inbox = inbox_without_continuity
+ message.conversation.update!(inbox: inbox_without_continuity)
+ message.conversation.contact.update!(email: 'test@example.com')
+ message.message_type = 'outgoing'
+
+ ActiveJob::Base.queue_adapter = :test
+ expect { message.save! }.not_to have_enqueued_job(ConversationReplyEmailJob)
+ end
+
+ it 'does not schedule email for private notes' do
+ message.inbox = inbox_with_continuity
+ message.conversation.update!(inbox: inbox_with_continuity)
+ message.conversation.contact.update!(email: 'test@example.com')
message.private = true
- allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
- message.save!
- expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
- end
-
- it 'calls EmailReply worker if the channel is email' do
- message.inbox = create(:inbox, account: message.account, channel: build(:channel_email, account: message.account))
- allow(EmailReplyWorker).to receive(:perform_in).and_return(true)
message.message_type = 'outgoing'
- message.content_attributes = { email: { text_content: { quoted: 'quoted text' } } }
- message.save!
- expect(EmailReplyWorker).to have_received(:perform_in).with(1.second, message.id)
+
+ ActiveJob::Base.queue_adapter = :test
+ expect { message.save! }.not_to have_enqueued_job(ConversationReplyEmailJob)
end
- it 'wont call notify email method unless its website or email channel' do
- message.inbox = create(:inbox, account: message.account, channel: build(:channel_api, account: message.account))
- allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
+ it 'calls SendReplyJob for all channels' do
+ allow(SendReplyJob).to receive(:perform_later).and_return(true)
+ message.message_type = 'outgoing'
message.save!
- expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
+ expect(SendReplyJob).to have_received(:perform_later).with(message.id)
end
end
end
@@ -678,4 +693,54 @@ RSpec.describe Message do
end
end
end
+
+ describe '#reindex_for_search callback' do
+ let(:account) { create(:account) }
+ let(:conversation) { create(:conversation, account: account) }
+
+ before do
+ allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
+ account.enable_features('advanced_search_indexing')
+ end
+
+ context 'when message should be indexed' do
+ it 'calls reindex_for_search for incoming message on create' do
+ message = build(:message, conversation: conversation, account: account, message_type: :incoming)
+ expect(message).to receive(:reindex_for_search)
+ message.save!
+ end
+
+ it 'calls reindex_for_search for outgoing message on update' do
+ # rubocop:disable RSpec/AnyInstance
+ allow_any_instance_of(described_class).to receive(:reindex_for_search).and_return(true)
+ # rubocop:enable RSpec/AnyInstance
+ message = create(:message, conversation: conversation, account: account, message_type: :outgoing)
+ expect(message).to receive(:reindex_for_search).and_return(true)
+ message.update!(content: 'Updated content')
+ end
+ end
+
+ context 'when message should not be indexed' do
+ it 'does not call reindex_for_search for activity message' do
+ message = build(:message, conversation: conversation, account: account, message_type: :activity)
+ expect(message).not_to receive(:reindex_for_search)
+ message.save!
+ end
+
+ it 'does not call reindex_for_search for unpaid account on cloud' do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ account.disable_features('advanced_search_indexing')
+ message = build(:message, conversation: conversation, account: account, message_type: :incoming)
+ expect(message).not_to receive(:reindex_for_search)
+ message.save!
+ end
+
+ it 'does not call reindex_for_search when advanced search is not allowed' do
+ allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
+ message = build(:message, conversation: conversation, account: account, message_type: :incoming)
+ expect(message).not_to receive(:reindex_for_search)
+ message.save!
+ end
+ end
+ end
end
diff --git a/spec/policies/conversation_policy_spec.rb b/spec/policies/conversation_policy_spec.rb
index ecc3134fc..d75a6bc3a 100644
--- a/spec/policies/conversation_policy_spec.rb
+++ b/spec/policies/conversation_policy_spec.rb
@@ -4,11 +4,12 @@ RSpec.describe ConversationPolicy, type: :policy do
subject { described_class }
let(:account) { create(:account) }
- let(:conversation) { create(:conversation, account: account) }
let(:administrator) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
- let(:administrator_context) { { user: administrator, account: account, account_user: administrator.account_users.first } }
- let(:agent_context) { { user: agent, account: account, account_user: agent.account_users.first } }
+ let(:administrator_context) { { user: administrator, account: account, account_user: administrator.account_users.find_by(account: account) } }
+ let(:agent_context) { { user: agent, account: account, account_user: agent.account_users.find_by(account: account) } }
+
+ let(:conversation) { create(:conversation, account: account) }
permissions :destroy? do
context 'when user is an administrator' do
@@ -31,4 +32,42 @@ RSpec.describe ConversationPolicy, type: :policy do
end
end
end
+
+ permissions :show? do
+ context 'when user is an administrator' do
+ it 'allows access' do
+ expect(subject).to permit(administrator_context, conversation)
+ end
+ end
+
+ context 'when agent has inbox access' do
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before { create(:inbox_member, user: agent, inbox: inbox) }
+
+ it 'allows access' do
+ expect(subject).to permit(agent_context, conversation)
+ end
+ end
+
+ context 'when agent has team access' do
+ let(:team) { create(:team, account: account) }
+ let(:conversation) { create(:conversation, :with_team, account: account, team: team) }
+
+ before { create(:team_member, team: team, user: agent) }
+
+ it 'allows access' do
+ expect(subject).to permit(agent_context, conversation)
+ end
+ end
+
+ context 'when agent lacks inbox and team access' do
+ let(:conversation) { create(:conversation, account: account) }
+
+ it 'denies access' do
+ expect(subject).not_to permit(agent_context, conversation)
+ end
+ end
+ end
end
diff --git a/spec/services/email/send_on_email_service_spec.rb b/spec/services/email/send_on_email_service_spec.rb
new file mode 100644
index 000000000..5a4997eb0
--- /dev/null
+++ b/spec/services/email/send_on_email_service_spec.rb
@@ -0,0 +1,86 @@
+require 'rails_helper'
+
+describe Email::SendOnEmailService do
+ let(:account) { create(:account) }
+ let(:email_channel) { create(:channel_email, account: account) }
+ let(:inbox) { create(:inbox, account: account, channel: email_channel) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:message) { create(:message, conversation: conversation, message_type: 'outgoing') }
+ let(:service) { described_class.new(message: message) }
+
+ describe '#perform' do
+ let(:mailer_context) { instance_double(ConversationReplyMailer) }
+ let(:delivery) { instance_double(ActionMailer::MessageDelivery) }
+ let(:email_message) { instance_double(Mail::Message) }
+
+ before do
+ allow(ConversationReplyMailer).to receive(:with).with(account: message.account).and_return(mailer_context)
+ end
+
+ context 'when message is email notifiable' do
+ before do
+ allow(mailer_context).to receive(:email_reply).with(message).and_return(delivery)
+ allow(delivery).to receive(:deliver_now).and_return(email_message)
+ allow(email_message).to receive(:message_id).and_return(
+ "conversation/#{conversation.uuid}/messages/" \
+ "#{message.id}@#{conversation.account.domain}"
+ )
+ end
+
+ it 'sends email via ConversationReplyMailer' do
+ service.perform
+
+ expect(ConversationReplyMailer).to have_received(:with).with(account: message.account)
+ expect(mailer_context).to have_received(:email_reply).with(message)
+ expect(delivery).to have_received(:deliver_now)
+ end
+
+ it 'updates message source id on success' do
+ service.perform
+
+ expect(message.reload.source_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}")
+ end
+ end
+
+ context 'when message is not email notifiable' do
+ let(:message) { create(:message, conversation: conversation, message_type: 'incoming') }
+
+ before do
+ allow(mailer_context).to receive(:email_reply)
+ end
+
+ it 'does not send email' do
+ service.perform
+
+ expect(ConversationReplyMailer).not_to have_received(:with)
+ expect(mailer_context).not_to have_received(:email_reply)
+ end
+ end
+
+ context 'when an error occurs' do
+ let(:error_message) { 'SMTP connection failed' }
+ let(:error) { StandardError.new(error_message) }
+ let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
+ let(:status_service) { instance_double(Messages::StatusUpdateService, perform: true) }
+
+ before do
+ allow(mailer_context).to receive(:email_reply).with(message).and_return(delivery)
+ allow(delivery).to receive(:deliver_now).and_raise(error)
+ allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
+ end
+
+ it 'captures the exception' do
+ expect(ChatwootExceptionTracker).to receive(:new).with(error, account: message.account)
+
+ service.perform
+ end
+
+ it 'updates message status to failed' do
+ service.perform
+
+ expect(message.reload.status).to eq('failed')
+ expect(message.reload.external_error).to eq(error_message)
+ end
+ end
+ end
+end
diff --git a/spec/services/messages/send_email_notification_service_spec.rb b/spec/services/messages/send_email_notification_service_spec.rb
new file mode 100644
index 000000000..7c0970fe1
--- /dev/null
+++ b/spec/services/messages/send_email_notification_service_spec.rb
@@ -0,0 +1,178 @@
+require 'rails_helper'
+
+describe Messages::SendEmailNotificationService do
+ let(:account) { create(:account) }
+ let(:conversation) { create(:conversation, account: account) }
+ let(:message) { create(:message, conversation: conversation, message_type: 'outgoing') }
+ let(:service) { described_class.new(message: message) }
+
+ describe '#perform' do
+ context 'when email notification should be sent' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ conversation.contact.update!(email: 'test@example.com')
+ allow(Redis::Alfred).to receive(:set).and_return(true)
+ ActiveJob::Base.queue_adapter = :test
+ end
+
+ it 'enqueues ConversationReplyEmailJob' do
+ expect { service.perform }.to have_enqueued_job(ConversationReplyEmailJob).with(conversation.id, message.id).on_queue('mailers')
+ end
+
+ it 'atomically sets redis key to prevent duplicate emails' do
+ expected_key = format(Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
+
+ service.perform
+
+ expect(Redis::Alfred).to have_received(:set).with(expected_key, message.id, nx: true, ex: 1.hour.to_i)
+ end
+
+ context 'when redis key already exists' do
+ before do
+ allow(Redis::Alfred).to receive(:set).and_return(false)
+ end
+
+ it 'does not enqueue job' do
+ expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
+ end
+
+ it 'attempts atomic set once' do
+ service.perform
+
+ expect(Redis::Alfred).to have_received(:set).once
+ end
+ end
+ end
+
+ context 'when handling concurrent requests' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ conversation.contact.update!(email: 'test@example.com')
+ end
+
+ it 'prevents duplicate jobs under race conditions' do
+ # Create 5 threads that simultaneously try to enqueue workers for the same conversation
+ threads = Array.new(5) do
+ Thread.new do
+ msg = create(:message, conversation: conversation, message_type: 'outgoing')
+ described_class.new(message: msg).perform
+ end
+ end
+
+ threads.each(&:join)
+
+ # Only ONE job should be scheduled despite 5 concurrent attempts
+ jobs_for_conversation = ActiveJob::Base.queue_adapter.enqueued_jobs.select do |job|
+ job[:job] == ConversationReplyEmailJob && job[:args].first == conversation.id
+ end
+ expect(jobs_for_conversation.size).to eq(1)
+ end
+ end
+
+ context 'when email notification should not be sent' do
+ before do
+ ActiveJob::Base.queue_adapter = :test
+ end
+
+ context 'when message is not email notifiable' do
+ let(:message) { create(:message, conversation: conversation, message_type: 'incoming') }
+
+ it 'does not enqueue job' do
+ expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
+ end
+ end
+
+ context 'when contact has no email' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ conversation.contact.update!(email: nil)
+ end
+
+ it 'does not enqueue job' do
+ expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
+ end
+ end
+
+ context 'when channel does not support email notifications' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_sms, account: account)) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ conversation.contact.update!(email: 'test@example.com')
+ end
+
+ it 'does not enqueue job' do
+ expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
+ end
+ end
+ end
+ end
+
+ describe '#should_send_email_notification?' do
+ context 'with WebWidget channel' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ conversation.contact.update!(email: 'test@example.com')
+ end
+
+ it 'returns true when continuity_via_email is enabled' do
+ expect(service.send(:should_send_email_notification?)).to be true
+ end
+
+ context 'when continuity_via_email is disabled' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: false)) }
+
+ it 'returns false' do
+ expect(service.send(:should_send_email_notification?)).to be false
+ end
+ end
+ end
+
+ context 'with API channel' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_api, account: account)) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ conversation.contact.update!(email: 'test@example.com')
+ allow(account).to receive(:feature_enabled?).and_return(false)
+ allow(account).to receive(:feature_enabled?).with('email_continuity_on_api_channel').and_return(true)
+ end
+
+ it 'returns true when email_continuity_on_api_channel feature is enabled' do
+ expect(service.send(:should_send_email_notification?)).to be true
+ end
+
+ context 'when email_continuity_on_api_channel feature is disabled' do
+ before do
+ allow(account).to receive(:feature_enabled?).and_return(false)
+ allow(account).to receive(:feature_enabled?).with('email_continuity_on_api_channel').and_return(false)
+ end
+
+ it 'returns false' do
+ expect(service.send(:should_send_email_notification?)).to be false
+ end
+ end
+ end
+
+ context 'with other channels' do
+ let(:inbox) { create(:inbox, account: account, channel: create(:channel_email, account: account)) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ conversation.contact.update!(email: 'test@example.com')
+ end
+
+ it 'returns false' do
+ expect(service.send(:should_send_email_notification?)).to be false
+ end
+ end
+ end
+end
diff --git a/spec/services/twilio/incoming_message_service_spec.rb b/spec/services/twilio/incoming_message_service_spec.rb
index d32ef59bb..190a6c45a 100644
--- a/spec/services/twilio/incoming_message_service_spec.rb
+++ b/spec/services/twilio/incoming_message_service_spec.rb
@@ -402,6 +402,230 @@ describe Twilio::IncomingMessageService do
existing_contact.reload
expect(existing_contact.name).to eq('Alice Johnson')
end
+
+ describe 'When the incoming number is a Brazilian number in new format with 9 included' do
+ let!(:whatsapp_twilio_channel) do
+ create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
+ inbox: create(:inbox, account: account, greeting_enabled: false))
+ end
+
+ it 'creates appropriate conversations, message and contacts if contact does not exist' do
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+5541988887777',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Brazil',
+ ProfileName: 'João Silva'
+ }
+
+ described_class.new(params: params).perform
+
+ expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
+ expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('João Silva')
+ expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
+ expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
+ end
+
+ it 'appends to existing contact if contact inbox exists' do
+ # Create existing contact with same format
+ normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
+ contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
+ inbox: whatsapp_twilio_channel.inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
+
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+5541988887777',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Another message from Brazil',
+ ProfileName: 'João Silva'
+ }
+
+ described_class.new(params: params).perform
+
+ # No new conversation should be created
+ expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
+ # Message appended to the last conversation
+ expect(last_conversation.messages.last.content).to eq('Another message from Brazil')
+ end
+ end
+
+ describe 'When incoming number is a Brazilian number in old format without the 9 included' do
+ let!(:whatsapp_twilio_channel) do
+ create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
+ inbox: create(:inbox, account: account, greeting_enabled: false))
+ end
+
+ it 'appends to existing contact when contact inbox exists in old format' do
+ # Create existing contact with old format (12 digits)
+ old_contact = create(:contact, account: account, phone_number: '+554188887777')
+ contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+554188887777', contact: old_contact, inbox: whatsapp_twilio_channel.inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
+
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+554188887777',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Brazil old format',
+ ProfileName: 'Maria Silva'
+ }
+
+ described_class.new(params: params).perform
+
+ # No new conversation should be created
+ expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
+ # Message appended to the last conversation
+ expect(last_conversation.messages.last.content).to eq('Test message from Brazil old format')
+ end
+
+ it 'appends to existing contact when contact inbox exists in new format' do
+ # Create existing contact with new format (13 digits)
+ normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
+ contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
+ inbox: whatsapp_twilio_channel.inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
+
+ # Incoming message with old format (12 digits)
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+554188887777',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Brazil',
+ ProfileName: 'João Silva'
+ }
+
+ described_class.new(params: params).perform
+
+ # Should find and use existing contact, not create duplicate
+ expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
+ # Message appended to the existing conversation
+ expect(last_conversation.messages.last.content).to eq('Test message from Brazil')
+ # Should use the existing contact's source_id (normalized format)
+ expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
+ end
+
+ it 'creates contact inbox with incoming number when no existing contact' do
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+554188887777',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Brazil',
+ ProfileName: 'Carlos Silva'
+ }
+
+ described_class.new(params: params).perform
+
+ expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
+ expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Silva')
+ expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
+ expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+554188887777')
+ end
+ end
+
+ describe 'When the incoming number is an Argentine number with 9 after country code' do
+ let!(:whatsapp_twilio_channel) do
+ create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
+ inbox: create(:inbox, account: account, greeting_enabled: false))
+ end
+
+ it 'creates appropriate conversations, message and contacts if contact does not exist' do
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+5491123456789',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Argentina',
+ ProfileName: 'Carlos Mendoza'
+ }
+
+ described_class.new(params: params).perform
+
+ expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
+ expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Mendoza')
+ expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
+ expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5491123456789')
+ end
+
+ it 'appends to existing contact if contact inbox exists with normalized format' do
+ # Create existing contact with normalized format (without 9 after country code)
+ normalized_contact = create(:contact, account: account, phone_number: '+541123456789')
+ contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: normalized_contact,
+ inbox: whatsapp_twilio_channel.inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
+
+ # Incoming message with 9 after country code
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+5491123456789',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Argentina',
+ ProfileName: 'Carlos Mendoza'
+ }
+
+ described_class.new(params: params).perform
+
+ # Should find and use existing contact, not create duplicate
+ expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
+ # Message appended to the existing conversation
+ expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
+ # Should use the normalized source_id from existing contact
+ expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
+ end
+ end
+
+ describe 'When incoming number is an Argentine number without 9 after country code' do
+ let!(:whatsapp_twilio_channel) do
+ create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
+ inbox: create(:inbox, account: account, greeting_enabled: false))
+ end
+
+ it 'appends to existing contact when contact inbox exists with same format' do
+ # Create existing contact with same format (without 9)
+ contact = create(:contact, account: account, phone_number: '+541123456789')
+ contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: contact, inbox: whatsapp_twilio_channel.inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
+
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+541123456789',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Argentina',
+ ProfileName: 'Ana García'
+ }
+
+ described_class.new(params: params).perform
+
+ # No new conversation should be created
+ expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
+ # Message appended to the last conversation
+ expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
+ end
+
+ it 'creates contact inbox with incoming number when no existing contact' do
+ params = {
+ SmsSid: 'SMxx',
+ From: 'whatsapp:+541123456789',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
+ Body: 'Test message from Argentina',
+ ProfileName: 'Diego López'
+ }
+
+ described_class.new(params: params).perform
+
+ expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
+ expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Diego López')
+ expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
+ expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
+ end
+ end
end
end
end
diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb
index ede1ba824..6c23e9b71 100644
--- a/spec/services/whatsapp/incoming_message_service_spec.rb
+++ b/spec/services/whatsapp/incoming_message_service_spec.rb
@@ -341,6 +341,58 @@ describe Whatsapp::IncomingMessageService do
end
end
+ describe 'When the incoming waid is an Argentine number with 9 after country code' do
+ let(:wa_id) { '5491123456789' }
+
+ it 'creates appropriate conversations, message and contacts if contact does not exist' do
+ described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
+ expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
+ expect(Contact.all.first.name).to eq('Sojan Jose')
+ expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
+ expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
+ end
+
+ it 'appends to existing contact if contact inbox exists with normalized format' do
+ # Normalized format removes the 9 after country code
+ normalized_wa_id = '541123456789'
+ contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: normalized_wa_id)
+ last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
+ described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
+ # no new conversation should be created
+ expect(whatsapp_channel.inbox.conversations.count).to eq(1)
+ # message appended to the last conversation
+ expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
+ # should use the normalized wa_id from existing contact
+ expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(normalized_wa_id)
+ end
+ end
+
+ describe 'When incoming waid is an Argentine number without 9 after country code' do
+ let(:wa_id) { '541123456789' }
+
+ context 'when a contact inbox exists with the same format' do
+ it 'appends to existing contact' do
+ contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
+ last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
+ described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
+ # no new conversation should be created
+ expect(whatsapp_channel.inbox.conversations.count).to eq(1)
+ # message appended to the last conversation
+ expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
+ end
+ end
+
+ context 'when a contact inbox does not exist' do
+ it 'creates contact inbox with the incoming waid' do
+ described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
+ expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
+ expect(Contact.all.first.name).to eq('Sojan Jose')
+ expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
+ expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
+ end
+ end
+ end
+
describe 'when message processing is in progress' do
it 'ignores the current message creation request' do
params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }],
diff --git a/spec/services/whatsapp/populate_template_parameters_service_spec.rb b/spec/services/whatsapp/populate_template_parameters_service_spec.rb
new file mode 100644
index 000000000..05390bd90
--- /dev/null
+++ b/spec/services/whatsapp/populate_template_parameters_service_spec.rb
@@ -0,0 +1,70 @@
+require 'rails_helper'
+
+describe Whatsapp::PopulateTemplateParametersService do
+ let(:service) { described_class.new }
+
+ describe '#normalize_url' do
+ it 'normalizes URLs with spaces' do
+ url_with_spaces = 'https://example.com/path with spaces'
+ normalized = service.send(:normalize_url, url_with_spaces)
+
+ expect(normalized).to eq('https://example.com/path%20with%20spaces')
+ end
+
+ it 'handles URLs with special characters' do
+ url = 'https://example.com/path?query=test value'
+ normalized = service.send(:normalize_url, url)
+
+ expect(normalized).to include('https://example.com/path')
+ expect(normalized).not_to include(' ')
+ end
+
+ it 'returns valid URLs unchanged' do
+ url = 'https://example.com/valid-path'
+ normalized = service.send(:normalize_url, url)
+
+ expect(normalized).to eq(url)
+ end
+ end
+
+ describe '#build_media_parameter' do
+ context 'when URL contains spaces' do
+ it 'normalizes the URL before building media parameter' do
+ url_with_spaces = 'https://example.com/image with spaces.jpg'
+ result = service.build_media_parameter(url_with_spaces, 'IMAGE')
+
+ expect(result[:type]).to eq('image')
+ expect(result[:image][:link]).to eq('https://example.com/image%20with%20spaces.jpg')
+ end
+ end
+
+ context 'when URL contains special characters in query string' do
+ it 'normalizes the URL correctly' do
+ url = 'https://example.com/video.mp4?title=My Video'
+ result = service.build_media_parameter(url, 'VIDEO', 'test_video')
+
+ expect(result[:type]).to eq('video')
+ expect(result[:video][:link]).not_to include(' ')
+ end
+ end
+
+ context 'when URL is already valid' do
+ it 'builds media parameter without changing URL' do
+ url = 'https://example.com/document.pdf'
+ result = service.build_media_parameter(url, 'DOCUMENT', 'test.pdf')
+
+ expect(result[:type]).to eq('document')
+ expect(result[:document][:link]).to eq(url)
+ expect(result[:document][:filename]).to eq('test.pdf')
+ end
+ end
+
+ context 'when URL is blank' do
+ it 'returns nil' do
+ result = service.build_media_parameter('', 'IMAGE')
+
+ expect(result).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/support/examples/encrypted_external_credential_examples.rb b/spec/support/examples/encrypted_external_credential_examples.rb
new file mode 100644
index 000000000..c67d814a9
--- /dev/null
+++ b/spec/support/examples/encrypted_external_credential_examples.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'encrypted external credential' do |factory:, attribute:, value: 'secret-token'|
+ before do
+ skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
+ if defined?(Facebook::Messenger::Subscriptions)
+ allow(Facebook::Messenger::Subscriptions).to receive(:subscribe).and_return(true)
+ allow(Facebook::Messenger::Subscriptions).to receive(:unsubscribe).and_return(true)
+ end
+ end
+
+ it "encrypts #{attribute} at rest" do
+ record = create(factory, attribute => value)
+
+ raw_stored_value = record.reload.read_attribute_before_type_cast(attribute).to_s
+ expect(raw_stored_value).to be_present
+ expect(raw_stored_value).not_to include(value)
+ expect(record.public_send(attribute)).to eq(value)
+ expect(record.encrypted_attribute?(attribute)).to be(true)
+ end
+end
diff --git a/spec/workers/conversation_reply_email_worker_spec.rb b/spec/workers/conversation_reply_email_worker_spec.rb
deleted file mode 100644
index 7b28eedde..000000000
--- a/spec/workers/conversation_reply_email_worker_spec.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-require 'rails_helper'
-
-Sidekiq::Testing.fake!
-RSpec.describe ConversationReplyEmailWorker, type: :worker do
- let(:conversation) { build(:conversation, display_id: nil) }
- let(:message) { build(:message, conversation: conversation, content_type: 'incoming_email', inbox: conversation.inbox) }
- let(:mailer) { double }
- let(:mailer_action) { double }
-
- describe 'testing ConversationSummaryEmailWorker' do
- before do
- conversation.save!
- allow(Conversation).to receive(:find).and_return(conversation)
- allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
- allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
- allow(mailer).to receive(:reply_with_summary).and_return(mailer_action)
- allow(mailer).to receive(:reply_without_summary).and_return(mailer_action)
- allow(mailer_action).to receive(:deliver_later).and_return(true)
- end
-
- it 'worker jobs are enqueued in the mailers queue' do
- described_class.perform_async
- expect(described_class.queue).to eq(:mailers)
- end
-
- it 'goes into the jobs array for testing environment' do
- expect do
- described_class.perform_async
- end.to change(described_class.jobs, :size).by(1)
- described_class.new.perform(1, message.id)
- end
-
- context 'with actions performed by the worker' do
- it 'calls ConversationSummaryMailer#reply_with_summary when last incoming message was not email' do
- described_class.new.perform(1, message.id)
- expect(mailer).to have_received(:reply_with_summary)
- end
-
- it 'calls ConversationSummaryMailer#reply_without_summary when last incoming message was from email' do
- message.save!
- described_class.new.perform(1, message.id)
- expect(mailer).to have_received(:reply_without_summary)
- end
- end
- end
-end
diff --git a/spec/workers/email_reply_worker_spec.rb b/spec/workers/email_reply_worker_spec.rb
deleted file mode 100644
index 579378918..000000000
--- a/spec/workers/email_reply_worker_spec.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe EmailReplyWorker, type: :worker do
- let(:account) { create(:account) }
- let(:channel) { create(:channel_email, account: account) }
- let(:message) { create(:message, message_type: :outgoing, inbox: channel.inbox, account: account) }
- let(:private_message) { create(:message, private: true, message_type: :outgoing, inbox: channel.inbox, account: account) }
- let(:incoming_message) { create(:message, message_type: :incoming, inbox: channel.inbox, account: account) }
- let(:template_message) { create(:message, message_type: :template, content_type: :input_csat, inbox: channel.inbox, account: account) }
- let(:mailer) { double }
- let(:mailer_action) { double }
-
- describe '#perform' do
- context 'when emails are successfully sent' do
- before do
- allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
- allow(mailer).to receive(:email_reply).and_return(mailer_action)
- allow(mailer_action).to receive(:deliver_now).and_return(true)
- end
-
- it 'calls mailer action with message' do
- described_class.new.perform(message.id)
- expect(mailer).to have_received(:email_reply).with(message)
- expect(mailer_action).to have_received(:deliver_now)
- end
-
- it 'does not call mailer action with a private message' do
- described_class.new.perform(private_message.id)
- expect(mailer).not_to have_received(:email_reply)
- expect(mailer_action).not_to have_received(:deliver_now)
- end
-
- it 'calls mailer action with a CSAT message' do
- described_class.new.perform(template_message.id)
- expect(mailer).to have_received(:email_reply).with(template_message)
- expect(mailer_action).to have_received(:deliver_now)
- end
-
- it 'does not call mailer action with an incoming message' do
- described_class.new.perform(incoming_message.id)
- expect(mailer).not_to have_received(:email_reply)
- expect(mailer_action).not_to have_received(:deliver_now)
- end
- end
-
- context 'when emails are not sent' do
- before do
- allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
- allow(mailer).to receive(:email_reply).and_return(mailer_action)
- allow(mailer_action).to receive(:deliver_now).and_raise(ArgumentError)
- end
-
- it 'mark message as failed' do
- expect { described_class.new.perform(message.id) }.to change { message.reload.status }.from('sent').to('failed')
- end
- end
- end
-end