-
+
+
+
+
+ {{ buildSelectedCountLabel }}
+
+
{{
$t('CAPTAIN.RESPONSES.SELECTED', {
@@ -322,17 +338,23 @@ onMounted(() => {
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleSearch/ArticleView.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleSearch/ArticleView.vue
index f2a2ab4d6..7c7dd0040 100644
--- a/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleSearch/ArticleView.vue
+++ b/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleSearch/ArticleView.vue
@@ -1,6 +1,7 @@
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Google.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Google.vue
index 1628d3e62..a5c16fa57 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Google.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Google.vue
@@ -12,7 +12,6 @@ defineOptions({
provider="google"
:title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')"
:description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')"
- :input-placeholder="$t('INBOX_MGMT.ADD.GOOGLE.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')"
/>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Microsoft.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Microsoft.vue
index 1e3706297..53cf5ccc7 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Microsoft.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Microsoft.vue
@@ -12,7 +12,6 @@ defineOptions({
provider="microsoft"
:title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')"
:description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')"
- :input-placeholder="$t('INBOX_MGMT.ADD.MICROSOFT.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')"
/>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/OAuthChannel.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/OAuthChannel.vue
index eb109c6f2..f2bfb63f3 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/OAuthChannel.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/OAuthChannel.vue
@@ -30,14 +30,9 @@ const props = defineProps({
type: String,
required: true,
},
- inputPlaceholder: {
- type: String,
- required: true,
- },
});
const isRequestingAuthorization = ref(false);
-const email = ref('');
const client = computed(() => {
if (props.provider === 'microsoft') {
@@ -50,9 +45,7 @@ const client = computed(() => {
async function requestAuthorization() {
try {
isRequestingAuthorization.value = true;
- const response = await client.value.generateAuthorization({
- email: email.value,
- });
+ const response = await client.value.generateAuthorization();
const {
data: { url },
} = response;
@@ -75,11 +68,6 @@ async function requestAuthorization() {
:header-content="description"
/>
diff --git a/app/javascript/shared/helpers/portalHelper.js b/app/javascript/shared/helpers/portalHelper.js
new file mode 100644
index 000000000..a9e92a92a
--- /dev/null
+++ b/app/javascript/shared/helpers/portalHelper.js
@@ -0,0 +1,40 @@
+/**
+ * Determine the best-matching locale from the list of locales allowed by the portal.
+ *
+ * The matching happens in the following order:
+ * 1. Exact match – the visitor-selected locale equals one in the `allowedLocales` list
+ * (e.g., `fr` ➜ `fr`).
+ * 2. Base language match – the base part of a compound locale (before the underscore)
+ * matches (e.g., `fr_CA` ➜ `fr`).
+ * 3. Variant match – when the base language is selected but a regional variant exists
+ * in the portal list (e.g., `fr` ➜ `fr_BE`).
+ *
+ * If none of these rules find a match, the function returns `null`,
+ * Don't show popular articles if locale doesn't match with allowed locales
+ *
+ * @export
+ * @param {string} selectedLocale The locale selected by the visitor (e.g., `fr_CA`).
+ * @param {string[]} allowedLocales Array of locales enabled for the portal.
+ * @returns {(string|null)} A locale string that should be used, or `null` if no suitable match.
+ */
+export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
+ // Ensure inputs are valid
+ if (
+ !selectedLocale ||
+ !Array.isArray(allowedLocales) ||
+ !allowedLocales.length
+ ) {
+ return null;
+ }
+
+ const [lang] = selectedLocale.split('_');
+
+ const priorityMatches = [
+ selectedLocale, // exact match
+ lang, // base language match
+ allowedLocales.find(l => l.startsWith(`${lang}_`)), // first variant match
+ ];
+
+ // Return the first match that exists in the allowed list, or null
+ return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
+};
diff --git a/app/javascript/shared/helpers/specs/portalHelper.spec.js b/app/javascript/shared/helpers/specs/portalHelper.spec.js
new file mode 100644
index 000000000..e79c9a3d9
--- /dev/null
+++ b/app/javascript/shared/helpers/specs/portalHelper.spec.js
@@ -0,0 +1,28 @@
+import { getMatchingLocale } from 'shared/helpers/portalHelper';
+
+describe('portalHelper - getMatchingLocale', () => {
+ it('returns exact match when present', () => {
+ const result = getMatchingLocale('fr', ['en', 'fr']);
+ expect(result).toBe('fr');
+ });
+
+ it('returns base language match when exact variant not present', () => {
+ const result = getMatchingLocale('fr_CA', ['en', 'fr']);
+ expect(result).toBe('fr');
+ });
+
+ it('returns variant match when base language not present', () => {
+ const result = getMatchingLocale('fr', ['en', 'fr_BE']);
+ expect(result).toBe('fr_BE');
+ });
+
+ it('returns null when no match found', () => {
+ const result = getMatchingLocale('de', ['en', 'fr']);
+ expect(result).toBeNull();
+ });
+
+ it('returns null for invalid inputs', () => {
+ expect(getMatchingLocale('', [])).toBeNull();
+ expect(getMatchingLocale(null, null)).toBeNull();
+ });
+});
diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js
index 273e9f8b4..bf8ec492b 100644
--- a/app/javascript/shared/mixins/inboxMixin.js
+++ b/app/javascript/shared/mixins/inboxMixin.js
@@ -63,6 +63,9 @@ export default {
isATelegramChannel() {
return this.channelType === INBOX_TYPES.TELEGRAM;
},
+ isAVoiceChannel() {
+ return this.channelType === INBOX_TYPES.VOICE;
+ },
isATwilioSMSChannel() {
const { medium: medium = '' } = this.inbox;
return this.isATwilioChannel && medium === 'sms';
diff --git a/app/javascript/widget/components/pageComponents/Home/Article/ArticleContainer.vue b/app/javascript/widget/components/pageComponents/Home/Article/ArticleContainer.vue
index 1d5fdc99d..e093d4316 100644
--- a/app/javascript/widget/components/pageComponents/Home/Article/ArticleContainer.vue
+++ b/app/javascript/widget/components/pageComponents/Home/Article/ArticleContainer.vue
@@ -7,6 +7,7 @@ import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useDarkMode } from 'widget/composables/useDarkMode';
+import { getMatchingLocale } from 'shared/helpers/portalHelper';
const store = useStore();
const router = useRouter();
@@ -20,17 +21,8 @@ const articleUiFlags = useMapGetter('article/uiFlags');
const locale = computed(() => {
const { locale: selectedLocale } = i18n;
- const {
- allowed_locales: allowedLocales,
- default_locale: defaultLocale = 'en',
- } = portal.value.config;
- // IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
- // If the exact match of a locale is available in the list of portal locales, return it
- // Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
- if (allowedLocales.includes(selectedLocale)) {
- return locale;
- }
- return defaultLocale;
+ const { allowed_locales: allowedLocales } = portal.value.config;
+ return getMatchingLocale(selectedLocale.value, allowedLocales);
});
const fetchArticles = () => {
@@ -46,6 +38,7 @@ const openArticleInArticleViewer = link => {
const params = new URLSearchParams({
show_plain_layout: 'true',
theme: prefersDarkMode.value ? 'dark' : 'light',
+ ...(locale.value && { locale: locale.value }),
});
// Combine link with query parameters
@@ -64,7 +57,8 @@ const hasArticles = computed(
() =>
!articleUiFlags.value.isFetching &&
!articleUiFlags.value.isError &&
- !!popularArticles.value.length
+ !!popularArticles.value.length &&
+ !!locale.value
);
onMounted(() => fetchArticles());
diff --git a/app/javascript/widget/store/modules/articles.js b/app/javascript/widget/store/modules/articles.js
index 27faa297b..2ad6ae1dd 100644
--- a/app/javascript/widget/store/modules/articles.js
+++ b/app/javascript/widget/store/modules/articles.js
@@ -23,6 +23,7 @@ export const actions = {
commit('setError', false);
try {
+ if (!locale) return;
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
if (cachedData) {
commit('setArticles', cachedData);
diff --git a/app/javascript/widget/views/ArticleViewer.vue b/app/javascript/widget/views/ArticleViewer.vue
index d3e1f9437..9289d0546 100644
--- a/app/javascript/widget/views/ArticleViewer.vue
+++ b/app/javascript/widget/views/ArticleViewer.vue
@@ -1,24 +1,16 @@
-
+
diff --git a/app/listeners/reporting_event_listener.rb b/app/listeners/reporting_event_listener.rb
index b31d899bb..9f22fe8de 100644
--- a/app/listeners/reporting_event_listener.rb
+++ b/app/listeners/reporting_event_listener.rb
@@ -47,6 +47,10 @@ class ReportingEventListener < BaseListener
message = extract_message_and_account(event)[0]
conversation = message.conversation
waiting_since = event.data[:waiting_since]
+
+ return if waiting_since.blank?
+
+ # When waiting_since is nil, set reply_time to 0
reply_time = message.created_at.to_i - waiting_since.to_i
reporting_event = ReportingEvent.new(
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index 922118b09..d6c5d0e4a 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -198,11 +198,21 @@ class Conversation < ApplicationRecord
private
def execute_after_update_commit_callbacks
+ handle_resolved_status_change
notify_status_change
create_activity
notify_conversation_updation
end
+ def handle_resolved_status_change
+ # When conversation is resolved, clear waiting_since using update_column to avoid callbacks
+ return unless saved_change_to_status? && status == 'resolved'
+
+ # rubocop:disable Rails/SkipsModelValidations
+ update_column(:waiting_since, nil)
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+
def ensure_snooze_until_reset
self.snoozed_until = nil unless snoozed?
end
diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb
index dfe889bfa..3b5cd821a 100644
--- a/app/models/integrations/app.rb
+++ b/app/models/integrations/app.rb
@@ -55,9 +55,11 @@ class Integrations::App
when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
- account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
+ shopify_enabled?(account)
when 'leadsquared'
account.feature_enabled?('crm_integration')
+ when 'notion'
+ notion_enabled?(account)
else
true
end
@@ -113,4 +115,14 @@ class Integrations::App
all.detect { |app| app.id == params[:id] }
end
end
+
+ private
+
+ def shopify_enabled?(account)
+ account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
+ end
+
+ def notion_enabled?(account)
+ account.feature_enabled?('notion_integration') && GlobalConfigService.load('NOTION_CLIENT_ID', nil).present?
+ end
end
diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb
index 2f028ee3e..cf71d021c 100644
--- a/app/models/integrations/hook.rb
+++ b/app/models/integrations/hook.rb
@@ -53,6 +53,10 @@ class Integrations::Hook < ApplicationRecord
app_id == 'dialogflow'
end
+ def notion?
+ app_id == 'notion'
+ end
+
def disable
update(status: 'disabled')
end
diff --git a/app/models/message.rb b/app/models/message.rb
index e7c4e9b6c..d4036416e 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -24,6 +24,7 @@
#
# Indexes
#
+# idx_messages_account_content_created (account_id,content_type,created_at)
# index_messages_on_account_created_type (account_id,created_at,message_type)
# index_messages_on_account_id (account_id)
# index_messages_on_account_id_and_inbox_id (account_id,inbox_id)
diff --git a/app/services/instagram/messenger/message_text.rb b/app/services/instagram/messenger/message_text.rb
index 0028a15d6..ed544263e 100644
--- a/app/services/instagram/messenger/message_text.rb
+++ b/app/services/instagram/messenger/message_text.rb
@@ -24,6 +24,15 @@ class Instagram::Messenger::MessageText < Instagram::BaseMessageText
end
def handle_client_error(error)
+ # Handle error code 230: User consent is required to access user profile
+ # This typically occurs when the connected Instagram account attempts to send a message to a user
+ # who has never messaged this Instagram account before.
+ # We can safely ignore this error as per Facebook documentation.
+ if error.message.include?('230')
+ Rails.logger.warn error
+ return
+ end
+
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[FacebookUserFetchClientError]: #{error.message}")
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
diff --git a/app/services/llm_formatter/conversation_llm_formatter.rb b/app/services/llm_formatter/conversation_llm_formatter.rb
index 1444d75c1..8654e0adf 100644
--- a/app/services/llm_formatter/conversation_llm_formatter.rb
+++ b/app/services/llm_formatter/conversation_llm_formatter.rb
@@ -5,22 +5,34 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
sections << "Channel: #{@record.inbox.channel.name}"
sections << 'Message History:'
sections << if @record.messages.any?
- build_messages
+ build_messages(config)
else
'No messages in this conversation'
end
sections << "Contact Details: #{@record.contact.to_llm_text}" if config[:include_contact_details]
+
+ attributes = build_attributes
+ if attributes.present?
+ sections << 'Conversation Attributes:'
+ sections << attributes
+ end
+
sections.join("\n")
end
private
- def build_messages
+ def build_messages(config = {})
return "No messages in this conversation\n" if @record.messages.empty?
message_text = ''
- @record.messages.chat.order(created_at: :asc).each do |message|
+ messages = @record.messages.where.not(message_type: :activity).order(created_at: :asc)
+
+ messages.each do |message|
+ # Skip private messages unless explicitly included in config
+ next if message.private? && !config[:include_private_messages]
+
message_text << format_message(message)
end
message_text
@@ -28,6 +40,14 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
def format_message(message)
sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
+ sender = "[Private Note] #{sender}" if message.private?
"#{sender}: #{message.content}\n"
end
+
+ def build_attributes
+ attributes = @record.account.custom_attribute_definitions.with_attribute_model('conversation_attribute').map do |attribute|
+ "#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}"
+ end
+ attributes.join("\n")
+ end
end
diff --git a/app/services/message_templates/template/auto_resolve.rb b/app/services/message_templates/template/auto_resolve.rb
index c70ab9ad2..1b3dfa5c2 100644
--- a/app/services/message_templates/template/auto_resolve.rb
+++ b/app/services/message_templates/template/auto_resolve.rb
@@ -4,8 +4,10 @@ class MessageTemplates::Template::AutoResolve
def perform
return if conversation.account.auto_resolve_message.blank?
- ActiveRecord::Base.transaction do
+ if within_messaging_window?
conversation.messages.create!(auto_resolve_message_params)
+ else
+ create_auto_resolve_not_sent_activity_message
end
end
@@ -14,6 +16,21 @@ class MessageTemplates::Template::AutoResolve
delegate :contact, :account, to: :conversation
delegate :inbox, to: :message
+ def within_messaging_window?
+ conversation.can_reply?
+ end
+
+ def create_auto_resolve_not_sent_activity_message
+ content = I18n.t('conversations.activity.auto_resolve.not_sent_due_to_messaging_window')
+ activity_message_params = {
+ account_id: conversation.account_id,
+ inbox_id: conversation.inbox_id,
+ message_type: :activity,
+ content: content
+ }
+ ::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params) if content
+ end
+
def auto_resolve_message_params
{
account_id: @conversation.account_id,
diff --git a/app/views/mailers/administrator_notifications/account_compliance_mailer/account_deleted.liquid b/app/views/mailers/administrator_notifications/account_compliance_mailer/account_deleted.liquid
index 636a5daa2..2e49a7830 100644
--- a/app/views/mailers/administrator_notifications/account_compliance_mailer/account_deleted.liquid
+++ b/app/views/mailers/administrator_notifications/account_compliance_mailer/account_deleted.liquid
@@ -6,8 +6,8 @@
Chatwoot Installation: {{ meta.instance_url }}
Account ID: {{ meta.account_id }}
Account Name: {{ meta.account_name }}
+
Deletion due at: {{ meta.marked_for_deletion_at }}
Deleted At: {{ meta.deleted_at }}
-
Marked for Deletion at: {{ meta.marked_for_deletion_at }}
Deletion Reason: {{ meta.deletion_reason }}
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 319ba4b4e..feb5dff96 100644
--- a/app/views/mailers/conversation_reply_mailer/email_reply.html.erb
+++ b/app/views/mailers/conversation_reply_mailer/email_reply.html.erb
@@ -1,5 +1,5 @@
<% if @message.content %>
- <%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
+ <%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<% end %>
<% if @large_attachments.present? %>
Attachments:
diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb
index fabff914b..6669fe87d 100644
--- a/app/views/super_admin/application/_icons.html.erb
+++ b/app/views/super_admin/application/_icons.html.erb
@@ -156,9 +156,14 @@
+
+
+
+
+
diff --git a/config/app.yml b/config/app.yml
index 36bbdbcf1..6ba134f19 100644
--- a/config/app.yml
+++ b/config/app.yml
@@ -1,5 +1,5 @@
shared: &shared
- version: '4.2.0'
+ version: '4.3.0'
development:
<<: *shared
diff --git a/config/features.yml b/config/features.yml
index 131456c72..5171b1c01 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -168,4 +168,11 @@
enabled: true
- name: crm_integration
display_name: CRM Integration
- enabled: false
\ No newline at end of file
+ enabled: false
+- name: channel_voice
+ display_name: Voice Channel
+ enabled: false
+ chatwoot_internal: true
+- name: notion_integration
+ display_name: Notion Integration
+ enabled: false
diff --git a/config/installation_config.yml b/config/installation_config.yml
index 66538d4ab..a089a4ea7 100644
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -288,6 +288,25 @@
type: secret
## ------ End of Configs added for Linear ------ ##
+## ------ Configs added for Notion ------ ##
+- name: NOTION_CLIENT_ID
+ display_title: 'Notion Client ID'
+ value:
+ locked: false
+ description: 'Notion client ID'
+- name: NOTION_CLIENT_SECRET
+ display_title: 'Notion Client Secret'
+ value:
+ locked: false
+ description: 'Notion client secret'
+ type: secret
+- name: NOTION_VERSION
+ display_title: 'Notion Version'
+ value: '2022-06-28'
+ locked: false
+ description: 'Notion version'
+## ------ End of Configs added for Notion ------ ##
+
## ------ Configs added for Slack ------ ##
- name: SLACK_CLIENT_ID
display_title: 'Slack Client ID'
diff --git a/config/integration/apps.yml b/config/integration/apps.yml
index 1faf35670..dd5c722a4 100644
--- a/config/integration/apps.yml
+++ b/config/integration/apps.yml
@@ -63,6 +63,12 @@ linear:
action: https://linear.app/oauth/authorize
hook_type: account
allow_multiple_hooks: false
+notion:
+ id: notion
+ logo: notion.png
+ i18n_key: notion
+ hook_type: account
+ allow_multiple_hooks: false
slack:
id: slack
logo: slack.png
diff --git a/config/locales/am.yml b/config/locales/am.yml
index c773e3081..bb6feec58 100644
--- a/config/locales/am.yml
+++ b/config/locales/am.yml
@@ -91,6 +91,8 @@ am:
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
@@ -138,6 +140,8 @@ am:
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:
@@ -172,6 +176,10 @@ am:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/ar.yml b/config/locales/ar.yml
index 5a6463359..d114c08cb 100644
--- a/config/locales/ar.yml
+++ b/config/locales/ar.yml
@@ -91,6 +91,8 @@ ar:
conversations_count: عدد المحادثات
avg_first_response_time: متوسط وقت الرد الأول
avg_resolution_time: متوسط وقت الحل
+ avg_reply_time: Avg reply time
+ resolution_count: عدد مرات الإغلاق
team_csv:
team_name: اسم الفريق
conversations_count: عدد المحادثات
@@ -138,6 +140,8 @@ ar:
instagram_story_content: 'أشار %{story_sender} إليك في القصة: '
instagram_deleted_story_content: هذه القصة لم تعد متاحة.
deleted: تم حذف هذه الرسالة
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'رمز الخطأ: %{error_code}'
activity:
@@ -172,6 +176,10 @@ ar:
sla:
added: '%{user_name} أضاف سياسة مستوى الخدمة %{sla_name}'
removed: '%{user_name} أزال سياسة مستوى الخدمة %{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'
muted: '%{user_name} كتم صوت المحادثة'
diff --git a/config/locales/az.yml b/config/locales/az.yml
index c9d2c1ac9..a77dd793e 100644
--- a/config/locales/az.yml
+++ b/config/locales/az.yml
@@ -91,6 +91,8 @@ az:
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
@@ -138,6 +140,8 @@ az:
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:
@@ -172,6 +176,10 @@ az:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/bg.yml b/config/locales/bg.yml
index 2ab74952f..b638f02e3 100644
--- a/config/locales/bg.yml
+++ b/config/locales/bg.yml
@@ -91,6 +91,8 @@ bg:
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
@@ -138,6 +140,8 @@ bg:
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:
@@ -172,6 +176,10 @@ bg:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/ca.yml b/config/locales/ca.yml
index 7daabbd99..0c610c4c0 100644
--- a/config/locales/ca.yml
+++ b/config/locales/ca.yml
@@ -91,6 +91,8 @@ ca:
conversations_count: Nre. de converses
avg_first_response_time: Temps mitjà de primera resposta
avg_resolution_time: Temps mitjà de resolució
+ avg_reply_time: Avg reply time
+ resolution_count: Total de resolucions
team_csv:
team_name: Nom de l'equip
conversations_count: Recompte de converses
@@ -138,6 +140,8 @@ ca:
instagram_story_content: '%{story_sender} t''ha mencionat a la història: '
instagram_deleted_story_content: Aquesta història ja no està disponible.
deleted: Aquest missatge a sigut eliminat
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Codi d''error: %{error_code}'
activity:
@@ -172,6 +176,10 @@ ca:
sla:
added: '%{user_name} ha afegit la política de SLA %{sla_name}'
removed: '%{user_name} ha eliminat la política de 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}'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
muted: '%{user_name} ha silenciat la conversa'
diff --git a/config/locales/cs.yml b/config/locales/cs.yml
index 75a840336..d21ebfbef 100644
--- a/config/locales/cs.yml
+++ b/config/locales/cs.yml
@@ -91,6 +91,8 @@ cs:
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: Počet rozlišení
team_csv:
team_name: Team name
conversations_count: Conversations count
@@ -138,6 +140,8 @@ cs:
instagram_story_content: '%{story_sender} mentioned you in the story: '
instagram_deleted_story_content: This story is no longer available.
deleted: Tato zpráva byla smazána
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
@@ -172,6 +176,10 @@ cs:
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'
muted: '%{user_name} ztlumil/a konverzaci'
diff --git a/config/locales/da.yml b/config/locales/da.yml
index 5f4a816da..37478f846 100644
--- a/config/locales/da.yml
+++ b/config/locales/da.yml
@@ -91,6 +91,8 @@ da:
conversations_count: Antal samtaler
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Antal Afsluttede
team_csv:
team_name: Team navn
conversations_count: Samtaler tæller
@@ -138,6 +140,8 @@ da:
instagram_story_content: '%{story_sender} nævnte dig i historien: '
instagram_deleted_story_content: Denne historie er ikke længere tilgængelig.
deleted: Denne besked blev slettet
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
@@ -172,6 +176,10 @@ da:
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'
muted: '%{user_name} har slukket for samtalen'
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 29ca84b45..85fad2d29 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -91,6 +91,8 @@ de:
conversations_count: Anzahl der Konversationen
avg_first_response_time: Durchschnittliche Zeit bis zur ersten Antwort
avg_resolution_time: Durchschnittliche Auflösung
+ avg_reply_time: Avg reply time
+ resolution_count: Auflösungsanzahl
team_csv:
team_name: Teamname
conversations_count: Anzahl Gespräche
@@ -138,6 +140,8 @@ de:
instagram_story_content: '%{story_sender} erwähnte sie in der Geschichte: '
instagram_deleted_story_content: Diese Geschichte ist nicht mehr verfügbar.
deleted: Diese Nachricht wurde gelöscht
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Fehlercode: %{error_code}'
activity:
@@ -172,6 +176,10 @@ de:
sla:
added: '%{user_name} hat SLA-Richtlinie %{sla_name} hinzugefügt'
removed: '%{user_name} hat SLA-Richtlinie %{sla_name} entfernt'
+ 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'
muted: '%{user_name} hat das Gespräch stumm geschaltet'
diff --git a/config/locales/el.yml b/config/locales/el.yml
index 8e15701ae..635e0d5ff 100644
--- a/config/locales/el.yml
+++ b/config/locales/el.yml
@@ -91,6 +91,8 @@ el:
conversations_count: Αριθμός συνομιλιών
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Αριθμός Αναλύσεων
team_csv:
team_name: Όνομα ομάδας
conversations_count: Αριθμός συνομιλιών
@@ -138,6 +140,8 @@ el:
instagram_story_content: 'Ο %{story_sender} σας ανέφερε στην ιστορία: '
instagram_deleted_story_content: Η ιστορία δεν είναι πλέον διαθέσιμη.
deleted: Το μήνυμα διαγράφηκε
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
@@ -172,6 +176,10 @@ el:
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'
muted: 'Ο χρήστης %{user_name} σίγασε την συνομιλία'
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 8b392f47f..a41a009cf 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -196,6 +196,8 @@ en:
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.'
@@ -255,6 +257,10 @@ en:
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.'
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 82b604ddb..308a11c4a 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -91,6 +91,8 @@ es:
conversations_count: Núm. de conversaciones
avg_first_response_time: Promedio de tiempo de la primera respuesta
avg_resolution_time: Tiempo promedio de resolución
+ avg_reply_time: Avg reply time
+ resolution_count: Número de resoluciones
team_csv:
team_name: Nombre del equipo
conversations_count: Cantidad de conversaciones
@@ -138,6 +140,8 @@ es:
instagram_story_content: '%{story_sender} te mencionó en la historia: '
instagram_deleted_story_content: Esta historia ya no está disponible.
deleted: Este mensaje se ha eliminado
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Código de error: %{error_code}'
activity:
@@ -172,6 +176,10 @@ es:
sla:
added: '%{user_name} agregó la política de SLA %{sla_name}'
removed: '%{user_name} eliminó la política de 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}'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
muted: '%{user_name} ha silenciado la conversación'
diff --git a/config/locales/fa.yml b/config/locales/fa.yml
index 6cdc532a7..0a5166ab4 100644
--- a/config/locales/fa.yml
+++ b/config/locales/fa.yml
@@ -91,6 +91,8 @@ fa:
conversations_count: تعداد گفتگوها
avg_first_response_time: میانگین زمان تا اولین پاسخ
avg_resolution_time: میانگین زمان حل مشکل
+ avg_reply_time: Avg reply time
+ resolution_count: تعداد مسائل حل شده
team_csv:
team_name: نام تیم
conversations_count: Conversations count
@@ -138,6 +140,8 @@ fa:
instagram_story_content: '%{story_sender} در داستان به شما اشاره کرده: '
instagram_deleted_story_content: این داستان دیگر در دسترس نیست.
deleted: این پیام حذف شد
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'کد خطا " %{error_code}'
activity:
@@ -172,6 +176,10 @@ fa:
sla:
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}'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
muted: '%{user_name} مکالمه را بی صدا کرد'
diff --git a/config/locales/fi.yml b/config/locales/fi.yml
index 94ff6cb9c..2f5fefe20 100644
--- a/config/locales/fi.yml
+++ b/config/locales/fi.yml
@@ -91,6 +91,8 @@ fi:
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: Selvitysmäärä
team_csv:
team_name: Team name
conversations_count: Conversations count
@@ -138,6 +140,8 @@ fi:
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:
@@ -172,6 +176,10 @@ fi:
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'
muted: '%{user_name} mykisti keskustelun'
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 406d69b55..a7925e6e8 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -91,6 +91,8 @@ fr:
conversations_count: Nbre de conversations
avg_first_response_time: Temps moyen pour une première réponse
avg_resolution_time: Temps nécessaire pour résoudre une demande (en moyenne)
+ avg_reply_time: Avg reply time
+ resolution_count: Nombre de résolutions
team_csv:
team_name: Nom de l'équipe
conversations_count: Nombre de conversations
@@ -138,6 +140,8 @@ fr:
instagram_story_content: '%{story_sender} vous a mentionné dans la story: '
instagram_deleted_story_content: Cette Story n'est plus disponible.
deleted: Ce message a été supprimé
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Code d''erreur : %{error_code}'
activity:
@@ -172,6 +176,10 @@ fr:
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'
muted: '%{user_name} a mis la conversation en sourdine'
diff --git a/config/locales/he.yml b/config/locales/he.yml
index 0945e2f50..3d2585675 100644
--- a/config/locales/he.yml
+++ b/config/locales/he.yml
@@ -91,6 +91,8 @@ he:
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: ספירת רזולוציות
team_csv:
team_name: שם קבוצה
conversations_count: Conversations count
@@ -138,6 +140,8 @@ he:
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:
@@ -172,6 +176,10 @@ he:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/hi.yml b/config/locales/hi.yml
index 76055318c..58899852e 100644
--- a/config/locales/hi.yml
+++ b/config/locales/hi.yml
@@ -91,6 +91,8 @@ hi:
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
@@ -138,6 +140,8 @@ hi:
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:
@@ -172,6 +176,10 @@ hi:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/hr.yml b/config/locales/hr.yml
index 23aa9555e..a4016bd34 100644
--- a/config/locales/hr.yml
+++ b/config/locales/hr.yml
@@ -91,6 +91,8 @@ hr:
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
@@ -138,6 +140,8 @@ hr:
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:
@@ -172,6 +176,10 @@ hr:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/hu.yml b/config/locales/hu.yml
index b9bb386a5..eb04702db 100644
--- a/config/locales/hu.yml
+++ b/config/locales/hu.yml
@@ -91,6 +91,8 @@ hu:
conversations_count: Beszélgetések száma
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Megoldások száma
team_csv:
team_name: Csapatnév
conversations_count: Beszélgetésszám
@@ -138,6 +140,8 @@ hu:
instagram_story_content: '%{story_sender} megemlített egy storyban: '
instagram_deleted_story_content: Ez a story már nem érhető el.
deleted: Az üzenet törölve lett
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Hibakód: %{error_code}'
activity:
@@ -172,6 +176,10 @@ hu:
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'
muted: '%{user_name} elnémította a beszélgetést'
diff --git a/config/locales/hy.yml b/config/locales/hy.yml
index acf78ed4d..77a36722a 100644
--- a/config/locales/hy.yml
+++ b/config/locales/hy.yml
@@ -91,6 +91,8 @@ hy:
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
@@ -138,6 +140,8 @@ hy:
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:
@@ -172,6 +176,10 @@ hy:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/id.yml b/config/locales/id.yml
index 4c039011c..0cf76f44a 100644
--- a/config/locales/id.yml
+++ b/config/locales/id.yml
@@ -91,6 +91,8 @@ id:
conversations_count: Jumlah percakapan
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Jumlah Terselesaikan
team_csv:
team_name: Nama Tim
conversations_count: Jumlah percakapan
@@ -138,6 +140,8 @@ id:
instagram_story_content: '%{story_sender} menyebutmu dalam story: '
instagram_deleted_story_content: Story ini tidak lagi tersedia.
deleted: Pesan ini telah terhapus
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
@@ -172,6 +176,10 @@ id:
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'
muted: '%{user_name} me-mute percakapan'
diff --git a/config/locales/is.yml b/config/locales/is.yml
index cf97814e2..a6efef268 100644
--- a/config/locales/is.yml
+++ b/config/locales/is.yml
@@ -91,6 +91,8 @@ is:
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
@@ -138,6 +140,8 @@ is:
instagram_story_content: '%{story_sender} minntist á þig í sögunni: '
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:
@@ -172,6 +176,10 @@ is:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/it.yml b/config/locales/it.yml
index e6ab55115..e4d48b1c2 100644
--- a/config/locales/it.yml
+++ b/config/locales/it.yml
@@ -91,6 +91,8 @@ it:
conversations_count: Numero di conversazioni
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Conteggio risoluzioni
team_csv:
team_name: Nome del team
conversations_count: Numero di conversazioni
@@ -138,6 +140,8 @@ it:
instagram_story_content: '%{story_sender} ti ha menzionato nella storia: '
instagram_deleted_story_content: Questa storia non è più disponibile.
deleted: Questo messaggio è stato eliminato
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
@@ -172,6 +176,10 @@ it:
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'
muted: '%{user_name} ha silenziato la conversazione'
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 928ac3e71..660d9f549 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -91,6 +91,8 @@ ja:
conversations_count: 会話数
avg_first_response_time: 初回応答の平均時間
avg_resolution_time: 解決までの平均時間
+ avg_reply_time: Avg reply time
+ resolution_count: 処理件数
team_csv:
team_name: チーム名
conversations_count: 会話回数
@@ -138,6 +140,8 @@ ja:
instagram_story_content: '%{story_sender} さんがストーリーであなたについて言及しました: '
instagram_deleted_story_content: このストーリーはもう利用できません。
deleted: このメッセージは削除されました
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'エラーコード: %{error_code}'
activity:
@@ -172,6 +176,10 @@ ja:
sla:
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}'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
muted: '%{user_name} が会話をミュートしました'
diff --git a/config/locales/ka.yml b/config/locales/ka.yml
index 4154ddf14..08b042c93 100644
--- a/config/locales/ka.yml
+++ b/config/locales/ka.yml
@@ -91,6 +91,8 @@ ka:
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
@@ -138,6 +140,8 @@ ka:
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:
@@ -172,6 +176,10 @@ ka:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index e76883f44..723c8a8ea 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -91,6 +91,8 @@ ko:
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: 해결 수
team_csv:
team_name: Team name
conversations_count: Conversations count
@@ -138,6 +140,8 @@ ko:
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:
@@ -172,6 +176,10 @@ ko:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/lt.yml b/config/locales/lt.yml
index dc093901f..9a7f843be 100644
--- a/config/locales/lt.yml
+++ b/config/locales/lt.yml
@@ -91,6 +91,8 @@ lt:
conversations_count: Pokalbių kiekis
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Sprendimų skaičius
team_csv:
team_name: Komandos pavadinimas
conversations_count: Pokalbių skaičius
@@ -138,6 +140,8 @@ lt:
instagram_story_content: '%{story_sender} paminėjo jus pasakojime: '
instagram_deleted_story_content: Šis pasakojimas nebepasiekiamas.
deleted: Šis pranešimas buvo ištrintas
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Klaidos kodas: %{error_code}'
activity:
@@ -172,6 +176,10 @@ lt:
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'
muted: '%{user_name} nutildė pokalbį'
diff --git a/config/locales/lv.yml b/config/locales/lv.yml
index dab14d39f..eaa113cb7 100644
--- a/config/locales/lv.yml
+++ b/config/locales/lv.yml
@@ -91,6 +91,8 @@ lv:
conversations_count: Sarunu skaits
avg_first_response_time: Vidējais pirmās reakcijas laiks
avg_resolution_time: Vidējais atrisināšanas laiks
+ avg_reply_time: Avg reply time
+ resolution_count: Atrisināšanas Skaits
team_csv:
team_name: Komandas nosaukums
conversations_count: Sarunu skaits
@@ -138,6 +140,8 @@ lv:
instagram_story_content: '%{story_sender} pieminēja jūs stāstā: '
instagram_deleted_story_content: Šis stāsts vairs nav pieejams.
deleted: Šis ziņojums ir izdzēsts
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Kļūdas kods: %{error_code}'
activity:
@@ -172,6 +176,10 @@ lv:
sla:
added: '%{user_name} pievienoja SLA politiku %{sla_name}'
removed: '%{user_name} noņēma SLA politiku %{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'
muted: '%{user_name} izslēdza sarunu'
diff --git a/config/locales/ml.yml b/config/locales/ml.yml
index df5f3f329..fefc77b18 100644
--- a/config/locales/ml.yml
+++ b/config/locales/ml.yml
@@ -91,6 +91,8 @@ ml:
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: മിഴിവ് എണ്ണം
team_csv:
team_name: ടീമിന്റെ പേര്
conversations_count: സംഭാഷണങ്ങളുടെ എണ്ണം
@@ -138,6 +140,8 @@ ml:
instagram_story_content: '%{story_sender} mentioned you in the story: '
instagram_deleted_story_content: This story is no longer available.
deleted: ഈ സന്ദേശം ഇല്ലാതാക്കി
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
@@ -172,6 +176,10 @@ ml:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/ms.yml b/config/locales/ms.yml
index 536fe8134..dea61ba68 100644
--- a/config/locales/ms.yml
+++ b/config/locales/ms.yml
@@ -91,6 +91,8 @@ ms:
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
@@ -138,6 +140,8 @@ ms:
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:
@@ -172,6 +176,10 @@ ms:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/ne.yml b/config/locales/ne.yml
index a1e2e478d..1b97449af 100644
--- a/config/locales/ne.yml
+++ b/config/locales/ne.yml
@@ -91,6 +91,8 @@ ne:
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
@@ -138,6 +140,8 @@ ne:
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:
@@ -172,6 +176,10 @@ ne:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index 85ca07291..f92ab3c01 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -91,6 +91,8 @@ nl:
conversations_count: Aantal conversaties
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Aantal Resoluties
team_csv:
team_name: Team Naam
conversations_count: Aantal gesprekken
@@ -138,6 +140,8 @@ nl:
instagram_story_content: '%{story_sender} heeft je genoemd in het verhaal: '
instagram_deleted_story_content: Dit verhaal is niet meer beschikbaar.
deleted: Dit bericht werd verwijderd
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
@@ -172,6 +176,10 @@ nl:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/no.yml b/config/locales/no.yml
index 130c554db..944b63559 100644
--- a/config/locales/no.yml
+++ b/config/locales/no.yml
@@ -91,6 +91,8 @@
conversations_count: Antall samtaler
avg_first_response_time: Første svartid
avg_resolution_time: Gjennomsnittstid for løsning
+ avg_reply_time: Avg reply time
+ resolution_count: Antall løsninger
team_csv:
team_name: Gruppe navn
conversations_count: Antall samtaler
@@ -138,6 +140,8 @@
instagram_story_content: '%{story_sender} nevnte deg i historien: '
instagram_deleted_story_content: Denne historien er ikke lenger tilgjengelig.
deleted: Denne meldingen er slettet
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Feilkode: %{error_code}'
activity:
@@ -172,6 +176,10 @@
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'
muted: '%{user_name} har dempet samtalen'
diff --git a/config/locales/pl.yml b/config/locales/pl.yml
index d589af5c5..b6e7bb5ef 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -91,6 +91,8 @@ pl:
conversations_count: Ilość rozmów
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Liczba rozwiązań
team_csv:
team_name: Nazwa zespołu
conversations_count: Liczba rozmów
@@ -138,6 +140,8 @@ pl:
instagram_story_content: '%{story_sender} wspomniał o Tobie w historii: '
instagram_deleted_story_content: Ta historia już nie jest dostępna.
deleted: Ta wiadomość została usunięta
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
@@ -172,6 +176,10 @@ pl:
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'
muted: '%{user_name} wyciszył/a rozmowę'
diff --git a/config/locales/pt.yml b/config/locales/pt.yml
index e725e619a..429b025f8 100644
--- a/config/locales/pt.yml
+++ b/config/locales/pt.yml
@@ -91,6 +91,8 @@ pt:
conversations_count: Num de conversas
avg_first_response_time: Média de tempo da primeira resposta
avg_resolution_time: Média de tempo de resolução
+ avg_reply_time: Avg reply time
+ resolution_count: Contagem de resolução
team_csv:
team_name: Nome da equipa
conversations_count: Número de conversas
@@ -138,6 +140,8 @@ pt:
instagram_story_content: '%{story_sender} mencionou você na história: '
instagram_deleted_story_content: Esta história já não está disponível.
deleted: Esta mensagem foi apagada
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Código de erro: %{error_code}'
activity:
@@ -172,6 +176,10 @@ pt:
sla:
added: '%{user_name} adicionou uma política de SLA %{sla_name}'
removed: '%{user_name} removeu a política de SLA de %{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'
muted: '%{user_name} bloqueou a conversa'
diff --git a/config/locales/pt_BR.yml b/config/locales/pt_BR.yml
index 19570ab07..e7d2bfb91 100644
--- a/config/locales/pt_BR.yml
+++ b/config/locales/pt_BR.yml
@@ -91,6 +91,8 @@ pt_BR:
conversations_count: Nº de Conversas
avg_first_response_time: Tempo médio de primeira resposta
avg_resolution_time: Tempo médio de resolução
+ avg_reply_time: Avg reply time
+ resolution_count: Contagem de Resolução
team_csv:
team_name: Nome do Time
conversations_count: Contagem de conversas
@@ -139,7 +141,7 @@ pt_BR:
instagram_deleted_story_content: Este Story não está mais disponível.
deleted: Esta mensagem foi excluída
whatsapp:
- list_button_label: 'Escolha um item'
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Código de erro: %{error_code}'
activity:
@@ -174,6 +176,10 @@ pt_BR:
sla:
added: '%{user_name} adicionou política de SLA %{sla_name}'
removed: '%{user_name} removeu a política de 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}'
csat:
not_sent_due_to_messaging_window: 'Pesquisa CSAT não foi enviada devido a restrições de envio de mensagens'
muted: '%{user_name} silenciou a conversa'
@@ -215,7 +221,7 @@ pt_BR:
slack:
name: 'Slack'
short_description: 'Receba notificações e responda as conversas diretamente no Slack.'
- description: 'Integre Chatwoot com Slack para manter seu time em sincronia. Essa integração permite que você receba notificações de novas conversas e as responda diretamente na interface do Slack.'
+ description: "Integre Chatwoot com Slack para manter seu time em sincronia. Essa integração permite que você receba notificações de novas conversas e as responda diretamente na interface do Slack."
webhooks:
name: 'Webhooks'
description: 'Eventos webhook fornecem atualizações sobre atividades em tempo real na sua conta Chatwoot. Você pode se inscrever em seus eventos preferidos, e o Chatwoot enviará as chamadas HTTP com as atualizações.'
@@ -226,7 +232,7 @@ pt_BR:
google_translate:
name: 'Tradutor do Google'
short_description: 'Traduzir automaticamente mensagens de clientes para agentes.'
- description: 'Integre o Google Tradutor para ajudar os agentes a traduzir facilmente as mensagens dos clientes. Esta integração detecta automaticamente o idioma e o converte para o idioma preferido do agente ou do administrador.'
+ description: "Integre o Google Tradutor para ajudar os agentes a traduzir facilmente as mensagens dos clientes. Esta integração detecta automaticamente o idioma e o converte para o idioma preferido do agente ou do administrador."
openai:
name: 'OpenAI'
short_description: 'Sugestões, resumos e aprimoramento de mensagem e resposta com IA.'
diff --git a/config/locales/ro.yml b/config/locales/ro.yml
index 3b5d6858e..408f85f87 100644
--- a/config/locales/ro.yml
+++ b/config/locales/ro.yml
@@ -91,6 +91,8 @@ ro:
conversations_count: Conversații
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Număr de rezoluții
team_csv:
team_name: Numele echipei
conversations_count: Conversațiile contează
@@ -138,6 +140,8 @@ ro:
instagram_story_content: '%{story_sender} menționat în poveste: '
instagram_deleted_story_content: Această poveste nu mai este disponibilă.
deleted: Acest mesaj a fost șters
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
@@ -172,6 +176,10 @@ ro:
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'
muted: '%{user_name} a dezactivat conversația'
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index 3f6a4bbee..46b749f21 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -91,6 +91,8 @@ ru:
conversations_count: Количество диалогов
avg_first_response_time: Среднее время первого ответа
avg_resolution_time: Среднее время завершения
+ avg_reply_time: Avg reply time
+ resolution_count: Количество завершенных
team_csv:
team_name: Название команды
conversations_count: Количество бесед
@@ -138,6 +140,8 @@ ru:
instagram_story_content: '%{story_sender} упомянул Вас в истории: '
instagram_deleted_story_content: Эта история больше недоступна.
deleted: Это сообщение было удалено
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Код ошибки: %{error_code}'
activity:
@@ -172,6 +176,10 @@ ru:
sla:
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}'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
muted: '%{user_name} заглушил(а) этот разговор'
diff --git a/config/locales/sh.yml b/config/locales/sh.yml
index 204a213c0..d1e84b328 100644
--- a/config/locales/sh.yml
+++ b/config/locales/sh.yml
@@ -91,6 +91,8 @@ sh:
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
@@ -138,6 +140,8 @@ sh:
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:
@@ -172,6 +176,10 @@ sh:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/sk.yml b/config/locales/sk.yml
index 29aec1757..d607ecb66 100644
--- a/config/locales/sk.yml
+++ b/config/locales/sk.yml
@@ -91,6 +91,8 @@ sk:
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: Počet vyriešených problémov
team_csv:
team_name: Názov tímu
conversations_count: Conversations count
@@ -138,6 +140,8 @@ sk:
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:
@@ -172,6 +176,10 @@ sk:
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'
muted: '%{user_name} stlmil konverzáciu'
diff --git a/config/locales/sl.yml b/config/locales/sl.yml
index eb3f30350..a4d981878 100644
--- a/config/locales/sl.yml
+++ b/config/locales/sl.yml
@@ -91,6 +91,8 @@ sl:
conversations_count: Število pogovorov
avg_first_response_time: Povprečni prvi odzivni čas
avg_resolution_time: Povprečni čas razrešitve
+ avg_reply_time: Avg reply time
+ resolution_count: Resolution Count
team_csv:
team_name: Ime ekipe
conversations_count: Število pogovorov
@@ -138,6 +140,8 @@ sl:
instagram_story_content: '%{story_sender} vas je omenil v zgodbi: '
instagram_deleted_story_content: Ta zgodba ni več na voljo.
deleted: To sporočilo je bilo izbrisano
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Koda napake: %{error_code}'
activity:
@@ -172,6 +176,10 @@ sl:
sla:
added: '%{user_name} je dodal politiko SLA %{sla_name}'
removed: '%{user_name} je odstranil politiko 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}'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
muted: '%{user_name} je utišal pogovor'
diff --git a/config/locales/sq.yml b/config/locales/sq.yml
index 8509dc3d0..a95d31f8b 100644
--- a/config/locales/sq.yml
+++ b/config/locales/sq.yml
@@ -91,6 +91,8 @@ sq:
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
@@ -138,6 +140,8 @@ sq:
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:
@@ -172,6 +176,10 @@ sq:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/sr.yml b/config/locales/sr.yml
index e8f1006a8..020ee79f6 100644
--- a/config/locales/sr.yml
+++ b/config/locales/sr.yml
@@ -91,6 +91,8 @@ sr-Latn:
conversations_count: Broj razgovora
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Broj rešenih
team_csv:
team_name: Naziv tima
conversations_count: Broj razgovora
@@ -138,6 +140,8 @@ sr-Latn:
instagram_story_content: '%{story_sender} vas je pomenuo u priči: '
instagram_deleted_story_content: Ova priča više nije dostupna.
deleted: Poruka je obrisana
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
@@ -172,6 +176,10 @@ sr-Latn:
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'
muted: '%{user_name} je utišao razgovor'
diff --git a/config/locales/sv.yml b/config/locales/sv.yml
index 4953c85da..e6fd303b2 100644
--- a/config/locales/sv.yml
+++ b/config/locales/sv.yml
@@ -91,6 +91,8 @@ sv:
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: Antal lösta
team_csv:
team_name: Team name
conversations_count: Conversations count
@@ -138,6 +140,8 @@ sv:
instagram_story_content: '%{story_sender} mentioned you in the story: '
instagram_deleted_story_content: This story is no longer available.
deleted: Detta meddelande har tagits bort
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Felkod: %{error_code}'
activity:
@@ -172,6 +176,10 @@ sv:
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'
muted: '%{user_name} har tystat konversationen'
diff --git a/config/locales/ta.yml b/config/locales/ta.yml
index a7571007f..19160d31c 100644
--- a/config/locales/ta.yml
+++ b/config/locales/ta.yml
@@ -91,6 +91,8 @@ ta:
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: தீர்மான எண்ணிக்கை
team_csv:
team_name: Team name
conversations_count: Conversations count
@@ -138,6 +140,8 @@ ta:
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:
@@ -172,6 +176,10 @@ ta:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/th.yml b/config/locales/th.yml
index b1b9bd8d7..92fc4b1c6 100644
--- a/config/locales/th.yml
+++ b/config/locales/th.yml
@@ -91,6 +91,8 @@ th:
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: จำนวนความละเอียด
team_csv:
team_name: ชื่อทีม
conversations_count: Conversations count
@@ -138,6 +140,8 @@ th:
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:
@@ -172,6 +176,10 @@ th:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/tl.yml b/config/locales/tl.yml
index 8c3d209fa..b0ff25b71 100644
--- a/config/locales/tl.yml
+++ b/config/locales/tl.yml
@@ -91,6 +91,8 @@ tl:
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
@@ -138,6 +140,8 @@ tl:
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:
@@ -172,6 +176,10 @@ tl:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/tr.yml b/config/locales/tr.yml
index 2f045ad5a..128783873 100644
--- a/config/locales/tr.yml
+++ b/config/locales/tr.yml
@@ -91,6 +91,8 @@ tr:
conversations_count: Konuşma sayısı
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Çözünürlük Sayısı
team_csv:
team_name: Ekip adı
conversations_count: Konuşma sayısı
@@ -138,6 +140,8 @@ tr:
instagram_story_content: '%{story_sender} hikayesinde senden bahsetti: '
instagram_deleted_story_content: Bu hikaye artık mevcut değil.
deleted: Bu mesaj silinmiş
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Hata kodu: %{error_code}'
activity:
@@ -172,6 +176,10 @@ tr:
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'
muted: '%{user_name}, sohbeti sessize aldı'
diff --git a/config/locales/uk.yml b/config/locales/uk.yml
index a51af682d..b48de05bb 100644
--- a/config/locales/uk.yml
+++ b/config/locales/uk.yml
@@ -91,6 +91,8 @@ uk:
conversations_count: '№ розмов'
avg_first_response_time: Середній час першої відповіді
avg_resolution_time: Середній час вирішення
+ avg_reply_time: Avg reply time
+ resolution_count: Кількість вирішень
team_csv:
team_name: Назва команди
conversations_count: Кількість бесід
@@ -138,6 +140,8 @@ uk:
instagram_story_content: '%{story_sender} згадав вас у сторіс: '
instagram_deleted_story_content: Ця історія більше не доступна.
deleted: Це повідомлення було видалено
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Код помилки: %{error_code}'
activity:
@@ -172,6 +176,10 @@ uk:
sla:
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}'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
muted: '%{user_name} включив безвучний режим'
diff --git a/config/locales/ur.yml b/config/locales/ur.yml
index 2a84171c2..5e441edc4 100644
--- a/config/locales/ur.yml
+++ b/config/locales/ur.yml
@@ -91,6 +91,8 @@ ur:
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
@@ -138,6 +140,8 @@ ur:
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:
@@ -172,6 +176,10 @@ ur:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/ur_IN.yml b/config/locales/ur_IN.yml
index 4cbeef832..cb659d105 100644
--- a/config/locales/ur_IN.yml
+++ b/config/locales/ur_IN.yml
@@ -91,6 +91,8 @@ ur:
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
@@ -138,6 +140,8 @@ ur:
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:
@@ -172,6 +176,10 @@ ur:
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'
muted: '%{user_name} has muted the conversation'
diff --git a/config/locales/vi.yml b/config/locales/vi.yml
index c626577e6..ff1e6c398 100644
--- a/config/locales/vi.yml
+++ b/config/locales/vi.yml
@@ -91,6 +91,8 @@ vi:
conversations_count: Số hội thoại
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
+ avg_reply_time: Avg reply time
+ resolution_count: Số lượng giải quyết
team_csv:
team_name: Tên nhóm
conversations_count: Số hội thoại
@@ -138,6 +140,8 @@ vi:
instagram_story_content: '%{story_sender} đã đề cập đến bạn trong hội thoại: '
instagram_deleted_story_content: Hội thoại này không còn nữa.
deleted: Tin nhắn đã bị xoá
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
@@ -172,6 +176,10 @@ vi:
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'
muted: '%{user_name} đã tắt tiếng hội thoại'
diff --git a/config/locales/zh_CN.yml b/config/locales/zh_CN.yml
index d071daa47..415de5705 100644
--- a/config/locales/zh_CN.yml
+++ b/config/locales/zh_CN.yml
@@ -91,6 +91,8 @@ zh_CN:
conversations_count: 对话数量
avg_first_response_time: 平均首次响应时间
avg_resolution_time: 平均解决时间
+ avg_reply_time: Avg reply time
+ resolution_count: 已解决的数量
team_csv:
team_name: 团队名称
conversations_count: 对话数量
@@ -138,6 +140,8 @@ zh_CN:
instagram_story_content: '%{story_sender} 会话中提到了你: '
instagram_deleted_story_content: 本信息不存在
deleted: 此消息已被删除
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: '错误代码: %{error_code}'
activity:
@@ -172,6 +176,10 @@ zh_CN:
sla:
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}'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
muted: '%{user_name} 已将会话静音'
diff --git a/config/locales/zh_TW.yml b/config/locales/zh_TW.yml
index 26a4b7e5d..d658f5a17 100644
--- a/config/locales/zh_TW.yml
+++ b/config/locales/zh_TW.yml
@@ -91,6 +91,8 @@ zh_TW:
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: 已解決的數量
team_csv:
team_name: 團隊名稱
conversations_count: 對話數量
@@ -138,6 +140,8 @@ zh_TW:
instagram_story_content: '%{story_sender} mentioned you in the story: '
instagram_deleted_story_content: This story is no longer available.
deleted: 訊息已被刪除
+ whatsapp:
+ list_button_label: 'Choose an item'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
@@ -172,6 +176,10 @@ zh_TW:
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'
muted: '%{user_name} 已將對話靜音'
diff --git a/config/routes.rb b/config/routes.rb
index 85a50377b..b9f85f25a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -228,6 +228,10 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
+ namespace :notion do
+ resource :authorization, only: [:create]
+ end
+
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resources :apps, only: [:index, :show]
@@ -265,6 +269,11 @@ Rails.application.routes.draw do
get :linked_issues
end
end
+ resource :notion, controller: 'notion', only: [] do
+ collection do
+ delete :destroy
+ end
+ end
end
resources :working_hours, only: [:update]
@@ -493,6 +502,7 @@ Rails.application.routes.draw do
get 'microsoft/callback', to: 'microsoft/callbacks#show'
get 'google/callback', to: 'google/callbacks#show'
get 'instagram/callback', to: 'instagram/callbacks#show'
+ get 'notion/callback', to: 'notion/callbacks#show'
# ----------------------------------------------------------------------
# Routes for external service verifications
get '.well-known/assetlinks.json' => 'android_app#assetlinks'
diff --git a/db/migrate/20250620120000_create_channel_voice.rb b/db/migrate/20250620120000_create_channel_voice.rb
new file mode 100644
index 000000000..9e2a25723
--- /dev/null
+++ b/db/migrate/20250620120000_create_channel_voice.rb
@@ -0,0 +1,16 @@
+class CreateChannelVoice < ActiveRecord::Migration[7.0]
+ def change
+ create_table :channel_voice do |t|
+ t.string :phone_number, null: false
+ t.string :provider, null: false, default: 'twilio'
+ t.jsonb :provider_config, null: false
+ t.integer :account_id, null: false
+ t.jsonb :additional_attributes, default: {}
+
+ t.timestamps
+ end
+
+ add_index :channel_voice, :phone_number, unique: true
+ add_index :channel_voice, :account_id
+ end
+end
\ No newline at end of file
diff --git a/db/migrate/20250627195529_add_index_to_messages.rb b/db/migrate/20250627195529_add_index_to_messages.rb
new file mode 100644
index 000000000..eb6b95cdd
--- /dev/null
+++ b/db/migrate/20250627195529_add_index_to_messages.rb
@@ -0,0 +1,22 @@
+class AddIndexToMessages < ActiveRecord::Migration[7.0]
+ disable_ddl_transaction!
+
+ def change
+ # This index is added as a temporary fix for performance issues in the CSAT
+ # responses controller where we query messages with account_id, content_type
+ # and created_at. The current implementation (account.message.input_csat.count)
+ # times out with millions of messages.
+ #
+ # TODO: Create a dedicated csat_survey table and add entries when surveys are
+ # sent, then query this table instead of the entire messages table for better
+ # performance.
+ return if index_exists?(
+ :messages,
+ [:account_id, :content_type, :created_at],
+ name: 'idx_messages_account_content_created'
+ )
+
+ add_index :messages, [:account_id, :content_type, :created_at],
+ name: 'idx_messages_account_content_created', algorithm: :concurrently
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index d065a8ff4..af0c89086 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_05_23_031839) do
+ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -443,6 +443,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
end
+ create_table "channel_voice", force: :cascade do |t|
+ t.string "phone_number", null: false
+ t.string "provider", default: "twilio", null: false
+ t.jsonb "provider_config", null: false
+ t.integer "account_id", null: false
+ t.jsonb "additional_attributes", default: {}
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_channel_voice_on_account_id"
+ t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
+ end
+
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
t.string "website_url"
t.integer "account_id"
@@ -813,6 +825,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
t.text "processed_message_content"
t.jsonb "sentiment", default: {}
t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin
+ t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created"
t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type"
t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id"
t.index ["account_id"], name: "index_messages_on_account_id"
@@ -907,7 +920,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
t.text "header_text"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
- t.jsonb "config", default: {"allowed_locales"=>["en"]}
+ t.jsonb "config", default: {"allowed_locales" => ["en"]}
t.boolean "archived", default: false
t.bigint "channel_web_widget_id"
t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_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 e5a055836..ec8e8e653 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
@@ -25,8 +25,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
def playground
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
- params[:message_content],
- message_history
+ additional_message: params[:message_content],
+ message_history: message_history
)
render json: response
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
index b39db609d..396c3a91d 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
@@ -6,4 +6,28 @@ module Enterprise::Api::V1::Accounts::InboxesController
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
end
+
+ private
+
+ def allowed_channel_types
+ super + ['voice']
+ end
+
+ def channel_type_from_params
+ case permitted_params[:channel][:type]
+ when 'voice'
+ Channel::Voice
+ else
+ super
+ end
+ end
+
+ def account_channels_method
+ case permitted_params[:channel][:type]
+ when 'voice'
+ Current.account.voice_channels
+ else
+ super
+ end
+ end
end
diff --git a/enterprise/app/helpers/super_admin/features.yml b/enterprise/app/helpers/super_admin/features.yml
index c20de2dfa..e86f66832 100644
--- a/enterprise/app/helpers/super_admin/features.yml
+++ b/enterprise/app/helpers/super_admin/features.yml
@@ -91,6 +91,12 @@ linear:
enabled: true
icon: 'icon-linear'
config_key: 'linear'
+notion:
+ name: 'Notion'
+ description: 'Configuration for setting up Notion Integration'
+ enabled: true
+ icon: 'icon-notion'
+ config_key: 'notion'
slack:
name: 'Slack'
description: 'Configuration for setting up Slack Integration'
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index eb62a9a38..431945896 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -13,7 +13,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
generate_and_process_response
end
rescue StandardError => e
- raise e if e.is_a?(ActiveJob::FileNotFoundError)
+ raise e if e.is_a?(ActiveStorage::FileNotFoundError)
handle_error(e)
ensure
@@ -26,8 +26,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def generate_and_process_response
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
- @conversation.messages.incoming.last.content,
- collect_previous_messages
+ message_history: collect_previous_messages
)
return process_action('handoff') if handoff_requested?
@@ -43,33 +42,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.map do |message|
- {
- content: message_content(message),
- role: determine_role(message)
- }
- end
- end
-
- def message_content(message)
- return message.content if message.content.present?
- return 'User has shared a message without content' unless message.attachments.any?
-
- audio_transcriptions = extract_audio_transcriptions(message.attachments)
- return audio_transcriptions if audio_transcriptions.present?
-
- 'User has shared an attachment'
- end
-
- def extract_audio_transcriptions(attachments)
- audio_attachments = attachments.where(file_type: :audio)
- return '' if audio_attachments.blank?
-
- transcriptions = ''
- audio_attachments.each do |attachment|
- result = Messages::AudioTranscriptionService.new(attachment).perform
- transcriptions += result[:transcriptions] if result[:success]
+ {
+ content: prepare_multimodal_message_content(message),
+ role: determine_role(message)
+ }
end
- transcriptions
end
def determine_role(message)
@@ -78,6 +55,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
message.message_type == 'incoming' ? 'user' : 'system'
end
+ def prepare_multimodal_message_content(message)
+ Captain::OpenAiMessageBuilderService.new(message: message).generate_content
+ end
+
def handoff_requested?
@response['response'] == 'conversation_handoff'
end
diff --git a/enterprise/app/models/channel/voice.rb b/enterprise/app/models/channel/voice.rb
new file mode 100644
index 000000000..a313129e2
--- /dev/null
+++ b/enterprise/app/models/channel/voice.rb
@@ -0,0 +1,64 @@
+# == Schema Information
+#
+# Table name: channel_voice
+#
+# id :bigint not null, primary key
+# additional_attributes :jsonb
+# phone_number :string not null
+# provider :string default("twilio"), not null
+# provider_config :jsonb not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :integer not null
+#
+# Indexes
+#
+# index_channel_voice_on_account_id (account_id)
+# index_channel_voice_on_phone_number (phone_number) UNIQUE
+#
+class Channel::Voice < ApplicationRecord
+ include Channelable
+
+ self.table_name = 'channel_voice'
+
+ validates :phone_number, presence: true, uniqueness: true
+ validates :provider, presence: true
+ validates :provider_config, presence: true
+
+ # Validate phone number format (E.164 format)
+ validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ }
+
+ # Provider-specific configs stored in JSON
+ validate :validate_provider_config
+
+ EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
+
+ def name
+ "Voice (#{phone_number})"
+ end
+
+ def messaging_window_enabled?
+ false
+ end
+
+ private
+
+ def validate_provider_config
+ return if provider_config.blank?
+
+ case provider
+ when 'twilio'
+ validate_twilio_config
+ end
+ end
+
+ def validate_twilio_config
+ config = provider_config.with_indifferent_access
+ required_keys = %w[account_sid auth_token api_key_sid api_key_secret]
+
+ required_keys.each do |key|
+ errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
+ end
+ end
+end
+
diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb
index 4a573a4c4..c31b6c10e 100644
--- a/enterprise/app/models/enterprise/concerns/account.rb
+++ b/enterprise/app/models/enterprise/concerns/account.rb
@@ -11,5 +11,6 @@ module Enterprise::Concerns::Account
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
has_many :copilot_threads, dependent: :destroy_async
+ has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
end
end
diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb
index 569931d44..ca8fafaa0 100644
--- a/enterprise/app/services/captain/llm/assistant_chat_service.rb
+++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb
@@ -12,9 +12,16 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
register_tools
end
- def generate_response(input, previous_messages = [], role = 'user')
- @messages += previous_messages
- @messages << { role: role, content: input } if input.present?
+ # additional_message: A single message (String) from the user that should be appended to the chat.
+ # It can be an empty String or nil when you only want to supply historical messages.
+ # message_history: An Array of already formatted messages that provide the previous context.
+ # role: The role for the additional_message (defaults to `user`).
+ #
+ # NOTE: Parameters are provided as keyword arguments to improve clarity and avoid relying on
+ # positional ordering.
+ def generate_response(additional_message: nil, message_history: [], role: 'user')
+ @messages += message_history
+ @messages << { role: role, content: additional_message } if additional_message.present?
request_chat_completion
end
diff --git a/enterprise/app/services/captain/open_ai_message_builder_service.rb b/enterprise/app/services/captain/open_ai_message_builder_service.rb
new file mode 100644
index 000000000..3320ad537
--- /dev/null
+++ b/enterprise/app/services/captain/open_ai_message_builder_service.rb
@@ -0,0 +1,59 @@
+class Captain::OpenAiMessageBuilderService
+ pattr_initialize [:message!]
+
+ def generate_content
+ parts = []
+ parts << text_part(@message.content) if @message.content.present?
+ parts.concat(attachment_parts(@message.attachments)) if @message.attachments.any?
+
+ return 'Message without content' if parts.blank?
+ return parts.first[:text] if parts.one? && parts.first[:type] == 'text'
+
+ parts
+ end
+
+ private
+
+ def text_part(text)
+ { type: 'text', text: text }
+ end
+
+ def image_part(image_url)
+ { type: 'image_url', image_url: { url: image_url } }
+ end
+
+ def attachment_parts(attachments)
+ image_attachments = attachments.where(file_type: :image)
+ image_content = image_parts(image_attachments)
+
+ transcription = extract_audio_transcriptions(attachments)
+ transcription_part = text_part(transcription) if transcription.present?
+
+ attachment_part = text_part('User has shared an attachment') if attachments.where.not(file_type: %i[image audio]).exists?
+
+ [image_content, transcription_part, attachment_part].flatten.compact
+ end
+
+ def image_parts(image_attachments)
+ image_attachments.each_with_object([]) do |attachment, parts|
+ url = get_attachment_url(attachment)
+ parts << image_part(url) if url.present?
+ end
+ end
+
+ def get_attachment_url(attachment)
+ return attachment.external_url if attachment.external_url.present?
+
+ attachment.file.attached? ? attachment.file_url : nil
+ end
+
+ def extract_audio_transcriptions(attachments)
+ audio_attachments = attachments.where(file_type: :audio)
+ return '' if audio_attachments.blank?
+
+ audio_attachments.map do |attachment|
+ result = Messages::AudioTranscriptionService.new(attachment).perform
+ result[:success] ? result[:transcriptions] : ''
+ end.join
+ end
+end
\ No newline at end of file
diff --git a/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb b/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb
index 64b52d012..6f942ee8e 100644
--- a/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb
+++ b/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb
@@ -30,7 +30,7 @@ class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseServ
conversation = Conversation.find_by(display_id: conversation_id, account_id: @assistant.account_id)
return 'Conversation not found' if conversation.blank?
- conversation.to_llm_text
+ conversation.to_llm_text(include_private_messages: true)
end
def active?
diff --git a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb
index 76c09a27a..e4df1050b 100644
--- a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb
+++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb
@@ -4,6 +4,8 @@ class Enterprise::Billing::CreateStripeCustomerService
DEFAULT_QUANTITY = 2
def perform
+ return if existing_subscription?
+
customer_id = prepare_customer_id
subscription = Stripe::Subscription.create(
{
@@ -50,4 +52,18 @@ class Enterprise::Billing::CreateStripeCustomerService
price_ids = default_plan['price_ids']
price_ids.first
end
+
+ def existing_subscription?
+ stripe_customer_id = account.custom_attributes['stripe_customer_id']
+ return false if stripe_customer_id.blank?
+
+ subscriptions = Stripe::Subscription.list(
+ {
+ customer: stripe_customer_id,
+ status: 'active',
+ limit: 1
+ }
+ )
+ subscriptions.data.present?
+ end
end
diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb
index 8b598cf28..b7d05766d 100644
--- a/enterprise/app/services/messages/audio_transcription_service.rb
+++ b/enterprise/app/services/messages/audio_transcription_service.rb
@@ -20,7 +20,10 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
private
def can_transcribe?
- account.audio_transcriptions.present? && account.usage_limits[:captain][:responses][:current_available].positive?
+ return false unless account.feature_enabled?('captain_integration')
+ return false if account.audio_transcriptions.blank?
+
+ account.usage_limits[:captain][:responses][:current_available].positive?
end
def fetch_audio_file
diff --git a/lib/integrations/slack/channel_builder.rb b/lib/integrations/slack/channel_builder.rb
index 1edacf8f7..707254e1f 100644
--- a/lib/integrations/slack/channel_builder.rb
+++ b/lib/integrations/slack/channel_builder.rb
@@ -24,10 +24,14 @@ class Integrations::Slack::ChannelBuilder
end
def channels
- conversations_list = slack_client.conversations_list(types: 'public_channel, private_channel', exclude_archived: true)
+ conversations_list = slack_client.conversations_list(types: 'public_channel,private_channel', exclude_archived: true)
channel_list = conversations_list.channels
while conversations_list.response_metadata.next_cursor.present?
- conversations_list = slack_client.conversations_list(cursor: conversations_list.response_metadata.next_cursor)
+ conversations_list = slack_client.conversations_list(
+ cursor: conversations_list.response_metadata.next_cursor,
+ types: 'public_channel,private_channel',
+ exclude_archived: true
+ )
channel_list.concat(conversations_list.channels)
end
channel_list
diff --git a/package.json b/package.json
index 879949d45..bf26a73a9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
- "version": "4.2.0",
+ "version": "4.3.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
diff --git a/public/assets/images/dashboard/channels/voice.png b/public/assets/images/dashboard/channels/voice.png
new file mode 100644
index 000000000..7c9481faf
Binary files /dev/null and b/public/assets/images/dashboard/channels/voice.png differ
diff --git a/public/dashboard/images/integrations/notion-dark.png b/public/dashboard/images/integrations/notion-dark.png
new file mode 100644
index 000000000..7d15c715e
Binary files /dev/null and b/public/dashboard/images/integrations/notion-dark.png differ
diff --git a/public/dashboard/images/integrations/notion.png b/public/dashboard/images/integrations/notion.png
new file mode 100644
index 000000000..a358e8a51
Binary files /dev/null and b/public/dashboard/images/integrations/notion.png differ
diff --git a/spec/controllers/api/v1/accounts/google/authorization_controller_spec.rb b/spec/controllers/api/v1/accounts/google/authorization_controller_spec.rb
index 19ad8f473..b7104108f 100644
--- a/spec/controllers/api/v1/accounts/google/authorization_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/google/authorization_controller_spec.rb
@@ -32,19 +32,20 @@ RSpec.describe 'Google Authorization API', type: :request do
as: :json
expect(response).to have_http_status(:success)
- google_service = Class.new { extend GoogleConcern }
- response_url = google_service.google_client.auth_code.authorize_url(
- {
- redirect_uri: "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback",
- scope: 'email profile https://mail.google.com/',
- response_type: 'code',
- prompt: 'consent',
- access_type: 'offline',
- client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
- }
- )
- expect(response.parsed_body['url']).to eq response_url
- expect(Redis::Alfred.get("google::#{administrator.email}")).to eq(account.id.to_s)
+
+ # Validate URL components
+ url = response.parsed_body['url']
+ uri = URI.parse(url)
+ params = CGI.parse(uri.query)
+
+ expect(url).to start_with('https://accounts.google.com/o/oauth2/auth')
+ expect(params['scope']).to eq(['email profile https://mail.google.com/'])
+ expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback"])
+
+ # Validate state parameter exists and can be decoded back to the account
+ expect(params['state']).to be_present
+ decoded_account = GlobalID::Locator.locate_signed(params['state'].first, for: 'default')
+ expect(decoded_account).to eq(account)
end
end
end
diff --git a/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb b/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb
index f1909d400..60b05b36c 100644
--- a/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb
@@ -19,7 +19,6 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
it 'returns unathorized for agent' do
post "/api/v1/accounts/#{account.id}/microsoft/authorization",
headers: agent.create_new_auth_token,
- params: { email: administrator.email },
as: :json
expect(response).to have_http_status(:unauthorized)
@@ -28,20 +27,27 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
it 'creates a new authorization and returns the redirect url' do
post "/api/v1/accounts/#{account.id}/microsoft/authorization",
headers: administrator.create_new_auth_token,
- params: { email: administrator.email },
as: :json
expect(response).to have_http_status(:success)
- microsoft_service = Class.new { extend MicrosoftConcern }
- response_url = microsoft_service.microsoft_client.auth_code.authorize_url(
- {
- redirect_uri: "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback",
- scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
- prompt: 'consent'
- }
- )
- expect(response.parsed_body['url']).to eq response_url
- expect(Redis::Alfred.get("microsoft::#{administrator.email}")).to eq(account.id.to_s)
+
+ # Validate URL components
+ url = response.parsed_body['url']
+ uri = URI.parse(url)
+ params = CGI.parse(uri.query)
+
+ expect(url).to start_with('https://login.microsoftonline.com/common/oauth2/v2.0/authorize')
+ expected_scope = [
+ 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All ' \
+ 'https://outlook.office.com/SMTP.Send openid profile email'
+ ]
+ expect(params['scope']).to eq(expected_scope)
+ expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
+
+ # Validate state parameter exists and can be decoded back to the account
+ expect(params['state']).to be_present
+ decoded_account = GlobalID::Locator.locate_signed(params['state'].first, for: 'default')
+ expect(decoded_account).to eq(account)
end
end
end
diff --git a/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb
new file mode 100644
index 000000000..ac4bc2841
--- /dev/null
+++ b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb
@@ -0,0 +1,53 @@
+require 'rails_helper'
+
+RSpec.describe 'Notion Authorization API', type: :request do
+ let(:account) { create(:account) }
+
+ describe 'POST /api/v1/accounts/{account.id}/notion/authorization' do
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/notion/authorization"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated user' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:administrator) { create(:user, account: account, role: :administrator) }
+
+ it 'returns unauthorized for agent' do
+ post "/api/v1/accounts/#{account.id}/notion/authorization",
+ headers: agent.create_new_auth_token,
+ params: { email: administrator.email },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'creates a new authorization and returns the redirect url' do
+ post "/api/v1/accounts/#{account.id}/notion/authorization",
+ headers: administrator.create_new_auth_token,
+ params: { email: administrator.email },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+
+ # Validate URL components
+ url = response.parsed_body['url']
+ uri = URI.parse(url)
+ params = CGI.parse(uri.query)
+
+ expect(url).to start_with('https://api.notion.com/v1/oauth/authorize')
+ expect(params['response_type']).to eq(['code'])
+ expect(params['owner']).to eq(['user'])
+ expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/notion/callback"])
+
+ # Validate state parameter exists and can be decoded back to the account
+ expect(params['state']).to be_present
+ decoded_account = GlobalID::Locator.locate_signed(params['state'].first, for: 'default')
+ expect(decoded_account).to eq(account)
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/spec/controllers/concerns/notion_concern_spec.rb b/spec/controllers/concerns/notion_concern_spec.rb
new file mode 100644
index 000000000..7ae11b17d
--- /dev/null
+++ b/spec/controllers/concerns/notion_concern_spec.rb
@@ -0,0 +1,56 @@
+require 'rails_helper'
+
+RSpec.describe NotionConcern, type: :concern do
+ let(:controller_class) do
+ Class.new do
+ include NotionConcern
+ end
+ end
+
+ let(:controller) { controller_class.new }
+
+ describe '#notion_client' do
+ let(:client_id) { 'test_notion_client_id' }
+ let(:client_secret) { 'test_notion_client_secret' }
+
+ before do
+ allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil).and_return(client_id)
+ allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil).and_return(client_secret)
+ end
+
+ it 'creates OAuth2 client with correct configuration' do
+ expect(OAuth2::Client).to receive(:new).with(
+ client_id,
+ client_secret,
+ {
+ site: 'https://api.notion.com',
+ authorize_url: 'https://api.notion.com/v1/oauth/authorize',
+ token_url: 'https://api.notion.com/v1/oauth/token',
+ auth_scheme: :basic_auth
+ }
+ )
+
+ controller.notion_client
+ end
+
+ it 'loads client credentials from GlobalConfigService' do
+ expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil)
+ expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil)
+
+ controller.notion_client
+ end
+
+ it 'returns OAuth2::Client instance' do
+ client = controller.notion_client
+ expect(client).to be_an_instance_of(OAuth2::Client)
+ end
+
+ it 'configures client with Notion-specific endpoints' do
+ client = controller.notion_client
+ expect(client.site).to eq('https://api.notion.com')
+ expect(client.options[:authorize_url]).to eq('https://api.notion.com/v1/oauth/authorize')
+ expect(client.options[:token_url]).to eq('https://api.notion.com/v1/oauth/token')
+ expect(client.options[:auth_scheme]).to eq(:basic_auth)
+ end
+ end
+end
diff --git a/spec/controllers/google/callbacks_controller_spec.rb b/spec/controllers/google/callbacks_controller_spec.rb
index 91535533c..a898ab395 100644
--- a/spec/controllers/google/callbacks_controller_spec.rb
+++ b/spec/controllers/google/callbacks_controller_spec.rb
@@ -4,11 +4,7 @@ RSpec.describe 'Google::CallbacksController', type: :request do
let(:account) { create(:account) }
let(:code) { SecureRandom.hex(10) }
let(:email) { Faker::Internet.email }
- let(:cache_key) { "google::#{email.downcase}" }
-
- before do
- Redis::Alfred.set(cache_key, account.id)
- end
+ let(:state) { account.to_sgid(expires_in: 15.minutes).to_s }
describe 'GET /google/callback' do
let(:response_body_success) do
@@ -27,7 +23,7 @@ RSpec.describe 'Google::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
.to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' })
- get google_callback_url, params: { code: code }
+ get google_callback_url, params: { code: code, state: state }
expect(response).to redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id)
expect(account.inboxes.count).to be 1
@@ -36,7 +32,6 @@ RSpec.describe 'Google::CallbacksController', type: :request do
expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on')
expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token]
expect(inbox.channel.imap_address).to eq 'imap.gmail.com'
- expect(Redis::Alfred.get(cache_key)).to be_nil
end
it 'updates inbox channel config if inbox exists with imap_login and authentication is successful' do
@@ -49,14 +44,13 @@ RSpec.describe 'Google::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
.to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' })
- get google_callback_url, params: { code: code }
+ get google_callback_url, params: { code: code, state: state }
expect(response).to redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
expect(account.inboxes.count).to be 1
expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on')
expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token]
expect(inbox.channel.imap_address).to eq 'imap.gmail.com'
- expect(Redis::Alfred.get(cache_key)).to be_nil
end
it 'creates inboxes with fallback_name when account name is not present in id_token' do
@@ -65,7 +59,7 @@ RSpec.describe 'Google::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
.to_return(status: 200, body: response_body_success_without_name.to_json, headers: { 'Content-Type' => 'application/json' })
- get google_callback_url, params: { code: code }
+ get google_callback_url, params: { code: code, state: state }
expect(response).to redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id)
expect(account.inboxes.count).to be 1
@@ -79,10 +73,9 @@ RSpec.describe 'Google::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
.to_return(status: 401)
- get google_callback_url, params: { code: code }
+ get google_callback_url, params: { code: code, state: state }
expect(response).to redirect_to '/'
- expect(Redis::Alfred.get(cache_key).to_i).to eq account.id
end
end
end
diff --git a/spec/controllers/microsoft/callbacks_controller_spec.rb b/spec/controllers/microsoft/callbacks_controller_spec.rb
index 41d8dbd29..6bd9a0583 100644
--- a/spec/controllers/microsoft/callbacks_controller_spec.rb
+++ b/spec/controllers/microsoft/callbacks_controller_spec.rb
@@ -4,11 +4,7 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
let(:account) { create(:account) }
let(:code) { SecureRandom.hex(10) }
let(:email) { Faker::Internet.email }
- let(:cache_key) { "microsoft::#{email.downcase}" }
-
- before do
- Redis::Alfred.set(cache_key, account.id)
- end
+ let(:state) { account.to_sgid(expires_in: 15.minutes).to_s }
describe 'GET /microsoft/callback' do
let(:response_body_success) do
@@ -27,7 +23,7 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' })
- get microsoft_callback_url, params: { code: code }
+ get microsoft_callback_url, params: { code: code, state: state }
expect(response).to redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id)
expect(account.inboxes.count).to be 1
@@ -36,7 +32,6 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on')
expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token]
expect(inbox.channel.imap_address).to eq 'outlook.office365.com'
- expect(Redis::Alfred.get(cache_key)).to be_nil
end
it 'creates updates inbox channel config if inbox exists and authentication is successful' do
@@ -48,14 +43,13 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' })
- get microsoft_callback_url, params: { code: code }
+ get microsoft_callback_url, params: { code: code, state: state }
expect(response).to redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: account.inboxes.last.id)
expect(account.inboxes.count).to be 1
expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on')
expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token]
expect(inbox.channel.imap_address).to eq 'outlook.office365.com'
- expect(Redis::Alfred.get(cache_key)).to be_nil
end
it 'creates inboxes with fallback_name when account name is not present in id_token' do
@@ -64,7 +58,7 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 200, body: response_body_success_without_name.to_json, headers: { 'Content-Type' => 'application/json' })
- get microsoft_callback_url, params: { code: code }
+ get microsoft_callback_url, params: { code: code, state: state }
expect(response).to redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id)
expect(account.inboxes.count).to be 1
@@ -78,10 +72,9 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 401)
- get microsoft_callback_url, params: { code: code }
+ get microsoft_callback_url, params: { code: code, state: state }
expect(response).to redirect_to '/'
- expect(Redis::Alfred.get(cache_key).to_i).to eq account.id
end
end
end
diff --git a/spec/controllers/notion/callbacks_controller_spec.rb b/spec/controllers/notion/callbacks_controller_spec.rb
new file mode 100644
index 000000000..045bbe396
--- /dev/null
+++ b/spec/controllers/notion/callbacks_controller_spec.rb
@@ -0,0 +1,112 @@
+require 'rails_helper'
+
+RSpec.describe Notion::CallbacksController, type: :request do
+ let(:account) { create(:account) }
+ let(:state) { account.to_sgid.to_s }
+ let(:oauth_code) { 'test_oauth_code' }
+ let(:notion_redirect_uri) { "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/integrations/notion" }
+
+ let(:notion_response_body) do
+ {
+ 'access_token' => 'notion_access_token_123',
+ 'token_type' => 'bearer',
+ 'workspace_name' => 'Test Workspace',
+ 'workspace_id' => 'workspace_123',
+ 'workspace_icon' => 'https://notion.so/icon.png',
+ 'bot_id' => 'bot_123',
+ 'owner' => {
+ 'type' => 'user',
+ 'user' => {
+ 'id' => 'user_123',
+ 'name' => 'Test User'
+ }
+ }
+ }
+ end
+
+ describe 'GET /notion/callback' do
+ before do
+ account.enable_features('notion_integration')
+ stub_const('ENV', ENV.to_hash.merge(
+ 'FRONTEND_URL' => 'http://localhost:3000',
+ 'NOTION_CLIENT_ID' => 'test_client_id',
+ 'NOTION_CLIENT_SECRET' => 'test_client_secret'
+ ))
+
+ controller = described_class.new
+ allow(controller).to receive(:account).and_return(account)
+ allow(controller).to receive(:notion_redirect_uri).and_return(notion_redirect_uri)
+ allow(described_class).to receive(:new).and_return(controller)
+ end
+
+ context 'when OAuth callback is successful' do
+ before do
+ stub_request(:post, 'https://api.notion.com/v1/oauth/token')
+ .to_return(
+ status: 200,
+ body: notion_response_body.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'creates a new integration hook' do
+ expect do
+ get '/notion/callback', params: { code: oauth_code, state: state }
+ end.to change(Integrations::Hook, :count).by(1)
+
+ hook = Integrations::Hook.last
+ expect(hook.access_token).to eq('notion_access_token_123')
+ expect(hook.app_id).to eq('notion')
+ expect(hook.status).to eq('enabled')
+ end
+
+ it 'sets correct hook attributes' do
+ get '/notion/callback', params: { code: oauth_code, state: state }
+
+ hook = Integrations::Hook.last
+ expect(hook.account).to eq(account)
+ expect(hook.app_id).to eq('notion')
+ expect(hook.access_token).to eq('notion_access_token_123')
+ expect(hook.status).to eq('enabled')
+ end
+
+ it 'stores notion workspace data in settings' do
+ get '/notion/callback', params: { code: oauth_code, state: state }
+
+ hook = Integrations::Hook.last
+ expect(hook.settings['token_type']).to eq('bearer')
+ expect(hook.settings['workspace_name']).to eq('Test Workspace')
+ expect(hook.settings['workspace_id']).to eq('workspace_123')
+ expect(hook.settings['workspace_icon']).to eq('https://notion.so/icon.png')
+ expect(hook.settings['bot_id']).to eq('bot_123')
+ expect(hook.settings['owner']).to eq(notion_response_body['owner'])
+ end
+
+ it 'handles successful callback and creates hook' do
+ get '/notion/callback', params: { code: oauth_code, state: state }
+
+ # Due to controller mocking limitations in test,
+ # the redirect URL construction fails but hook creation succeeds
+ expect(Integrations::Hook.last.app_id).to eq('notion')
+ expect(response).to be_redirect
+ end
+ end
+
+ context 'when OAuth token request fails' do
+ before do
+ stub_request(:post, 'https://api.notion.com/v1/oauth/token')
+ .to_return(
+ status: 400,
+ body: { error: 'invalid_grant' }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'redirects to home page on error' do
+ get '/notion/callback', params: { code: oauth_code, state: state }
+
+ expect(response).to redirect_to('/')
+ end
+ end
+ end
+end
diff --git a/spec/controllers/public/api/v1/portals/articles_controller_spec.rb b/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
index e6429a1de..2d8c5fcd5 100644
--- a/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
+++ b/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
@@ -65,6 +65,14 @@ RSpec.describe 'Public Articles API', type: :request do
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
expect(response_data.length).to eq(2)
+ # Only count articles in the current locale (category.locale is 'en')
+ expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(3)
+ end
+
+ it 'returns articles count from all locales when locale parameter is not present' do
+ get "/hc/#{portal.slug}/articles.json"
+
+ expect(response).to have_http_status(:success)
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(5)
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
index 1f6d83d80..80be6f30f 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
@@ -211,8 +211,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success)
expect(chat_service).to have_received(:generate_response).with(
- valid_params[:message_content],
- valid_params[:message_history]
+ additional_message: valid_params[:message_content],
+ message_history: valid_params[:message_history]
)
expect(json_response[:content]).to eq('Assistant response')
end
@@ -232,8 +232,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success)
expect(chat_service).to have_received(:generate_response).with(
- params_without_history[:message_content],
- []
+ additional_message: params_without_history[:message_content],
+ message_history: []
)
end
end
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb
index 724a7b0cb..498c42abd 100644
--- a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb
@@ -22,6 +22,22 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body)['auto_assignment_config']['max_assignment_limit']).to eq 10
end
+
+ it 'creates a voice inbox when administrator' do
+ post "/api/v1/accounts/#{account.id}/inboxes",
+ headers: admin.create_new_auth_token,
+ params: { name: 'Voice Inbox',
+ channel: { type: 'voice', phone_number: '+15551234567',
+ provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
+ auth_token: SecureRandom.hex(16),
+ api_key_sid: SecureRandom.hex(8),
+ api_key_secret: SecureRandom.hex(16) } } },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Voice Inbox')
+ expect(response.body).to include('+15551234567')
+ end
end
end
diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
index 1e4a6e824..ca8d4a6c0 100644
--- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
+++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
@@ -30,5 +30,30 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
account.reload
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
end
+
+ context 'when message contains an image' do
+ let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') }
+ let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') }
+
+ before do
+ image_attachment
+ end
+
+ it 'includes image URL directly in the message content for OpenAI vision analysis' do
+ # Expect the generate_response to receive multimodal content with image URL
+ expect(mock_llm_chat_service).to receive(:generate_response) do |**kwargs|
+ history = kwargs[:message_history]
+ last_entry = history.last
+ expect(last_entry[:content]).to be_an(Array)
+ expect(last_entry[:content].any? { |part| part[:type] == 'text' && part[:text] == 'Can you help with this error?' }).to be true
+ expect(last_entry[:content].any? do |part|
+ part[:type] == 'image_url' && part[:image_url][:url] == 'https://example.com/error.jpg'
+ end).to be true
+ { 'response' => 'I can see the error in your image. It appears to be a database connection issue.' }
+ end
+
+ described_class.perform_now(conversation, assistant)
+ end
+ end
end
end
diff --git a/spec/enterprise/models/channel/voice_spec.rb b/spec/enterprise/models/channel/voice_spec.rb
new file mode 100644
index 000000000..2e52807b0
--- /dev/null
+++ b/spec/enterprise/models/channel/voice_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Channel::Voice do
+ let(:channel) { create(:channel_voice) }
+
+ it 'has a valid factory' do
+ expect(channel).to be_valid
+ end
+
+ describe 'validations' do
+ it 'validates presence of provider_config' do
+ channel.provider_config = nil
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include("can't be blank")
+ end
+
+ it 'validates presence of account_sid in provider_config' do
+ channel.provider_config = { auth_token: 'token' }
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include('account_sid is required for Twilio provider')
+ end
+
+ it 'validates presence of auth_token in provider_config' do
+ channel.provider_config = { account_sid: 'sid' }
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include('auth_token is required for Twilio provider')
+ end
+
+ it 'validates presence of api_key_sid in provider_config' do
+ channel.provider_config = { account_sid: 'sid', auth_token: 'token' }
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include('api_key_sid is required for Twilio provider')
+ end
+
+ it 'validates presence of api_key_secret in provider_config' do
+ channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key' }
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider')
+ end
+
+ it 'is valid with all required provider_config fields' do
+ channel.provider_config = {
+ account_sid: 'test_sid',
+ auth_token: 'test_token',
+ api_key_sid: 'test_key',
+ api_key_secret: 'test_secret'
+ }
+ expect(channel).to be_valid
+ end
+ end
+
+ describe '#name' do
+ it 'returns Voice with phone number' do
+ expect(channel.name).to include('Voice')
+ expect(channel.name).to include(channel.phone_number)
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb b/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb
new file mode 100644
index 000000000..13c29f756
--- /dev/null
+++ b/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb
@@ -0,0 +1,309 @@
+require 'rails_helper'
+
+RSpec.describe Captain::OpenAiMessageBuilderService do
+ subject(:service) { described_class.new(message: message) }
+
+ let(:message) { create(:message, content: 'Hello world') }
+
+ describe '#generate_content' do
+ context 'when message has only text content' do
+ it 'returns the text content directly' do
+ expect(service.generate_content).to eq('Hello world')
+ end
+ end
+
+ context 'when message has no content and no attachments' do
+ let(:message) { create(:message, content: nil) }
+
+ it 'returns default message' do
+ expect(service.generate_content).to eq('Message without content')
+ end
+ end
+
+ context 'when message has text content and attachments' do
+ before do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
+ attachment.save!
+ end
+
+ it 'returns an array of content parts' do
+ result = service.generate_content
+ expect(result).to be_an(Array)
+ expect(result).to include({ type: 'text', text: 'Hello world' })
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ end
+ end
+
+ context 'when message has only non-text attachments' do
+ let(:message) { create(:message, content: nil) }
+
+ before do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
+ attachment.save!
+ end
+
+ it 'returns an array of content parts without text' do
+ result = service.generate_content
+ expect(result).to be_an(Array)
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ expect(result).not_to include(hash_including(type: 'text', text: 'Hello world'))
+ end
+ end
+ end
+
+ describe '#attachment_parts' do
+ let(:message) { create(:message, content: nil) }
+ let(:attachments) { message.attachments }
+
+ context 'with image attachments' do
+ before do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
+ attachment.save!
+ end
+
+ it 'includes image parts' do
+ result = service.send(:attachment_parts, attachments)
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ end
+ end
+
+ context 'with audio attachments' do
+ let(:audio_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio transcription text' })
+ )
+ end
+
+ it 'includes transcription text part' do
+ audio_attachment # trigger creation
+ result = service.send(:attachment_parts, attachments)
+ expect(result).to include({ type: 'text', text: 'Audio transcription text' })
+ end
+ end
+
+ context 'with other file types' do
+ before do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
+ attachment.save!
+ end
+
+ it 'includes generic attachment message' do
+ result = service.send(:attachment_parts, attachments)
+ expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
+ end
+ end
+
+ context 'with mixed attachment types' do
+ let(:image_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
+ attachment.save!
+ attachment
+ end
+
+ let(:audio_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ let(:document_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio text' })
+ )
+ end
+
+ it 'includes all relevant parts' do
+ image_attachment # trigger creation
+ audio_attachment # trigger creation
+ document_attachment # trigger creation
+
+ result = service.send(:attachment_parts, attachments)
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ expect(result).to include({ type: 'text', text: 'Audio text' })
+ expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
+ end
+ end
+ end
+
+ describe '#image_parts' do
+ let(:message) { create(:message, content: nil) }
+
+ context 'with valid image attachments' do
+ let(:image1) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image1.jpg')
+ attachment.save!
+ attachment
+ end
+
+ let(:image2) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image2.jpg')
+ attachment.save!
+ attachment
+ end
+
+ it 'returns image parts for all valid images' do
+ image1 # trigger creation
+ image2 # trigger creation
+
+ image_attachments = message.attachments.where(file_type: :image)
+ result = service.send(:image_parts, image_attachments)
+
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image1.jpg' } })
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image2.jpg' } })
+ end
+ end
+
+ context 'with image attachments without URLs' do
+ let(:image_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: nil)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(image_attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
+ end
+
+ it 'skips images without valid URLs' do
+ image_attachment # trigger creation
+
+ image_attachments = message.attachments.where(file_type: :image)
+ result = service.send(:image_parts, image_attachments)
+
+ expect(result).to be_empty
+ end
+ end
+ end
+
+ describe '#get_attachment_url' do
+ let(:attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image)
+ attachment.save!
+ attachment
+ end
+
+ context 'when attachment has external_url' do
+ before { attachment.update(external_url: 'https://example.com/image.jpg') }
+
+ it 'returns external_url' do
+ expect(service.send(:get_attachment_url, attachment)).to eq('https://example.com/image.jpg')
+ end
+ end
+
+ context 'when attachment has attached file' do
+ before do
+ attachment.update(external_url: nil)
+ allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: true))
+ allow(attachment).to receive(:file_url).and_return('https://local.com/file.jpg')
+ end
+
+ it 'returns file_url' do
+ expect(service.send(:get_attachment_url, attachment)).to eq('https://local.com/file.jpg')
+ end
+ end
+
+ context 'when attachment has no URL or file' do
+ before do
+ attachment.update(external_url: nil)
+ allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
+ end
+
+ it 'returns nil' do
+ expect(service.send(:get_attachment_url, attachment)).to be_nil
+ end
+ end
+ end
+
+ describe '#extract_audio_transcriptions' do
+ let(:message) { create(:message, content: nil) }
+
+ context 'with no audio attachments' do
+ it 'returns empty string' do
+ result = service.send(:extract_audio_transcriptions, message.attachments)
+ expect(result).to eq('')
+ end
+ end
+
+ context 'with successful audio transcriptions' do
+ let(:audio1) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ let(:audio2) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio1).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'First audio text. ' })
+ )
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio2).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Second audio text.' })
+ )
+ end
+
+ it 'concatenates all successful transcriptions' do
+ audio1 # trigger creation
+ audio2 # trigger creation
+
+ attachments = message.attachments
+ result = service.send(:extract_audio_transcriptions, attachments)
+ expect(result).to eq('First audio text. Second audio text.')
+ end
+ end
+
+ context 'with failed audio transcriptions' do
+ let(:audio_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: false, transcriptions: nil })
+ )
+ end
+
+ it 'returns empty string for failed transcriptions' do
+ audio_attachment # trigger creation
+
+ attachments = message.attachments
+ result = service.send(:extract_audio_transcriptions, attachments)
+ expect(result).to eq('')
+ end
+ end
+ end
+
+ describe 'private helper methods' do
+ describe '#text_part' do
+ it 'returns correct text part format' do
+ result = service.send(:text_part, 'Hello world')
+ expect(result).to eq({ type: 'text', text: 'Hello world' })
+ end
+ end
+
+ describe '#image_part' do
+ it 'returns correct image part format' do
+ result = service.send(:image_part, 'https://example.com/image.jpg')
+ expect(result).to eq({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb
index 4d7f1adc7..b8265bbbf 100644
--- a/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb
+++ b/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb
@@ -128,6 +128,29 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do
expect(result).to eq(conversation.to_llm_text)
end
+ it 'includes private messages in the llm text format' do
+ # Create a regular message
+ create(:message,
+ conversation: conversation,
+ message_type: 'outgoing',
+ content: 'Regular message',
+ private: false)
+
+ # Create a private message
+ create(:message,
+ conversation: conversation,
+ message_type: 'outgoing',
+ content: 'Private note content',
+ private: true)
+
+ result = service.execute({ 'conversation_id' => conversation.display_id })
+
+ # Verify that the result includes both regular and private messages
+ expect(result).to include('Regular message')
+ expect(result).to include('Private note content')
+ expect(result).to include('[Private Note]')
+ end
+
context 'when conversation belongs to different account' do
let(:other_account) { create(:account) }
let(:other_inbox) { create(:inbox, account: other_account) }
diff --git a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb
index 95edf73a1..f5b0bbe86 100644
--- a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb
+++ b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb
@@ -6,6 +6,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
let(:account) { create(:account) }
let!(:admin1) { create(:user, account: account, role: :administrator) }
let(:admin2) { create(:user, account: account, role: :administrator) }
+ let(:subscriptions_list) { double }
describe '#perform' do
before do
@@ -19,8 +20,9 @@ describe Enterprise::Billing::CreateStripeCustomerService do
it 'does not call stripe methods if customer id is present' do
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
-
+ allow(subscriptions_list).to receive(:data).and_return([])
allow(Stripe::Customer).to receive(:create)
+ allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(Stripe::Subscription).to receive(:create)
.and_return(
{
@@ -78,4 +80,63 @@ describe Enterprise::Billing::CreateStripeCustomerService do
)
end
end
+
+ describe 'when checking for existing subscriptions' do
+ before do
+ create(
+ :installation_config,
+ { name: 'CHATWOOT_CLOUD_PLANS', value: [
+ { 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
+ ] }
+ )
+ end
+
+ context 'when account has no stripe_customer_id' do
+ it 'creates a new subscription' do
+ customer = double
+ allow(Stripe::Customer).to receive(:create).and_return(customer)
+ allow(customer).to receive(:id).and_return('cus_random_number')
+ allow(Stripe::Subscription).to receive(:create).and_return(
+ {
+ plan: { id: 'price_random_number', product: 'prod_random_number' },
+ quantity: 2
+ }.with_indifferent_access
+ )
+
+ create_stripe_customer_service.new(account: account).perform
+
+ expect(Stripe::Customer).to have_received(:create)
+ expect(Stripe::Subscription).to have_received(:create)
+ end
+ end
+
+ context 'when account has stripe_customer_id' do
+ let(:stripe_customer_id) { 'cus_random_number' }
+
+ before do
+ account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id })
+ end
+
+ context 'when customer has active subscriptions' do
+ before do
+ allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
+ allow(subscriptions_list).to receive(:data).and_return(['subscription'])
+ allow(Stripe::Subscription).to receive(:create)
+ end
+
+ it 'does not create a new subscription' do
+ create_stripe_customer_service.new(account: account).perform
+
+ expect(Stripe::Subscription).not_to have_received(:create)
+ expect(Stripe::Subscription).to have_received(:list).with(
+ {
+ customer: stripe_customer_id,
+ status: 'active',
+ limit: 1
+ }
+ )
+ end
+ end
+ end
+ end
end
diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
index 78879e1a1..41a4cae83 100644
--- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb
+++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
@@ -18,6 +18,16 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
describe '#perform' do
let(:service) { described_class.new(attachment) }
+ context 'when captain_integration feature is not enabled' do
+ before do
+ account.disable_features!('captain_integration')
+ end
+
+ it 'returns transcription limit exceeded' do
+ expect(service.perform).to eq({ error: 'Transcription limit exceeded' })
+ end
+ end
+
context 'when transcription is successful' do
before do
# Mock can_transcribe? to return true and transcribe_audio method
diff --git a/spec/factories/channel/channel_voice.rb b/spec/factories/channel/channel_voice.rb
new file mode 100644
index 000000000..33be75f2e
--- /dev/null
+++ b/spec/factories/channel/channel_voice.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :channel_voice, class: 'Channel::Voice' do
+ sequence(:phone_number) { |n| "+155512345#{n.to_s.rjust(2, '0')}" }
+ provider_config do
+ {
+ account_sid: "AC#{SecureRandom.hex(16)}",
+ auth_token: SecureRandom.hex(16),
+ api_key_sid: SecureRandom.hex(8),
+ api_key_secret: SecureRandom.hex(16)
+ }
+ end
+ account
+
+ after(:create) do |channel_voice|
+ create(:inbox, channel: channel_voice, account: channel_voice.account)
+ end
+ end
+end
diff --git a/spec/listeners/reporting_event_listener_spec.rb b/spec/listeners/reporting_event_listener_spec.rb
index 9a6b8d123..0edf79556 100644
--- a/spec/listeners/reporting_event_listener_spec.rb
+++ b/spec/listeners/reporting_event_listener_spec.rb
@@ -66,6 +66,34 @@ describe ReportingEventListener do
end
describe '#reply_created' do
+ let(:contact) { create(:contact, account: account) }
+
+ def create_customer_message(conversation, created_at: Time.current)
+ create(:message,
+ message_type: 'incoming',
+ account: account,
+ inbox: inbox,
+ conversation: conversation,
+ sender: contact,
+ created_at: created_at)
+ end
+
+ def create_agent_message(conversation, created_at: Time.current, sender: user)
+ create(:message,
+ message_type: 'outgoing',
+ account: account,
+ inbox: inbox,
+ conversation: conversation,
+ sender: sender,
+ created_at: created_at)
+ end
+
+ def create_reply_event(agent_message, waiting_since, event_time = nil)
+ Events::Base.new('reply.created', event_time || agent_message.created_at,
+ waiting_since: waiting_since,
+ message: agent_message)
+ end
+
it 'creates reply created event' do
event = Events::Base.new('reply.created', Time.zone.now, waiting_since: 2.hours.ago, message: message)
listener.reply_created(event)
@@ -74,6 +102,88 @@ describe ReportingEventListener do
expect(events.length).to be 1
expect(events.first.value).to be_within(1).of(7200)
end
+
+ context 'when conversation is reopened' do
+ let(:resolved_conversation) do
+ create(:conversation, account: account, inbox: inbox, assignee: user,
+ status: 'resolved', contact: contact)
+ end
+
+ context 'when customer sends message after resolution' do
+ it 'calculates reply time from the reopening message' do
+ customer_message_time = 3.hours.ago
+ create_customer_message(resolved_conversation, created_at: customer_message_time)
+
+ resolved_conversation.reload
+ expect(resolved_conversation.status).to eq('open')
+
+ agent_reply_time = 1.hour.ago
+ agent_message = create_agent_message(resolved_conversation, created_at: agent_reply_time)
+
+ event = create_reply_event(agent_message, customer_message_time)
+ listener.reply_created(event)
+
+ events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
+ expect(events.length).to be 1
+ expect(events.first.value).to be_within(60).of(7200)
+ end
+ end
+
+ context 'when conversation has multiple reopenings' do
+ it 'tracks reply time correctly for each reopening' do
+ create_customer_message(resolved_conversation, created_at: 5.hours.ago)
+ first_agent_reply = create_agent_message(resolved_conversation, created_at: 4.hours.ago)
+
+ event = create_reply_event(first_agent_reply, 5.hours.ago)
+ listener.reply_created(event)
+
+ resolved_conversation.update!(status: 'resolved')
+
+ create_customer_message(resolved_conversation, created_at: 2.hours.ago)
+ second_agent_reply = create_agent_message(resolved_conversation, created_at: 1.5.hours.ago)
+
+ event = create_reply_event(second_agent_reply, 2.hours.ago)
+ listener.reply_created(event)
+
+ events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
+ .order(created_at: :asc)
+ expect(events.length).to be 2
+ expect(events.first.value).to be_within(60).of(3600)
+ expect(events.second.value).to be_within(60).of(1800)
+ end
+ end
+
+ context 'when conversation is manually reopened' do
+ it 'sets waiting_since when first customer message arrives after manual reopening' do
+ resolved_conversation.update!(status: 'open')
+
+ customer_message_time = 1.hour.ago
+ create_customer_message(resolved_conversation, created_at: customer_message_time)
+
+ agent_reply_time = 15.minutes.ago
+ agent_message = create_agent_message(resolved_conversation, created_at: agent_reply_time)
+
+ event = create_reply_event(agent_message, customer_message_time)
+ listener.reply_created(event)
+
+ events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
+ expect(events.length).to be 1
+ expect(events.first.value).to be_within(60).of(2700)
+ end
+ end
+
+ context 'when waiting_since is nil' do
+ it 'does not creates reply time events' do
+ agent_message = create_agent_message(resolved_conversation)
+
+ event = create_reply_event(agent_message, nil)
+ listener.reply_created(event)
+
+ events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
+ expect(events.length).to be 0
+ end
+ end
+ end
end
describe '#first_reply_created' do
diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb
index 8485fcf7a..2a0d6c8b0 100644
--- a/spec/mailers/conversation_reply_mailer_spec.rb
+++ b/spec/mailers/conversation_reply_mailer_spec.rb
@@ -154,6 +154,27 @@ RSpec.describe ConversationReplyMailer do
expect(mail.message_id).to eq message.source_id
end
+ context 'when message is a CSAT survey' do
+ let(:csat_message) do
+ create(:message, conversation: conversation, account: account, message_type: 'template',
+ content_type: 'input_csat', content: 'How would you rate our support?', sender: agent)
+ end
+
+ it 'includes CSAT survey URL in outgoing_content' do
+ with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
+ mail = described_class.email_reply(csat_message).deliver_now
+ expect(mail.decoded).to include "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
+ end
+ end
+
+ it 'uses outgoing_content for CSAT message body' do
+ with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
+ mail = described_class.email_reply(csat_message).deliver_now
+ expect(mail.decoded).to include csat_message.outgoing_content
+ end
+ end
+ end
+
context 'with email attachments' do
it 'includes small attachments as email attachments' do
message_with_attachment = create(:message, conversation: conversation, account: account, message_type: 'outgoing',
diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb
index aef91603d..a29359528 100644
--- a/spec/models/conversation_spec.rb
+++ b/spec/models/conversation_spec.rb
@@ -836,4 +836,117 @@ RSpec.describe Conversation do
expect(message_window_service).to have_received(:can_reply?)
end
end
+
+ describe 'reply time calculation flows' do
+ include ActiveJob::TestHelper
+
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:contact) { create(:contact, account: account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, assignee: agent, waiting_since: nil) }
+ let(:conversation_start_time) { 5.hours.ago }
+
+ before do
+ create(:inbox_member, user: agent, inbox: inbox)
+ # rubocop:disable Rails/SkipsModelValidations
+ conversation.update_column(:waiting_since, nil)
+ conversation.update_column(:created_at, conversation_start_time)
+ # rubocop:enable Rails/SkipsModelValidations
+ conversation.messages.destroy_all
+ conversation.reporting_events.destroy_all
+ conversation.reload
+ end
+
+ def create_customer_message(conversation, created_at: Time.current)
+ message = nil
+ perform_enqueued_jobs do
+ message = create(:message,
+ message_type: 'incoming',
+ account: conversation.account,
+ inbox: conversation.inbox,
+ conversation: conversation,
+ sender: conversation.contact,
+ created_at: created_at)
+ end
+ message
+ end
+
+ def create_agent_message(conversation, created_at: Time.current)
+ message = nil
+ perform_enqueued_jobs do
+ message = create(:message,
+ message_type: 'outgoing',
+ account: conversation.account,
+ inbox: conversation.inbox,
+ conversation: conversation,
+ sender: conversation.assignee,
+ created_at: created_at)
+ end
+ message
+ end
+
+ it 'correctly tracks waiting_since and creates first response time events' do
+ create_customer_message(conversation, created_at: conversation_start_time)
+ conversation.reload
+ expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
+
+ # Agent replies - this should create first response event
+ agent_reply1_time = 4.hours.ago
+ create_agent_message(conversation, created_at: agent_reply1_time)
+
+ first_response_events = account.reporting_events.where(name: 'first_response', conversation_id: conversation.id)
+ expect(first_response_events.count).to eq(1)
+ expect(first_response_events.first.value).to be_within(1.second).of(1.hour)
+
+ # the first response should also clear the waiting_since
+ conversation.reload
+ expect(conversation.waiting_since).to be_nil
+ end
+
+ it 'does not reset waiting_since if customer sends another message' do
+ create_customer_message(conversation, created_at: conversation_start_time)
+ conversation.reload
+ expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
+
+ create_customer_message(conversation, created_at: 3.hours.ago)
+ conversation.reload
+ expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
+ end
+
+ it 'records the correct reply_time for subsequent messages' do
+ create_customer_message(conversation, created_at: conversation_start_time)
+ create_agent_message(conversation, created_at: 4.hours.ago)
+ create_customer_message(conversation, created_at: 3.hours.ago)
+
+ create_agent_message(conversation, created_at: 2.hours.ago)
+ reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
+ expect(reply_events.count).to eq(1)
+ expect(reply_events.first.value).to be_within(1.second).of(1.hour)
+
+ conversation.reload
+ expect(conversation.waiting_since).to be_nil
+ end
+
+ it 'records zero reply time if an agent sends a message after resolution' do
+ create_customer_message(conversation, created_at: conversation_start_time)
+ create_agent_message(conversation, created_at: 4.hours.ago)
+ create_customer_message(conversation, created_at: 3.hours.ago)
+
+ conversation.toggle_status
+ expect(conversation.status).to eq('resolved')
+
+ conversation.toggle_status
+ expect(conversation.status).to eq('open')
+
+ conversation.reload
+ expect(conversation.waiting_since).to be_nil
+
+ create_agent_message(conversation, created_at: 1.hour.ago)
+ # update_waiting_since will ensure that no events were created since the waiting_since was nil
+ # if the event is created it should log zero value, we have handled that in the reporting_event_listener
+ reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
+ expect(reply_events.count).to eq(0)
+ end
+ end
end
diff --git a/spec/presenters/message_content_presenter_spec.rb b/spec/presenters/message_content_presenter_spec.rb
index b1be37ef2..f85bdb8d1 100644
--- a/spec/presenters/message_content_presenter_spec.rb
+++ b/spec/presenters/message_content_presenter_spec.rb
@@ -34,20 +34,21 @@ RSpec.describe MessageContentPresenter do
before do
allow(message.inbox).to receive(:web_widget?).and_return(false)
- allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com')
end
it 'returns I18n default message when no CSAT config and dynamically generates survey URL' do
- expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
- allow(I18n).to receive(:t).with('conversations.survey.response', link: expected_url)
- .and_return("Please rate this conversation, #{expected_url}")
- expect(presenter.outgoing_content).to eq("Please rate this conversation, #{expected_url}")
+ with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
+ expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
+ expect(presenter.outgoing_content).to include(expected_url)
+ end
end
it 'returns CSAT config message when config exists and dynamically generates survey URL' do
- allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' })
- expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
- expect(presenter.outgoing_content).to eq("Custom CSAT message #{expected_url}")
+ with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
+ allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' })
+ expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
+ expect(presenter.outgoing_content).to eq("Custom CSAT message #{expected_url}")
+ end
end
end
end
diff --git a/spec/services/llm_formatter/conversation_llm_formatter_spec.rb b/spec/services/llm_formatter/conversation_llm_formatter_spec.rb
index 93fec14f7..49fcc1a18 100644
--- a/spec/services/llm_formatter/conversation_llm_formatter_spec.rb
+++ b/spec/services/llm_formatter/conversation_llm_formatter_spec.rb
@@ -61,5 +61,30 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
expect(formatter.format(include_contact_details: true)).to eq(expected_output)
end
end
+
+ context 'when conversation has custom attributes' do
+ it 'includes formatted custom attributes in the output' do
+ create(
+ :custom_attribute_definition,
+ account: account,
+ attribute_display_name: 'Order ID',
+ attribute_key: 'order_id',
+ attribute_model: :conversation_attribute
+ )
+
+ conversation.update(custom_attributes: { 'order_id' => '12345' })
+
+ expected_output = [
+ "Conversation ID: ##{conversation.display_id}",
+ "Channel: #{conversation.inbox.channel.name}",
+ 'Message History:',
+ 'No messages in this conversation',
+ 'Conversation Attributes:',
+ 'Order ID: 12345'
+ ].join("\n")
+
+ expect(formatter.format).to eq(expected_output)
+ end
+ end
end
end