+
+ <%= content_for(:title) %>
+
- <% if show_search_bar %>
- <%= render(
- "search",
- search_term: search_term,
- resource_name: display_resource_name(page.resource_name)
- ) %>
- <% end %>
+
+ <% if show_search_bar %>
+
+ <%= render("filters", page: page) %>
+
+ <%= render(
+ "search",
+ search_term: search_term,
+ resource_name: display_resource_name(page.resource_name)
+ ) %>
+
+
+ <% end %>
-
- <%= link_to(
- t(
- "administrate.actions.new_resource",
- name: page.resource_name.titleize.downcase
- ),
- [:new, namespace, page.resource_path.to_sym],
- class: "button",
- ) if accessible_action?(new_resource, :new) %>
+
+ <%= link_to(
+ t(
+ "administrate.actions.new_resource",
+ name: page.resource_name.titleize.downcase
+ ),
+ [:new, namespace, page.resource_path.to_sym],
+ class: "button",
+ ) if accessible_action?(new_resource, :new) %>
+
+
diff --git a/config/app.yml b/config/app.yml
index 668ffcbaf..f655afca7 100644
--- a/config/app.yml
+++ b/config/app.yml
@@ -1,5 +1,5 @@
shared: &shared
- version: '4.0.2'
+ version: '4.0.3'
development:
<<: *shared
diff --git a/config/installation_config.yml b/config/installation_config.yml
index 1a804b707..d5924ebe0 100644
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -103,13 +103,16 @@
display_title: 'Facebook Verify Token'
description: 'The verify token used for Facebook Messenger Webhook'
locked: false
+ type: secret
- name: FB_APP_SECRET
display_title: 'Facebook App Secret'
locked: false
+ type: secret
- name: IG_VERIFY_TOKEN
display_title: 'Instagram Verify Token'
description: 'The verify token used for Instagram Webhook'
locked: false
+ type: secret
- name: FACEBOOK_API_VERSION
display_title: 'Facebook API Version'
description: 'Configure this if you want to use a different Facebook API version. Make sure its prefixed with `v`'
@@ -131,6 +134,7 @@
- name: AZURE_APP_SECRET
display_title: 'Azure App Secret'
locked: false
+ type: secret
# End of Microsoft Email Channel Config
# MARK: Captain Config
@@ -138,6 +142,7 @@
display_title: 'OpenAI API Key'
description: 'The API key used to authenticate requests to OpenAI services for Captain AI.'
locked: false
+ type: secret
- name: CAPTAIN_OPEN_AI_MODEL
display_title: 'OpenAI Model'
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4o-mini'
@@ -146,6 +151,7 @@
display_title: 'FireCrawl API Key (optional)'
description: 'The FireCrawl API key for the Captain AI service'
locked: false
+ type: secret
- name: CAPTAIN_CLOUD_PLAN_LIMITS
display_title: 'Captain Cloud Plan Limits'
description: 'The limits for the Captain AI service for different plans'
@@ -160,11 +166,13 @@
display_title: 'Inbox Token'
description: 'The Chatwoot Inbox Token for Contact Support in Cloud'
locked: false
+ type: secret
- name: CHATWOOT_INBOX_HMAC_KEY
value:
display_title: 'Inbox HMAC Key'
description: 'The Chatwoot Inbox HMAC Key for Contact Support in Cloud'
locked: false
+ type: secret
- name: CHATWOOT_CLOUD_PLANS
display_title: 'Cloud Plans'
value:
@@ -180,10 +188,12 @@
value:
display_title: 'Analytics Token'
description: 'The June.so analytics token for Chatwoot cloud'
+ type: secret
- name: CLEARBIT_API_KEY
value:
display_title: 'Clearbit API Key'
description: 'This API key is used for onboarding the users, to pre-fill account data.'
+ type: secret
- name: DASHBOARD_SCRIPTS
value:
display_title: 'Dashboard Scripts'
@@ -206,12 +216,18 @@
- name: CHATWOOT_SUPPORT_WEBSITE_TOKEN
value:
description: 'The Chatwoot website token, used to identify the Chatwoot inbox and display the "Contact Support" option on the billing page'
+ type: secret
- name: CHATWOOT_SUPPORT_SCRIPT_URL
value:
description: 'The Chatwoot script base URL, to display the "Contact Support" option on the billing page'
- name: CHATWOOT_SUPPORT_IDENTIFIER_HASH
value:
description: 'The Chatwoot identifier hash, to validate the contact in the live chat window.'
+ type: secret
+- name: ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL
+ display_title: Webhook URL to post security analysis
+ value:
+ description: Used to notify Chatwoot about account abuses, potential threads (Should be a Discord Webhook URL)
# ------- End of Chatwoot Internal Config for Self Hosted ----#
## ------ Configs added for enterprise clients ------ ##
@@ -241,5 +257,20 @@
display_title: 'Firebase Credentials'
value:
locked: false
+ type: secret
description: 'Contents on your firebase credentials json file'
## ------ End of Configs added for FCM v1 notifications ------ ##
+
+## ------ Configs added for Linear ------ ##
+- name: LINEAR_CLIENT_ID
+ display_title: 'Linear Client ID'
+ value:
+ locked: false
+ description: 'Linear client ID'
+- name: LINEAR_CLIENT_SECRET
+ display_title: 'Linear Client Secret'
+ value:
+ locked: false
+ description: 'Linear client secret'
+ type: secret
+## ------ End of Configs added for Linear ------ ##
diff --git a/config/locales/am.yml b/config/locales/am.yml
index c7ea8a171..3e2297c78 100644
--- a/config/locales/am.yml
+++ b/config/locales/am.yml
@@ -68,6 +68,8 @@ am:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/ar.yml b/config/locales/ar.yml
index 869346c8d..f01e74dd6 100644
--- a/config/locales/ar.yml
+++ b/config/locales/ar.yml
@@ -68,6 +68,8 @@ ar:
invalid_operator: مشغل غير صالح. المشغل المسموح به لـ %{attribute_name} هو [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: قيمة غير صالحة. القيم المقدمة ل %{attribute_name} غير صالحة
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: فترة التبليغ %{since} إلى %{until}
utc_warning: التقرير الذي تم إنشاؤه في التوقيت العالمي الموحّد
diff --git a/config/locales/az.yml b/config/locales/az.yml
index 6601310f6..ffd7e9051 100644
--- a/config/locales/az.yml
+++ b/config/locales/az.yml
@@ -68,6 +68,8 @@ az:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/bg.yml b/config/locales/bg.yml
index 6ff3193e6..05f930db4 100644
--- a/config/locales/bg.yml
+++ b/config/locales/bg.yml
@@ -68,6 +68,8 @@ bg:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/ca.yml b/config/locales/ca.yml
index 1c1b6cee1..c07a98f2f 100644
--- a/config/locales/ca.yml
+++ b/config/locales/ca.yml
@@ -68,6 +68,8 @@ ca:
invalid_operator: Operador no vàlid. Els operadors permesos per a %{attribute_name} son [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Valor no vàlid. Els valors proporcionats per a %{attribute_name} no són vàlids
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Període d'informes %{since} a %{until}
utc_warning: L'informe generat es troba a la zona horària UTC
diff --git a/config/locales/cs.yml b/config/locales/cs.yml
index b94669586..5e5c74c9e 100644
--- a/config/locales/cs.yml
+++ b/config/locales/cs.yml
@@ -68,6 +68,8 @@ cs:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/da.yml b/config/locales/da.yml
index 603c0268c..c7e268c88 100644
--- a/config/locales/da.yml
+++ b/config/locales/da.yml
@@ -68,6 +68,8 @@ da:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Rapporteringsperiode %{since} til %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 142f31859..89bd65f0b 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -68,6 +68,8 @@ de:
invalid_operator: Ungültiger Operator. Die erlaubten Operatoren für %{attribute_name} sind [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Ungültiger Wert. Die Werte für %{attribute_name} sind ungültig
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Berichtszeitraum von %{since} bis %{until}
utc_warning: Der generierte Bericht ist in UTC-Zeitzone
diff --git a/config/locales/el.yml b/config/locales/el.yml
index 3236f5272..8b7330a3c 100644
--- a/config/locales/el.yml
+++ b/config/locales/el.yml
@@ -68,6 +68,8 @@ el:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Περίοδος αναφοράς %{since} έως %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 16d13ab71..4183c873d 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -82,6 +82,8 @@ en:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 51cc4bbcd..63e176c5c 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -68,6 +68,8 @@ es:
invalid_operator: Operador no válido. Los operadores permitidos para %{attribute_name} son [%{allowed_keys}].
invalid_query_operator: El operador de consulta debe ser "Y" o "O".
invalid_value: Valor no válido. Los valores proporcionados para %{attribute_name} no son válidos
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reportando el periodo desde %{since} hasta %{until}
utc_warning: El informe generado está en zona horaria UTC
diff --git a/config/locales/fa.yml b/config/locales/fa.yml
index 6954ddf3e..3818d1d2e 100644
--- a/config/locales/fa.yml
+++ b/config/locales/fa.yml
@@ -68,6 +68,8 @@ fa:
invalid_operator: این عملیات مجاز نیست. عملیات های مجاز برای %{attribute_name} شامل %{allowed_keys} می باشد.
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: مقدار معتبر نیست. مقادیر ارائه شده برای %{attribute_name} معتبر نیست
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: زمان گزارش از %{since} تا %{until}
utc_warning: گزارش تولید شده در منطقه زمانی UTC است
diff --git a/config/locales/fi.yml b/config/locales/fi.yml
index 4227c654a..992be9fd1 100644
--- a/config/locales/fi.yml
+++ b/config/locales/fi.yml
@@ -68,6 +68,8 @@ fi:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Raportointijakso %{since} – %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index ce2db80a5..c47b19809 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -68,6 +68,8 @@ fr:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Période de rapport %{since} à %{until}
utc_warning: Le rapport généré est dans le fuseau horaire UTC
diff --git a/config/locales/he.yml b/config/locales/he.yml
index f794d5716..7162882a6 100644
--- a/config/locales/he.yml
+++ b/config/locales/he.yml
@@ -68,6 +68,8 @@ he:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/hi.yml b/config/locales/hi.yml
index 8c4db36c4..a31c616dc 100644
--- a/config/locales/hi.yml
+++ b/config/locales/hi.yml
@@ -68,6 +68,8 @@ hi:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/hr.yml b/config/locales/hr.yml
index 340421661..735bf0067 100644
--- a/config/locales/hr.yml
+++ b/config/locales/hr.yml
@@ -68,6 +68,8 @@ hr:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/hu.yml b/config/locales/hu.yml
index 915ef64fc..73ece653d 100644
--- a/config/locales/hu.yml
+++ b/config/locales/hu.yml
@@ -68,6 +68,8 @@ hu:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Jelentési időszak %{since}-tól %{until}-ig
utc_warning: A generált riport UTC időzónát használ
diff --git a/config/locales/hy.yml b/config/locales/hy.yml
index a3c2874df..0750ac8c1 100644
--- a/config/locales/hy.yml
+++ b/config/locales/hy.yml
@@ -68,6 +68,8 @@ hy:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/id.yml b/config/locales/id.yml
index 4d554a31c..a154d065c 100644
--- a/config/locales/id.yml
+++ b/config/locales/id.yml
@@ -68,6 +68,8 @@ id:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Periode pelaporan %{since} hingga %{until}
utc_warning: Laporan yang dihasilkan berada dalam zona waktu UTC
diff --git a/config/locales/is.yml b/config/locales/is.yml
index 40d63365d..5847ea6d7 100644
--- a/config/locales/is.yml
+++ b/config/locales/is.yml
@@ -68,6 +68,8 @@ is:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/it.yml b/config/locales/it.yml
index bbde04589..39b14762d 100644
--- a/config/locales/it.yml
+++ b/config/locales/it.yml
@@ -68,6 +68,8 @@ it:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Periodo di segnalazione da %{since} a %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 2ebf3b232..0fb403728 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -68,6 +68,8 @@ ja:
invalid_operator: 無効な演算子です。%{attribute_name} に許可されている演算子は [%{allowed_keys}] です。
invalid_query_operator: クエリ演算子は "AND" または "OR" でなければなりません。
invalid_value: 無効な値です。%{attribute_name} に提供された値は無効です。
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: レポート期間 %{since} から %{until} まで
utc_warning: 生成されたレポートはUTCタイムゾーンです
diff --git a/config/locales/ka.yml b/config/locales/ka.yml
index 980b7b161..51083a438 100644
--- a/config/locales/ka.yml
+++ b/config/locales/ka.yml
@@ -68,6 +68,8 @@ ka:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index 7e1156b68..6008c841a 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -68,6 +68,8 @@ ko:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: 보고 기간 %{since} - %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/lt.yml b/config/locales/lt.yml
index d073c0fc7..50475f598 100644
--- a/config/locales/lt.yml
+++ b/config/locales/lt.yml
@@ -68,6 +68,8 @@ lt:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Ataskaitinis laikotarpis nuo %{since} iki %{until}
utc_warning: Sugeneruota ataskaita yra UTC laiko juostoje
diff --git a/config/locales/lv.yml b/config/locales/lv.yml
index 68b8816d4..acf0d2ca9 100644
--- a/config/locales/lv.yml
+++ b/config/locales/lv.yml
@@ -68,6 +68,8 @@ lv:
invalid_operator: Nederīgs operators. Atļautie operatori priekš %{attribute_name} ir [%{allowed_keys}].
invalid_query_operator: Vaicājuma operatoram ir jābūt "UN" vai "VAI".
invalid_value: Nederīga vērtība. Norādītās vērtības priekš %{attribute_name} nav derīgas
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Ziņošanas periods %{since} līdz %{until}
utc_warning: Izveidotais pārskats atbilst UTC laika joslai
diff --git a/config/locales/ml.yml b/config/locales/ml.yml
index c1c7a4e3f..268c76329 100644
--- a/config/locales/ml.yml
+++ b/config/locales/ml.yml
@@ -68,6 +68,8 @@ ml:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/ms.yml b/config/locales/ms.yml
index 4023cca51..d8cb502e9 100644
--- a/config/locales/ms.yml
+++ b/config/locales/ms.yml
@@ -68,6 +68,8 @@ ms:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/ne.yml b/config/locales/ne.yml
index 8e4d45483..5c72426a4 100644
--- a/config/locales/ne.yml
+++ b/config/locales/ne.yml
@@ -68,6 +68,8 @@ ne:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index 2e73b8bd3..7ff10aa66 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -68,6 +68,8 @@ nl:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Rapportering van %{since} tot %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/no.yml b/config/locales/no.yml
index 52e3902e7..771c924a0 100644
--- a/config/locales/no.yml
+++ b/config/locales/no.yml
@@ -68,6 +68,8 @@
invalid_operator: Ugyldig operatør. De tillatte operatørene for %{attribute_name} er [%{allowed_keys}].
invalid_query_operator: Spørrings-operatør må være enten "AND" eller "OR".
invalid_value: Ugyldig verdi. Verdiene angitt for %{attribute_name} er ugyldige
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Rapporteringsperiode %{since} til %{until}
utc_warning: Rapporten generert er i UTC tidssone
diff --git a/config/locales/pl.yml b/config/locales/pl.yml
index 69dd0fc5a..f95e8a1c8 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -68,6 +68,8 @@ pl:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Okres raportowania od %{since} do %{until}
utc_warning: Generowany raport jest w strefie czasowej UTC
diff --git a/config/locales/pt.yml b/config/locales/pt.yml
index c498a8deb..844485ca6 100644
--- a/config/locales/pt.yml
+++ b/config/locales/pt.yml
@@ -68,6 +68,8 @@ pt:
invalid_operator: Operador inválido. Os operadores permitidos para %{attribute_name} são [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Valor inválido. Os valores fornecidos para %{attribute_name} são inválidos
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Período do relatório de %{since} a %{until}
utc_warning: O relatório gerado está no fuso horário UTC
diff --git a/config/locales/pt_BR.yml b/config/locales/pt_BR.yml
index 6e471537f..36ec2df73 100644
--- a/config/locales/pt_BR.yml
+++ b/config/locales/pt_BR.yml
@@ -68,6 +68,8 @@ pt_BR:
invalid_operator: Operador inválido. Os operadores permitidos para %{attribute_name} são [%{allowed_keys}].
invalid_query_operator: Operador de consulta deve ser "E" ou "OU".
invalid_value: Valor inválido. Os valores fornecidos para %{attribute_name} são inválidos
+ custom_attribute_definition:
+ key_conflict: A chave fornecida não é permitida pois pode entrar em conflito com os atributos padrão.
reports:
period: Reportando o período %{since} a %{until}
utc_warning: O relatório gerado está em fuso horário UTC
@@ -133,7 +135,7 @@ pt_BR:
messages:
instagram_story_content: '%{story_sender} mencionou você na conversa: '
instagram_deleted_story_content: Este Story não está mais disponível.
- deleted: Esta mensagem foi apagada
+ deleted: Esta mensagem foi excluída
delivery_status:
error_code: 'Código de erro: %{error_code}'
activity:
diff --git a/config/locales/ro.yml b/config/locales/ro.yml
index ff5e7d032..f832b940a 100644
--- a/config/locales/ro.yml
+++ b/config/locales/ro.yml
@@ -68,6 +68,8 @@ ro:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Perioada de raportare %{since}-%{until}
utc_warning: Raportul generat este în fusul orar UTC
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index cf12f801d..c0a6618ba 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -68,6 +68,8 @@ ru:
invalid_operator: Неверный оператор. Допустимыми операторами для %{attribute_name} являются [%{allowed_keys}].
invalid_query_operator: Оператор запроса должен быть "AND" или "OR".
invalid_value: Недопустимое значение. Значения, предоставленные для %{attribute_name} являются недопустимыми
+ custom_attribute_definition:
+ key_conflict: Предоставленный ключ не разрешён, так как он может конфликтовать со стандартными атрибутами.
reports:
period: Отчётный период с %{since} по %{until}
utc_warning: Отчёт создан в часовом поясе UTC
@@ -165,7 +167,7 @@ ru:
removed: '%{user_name} удалил политику SLA %{sla_name}'
muted: '%{user_name} заглушил(а) этот разговор'
unmuted: '%{user_name} включил(а) уведомления для разговора'
- auto_resolution_message: 'Resolving the conversation as it has been inactive for a while. Please start a new conversation if you need further assistance.'
+ auto_resolution_message: 'Разговор закрывается, поскольку он был неактивен в течение длительного времени. Пожалуйста, начните новый разговор, если потребуется дополнительная помощь.'
templates:
greeting_message_body: '%{account_name} как правило отвечает в течении несколько часов.'
ways_to_reach_you_message_body: 'Оставьте ваш email для связи'
@@ -216,8 +218,8 @@ ru:
name: 'Linear'
description: 'Создавайте или прикрепляйте уже существующие задачи в Linear непосредственно из окна диалога для более упорядоченного и эффективного процесса отслеживания проблем.'
captain:
- copilot_error: 'Please connect an assistant to this inbox to use Copilot'
- copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
+ copilot_error: 'Пожалуйста, подключите ассистента к этому источнику входящих для использования Copilot'
+ copilot_limit: 'У вас закончились кредиты для Copilot. Вы можете купить дополнительные кредиты в разделе биллинга.'
public_portal:
search:
search_placeholder: Поиск статьи по названию или содержанию...
diff --git a/config/locales/sh.yml b/config/locales/sh.yml
index 577146912..0714ad604 100644
--- a/config/locales/sh.yml
+++ b/config/locales/sh.yml
@@ -68,6 +68,8 @@ sh:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/sk.yml b/config/locales/sk.yml
index c211c4a5a..1de4582a0 100644
--- a/config/locales/sk.yml
+++ b/config/locales/sk.yml
@@ -68,6 +68,8 @@ sk:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/sl.yml b/config/locales/sl.yml
index c622ce4ff..7af2f0215 100644
--- a/config/locales/sl.yml
+++ b/config/locales/sl.yml
@@ -68,6 +68,8 @@ sl:
invalid_operator: Neveljaven operater. Dovoljeni operaterji za %{attribute_name} so [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Neveljavna vrednost. Podane vrednosti za %{attribute_name} so neveljavne
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Obdobje poročanja %{since} do %{until}
utc_warning: Ustvarjeno poročilo je v časovnem pasu UTC
diff --git a/config/locales/sq.yml b/config/locales/sq.yml
index 67a8034bb..e5b7dab72 100644
--- a/config/locales/sq.yml
+++ b/config/locales/sq.yml
@@ -68,6 +68,8 @@ sq:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/sr.yml b/config/locales/sr.yml
index 246c8a103..356ad5346 100644
--- a/config/locales/sr.yml
+++ b/config/locales/sr.yml
@@ -68,6 +68,8 @@ sr-Latn:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Period izveštaja %{since} do %{until}
utc_warning: Generisani izveštaj je u UTC vremenskoj zoni
diff --git a/config/locales/sv.yml b/config/locales/sv.yml
index a437a6c9f..a12e6c4b5 100644
--- a/config/locales/sv.yml
+++ b/config/locales/sv.yml
@@ -68,6 +68,8 @@ sv:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Rapporteringsperiod %{since} till %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/ta.yml b/config/locales/ta.yml
index 3d8b775d9..edfda5b74 100644
--- a/config/locales/ta.yml
+++ b/config/locales/ta.yml
@@ -68,6 +68,8 @@ ta:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/th.yml b/config/locales/th.yml
index ef04f4dba..f0d49ef53 100644
--- a/config/locales/th.yml
+++ b/config/locales/th.yml
@@ -68,6 +68,8 @@ th:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/tl.yml b/config/locales/tl.yml
index 5f246d546..d6995fabf 100644
--- a/config/locales/tl.yml
+++ b/config/locales/tl.yml
@@ -68,6 +68,8 @@ tl:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/tr.yml b/config/locales/tr.yml
index 57a4dbd1f..e3e6c0dec 100644
--- a/config/locales/tr.yml
+++ b/config/locales/tr.yml
@@ -68,6 +68,8 @@ tr:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Raporlama aralığı %{since}'dan %{until}'a
utc_warning: Oluşturulan rapor UTC zaman dilimindedir.
diff --git a/config/locales/uk.yml b/config/locales/uk.yml
index 687508a52..c7860385f 100644
--- a/config/locales/uk.yml
+++ b/config/locales/uk.yml
@@ -68,6 +68,8 @@ uk:
invalid_operator: Некоректний оператор. Дозволені оператори для %{attribute_name} є [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Невірне значення. Надані значення для %{attribute_name} є неприпустимі
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Період звіту %{since} до %{until}
utc_warning: Звіт створено в часовій зоні UTC
diff --git a/config/locales/ur.yml b/config/locales/ur.yml
index 771abb0b7..defae7b0d 100644
--- a/config/locales/ur.yml
+++ b/config/locales/ur.yml
@@ -68,6 +68,8 @@ ur:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/ur_IN.yml b/config/locales/ur_IN.yml
index dadbb2ba0..76fdf9707 100644
--- a/config/locales/ur_IN.yml
+++ b/config/locales/ur_IN.yml
@@ -68,6 +68,8 @@ ur:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/locales/vi.yml b/config/locales/vi.yml
index 22054f141..378faded9 100644
--- a/config/locales/vi.yml
+++ b/config/locales/vi.yml
@@ -68,6 +68,8 @@ vi:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Thời gian báo cáo từ %{since} đến %{until}
utc_warning: Báo cáo đã được tạo với múi giờ UTC
diff --git a/config/locales/zh_CN.yml b/config/locales/zh_CN.yml
index 4c672e8a3..6ce090097 100644
--- a/config/locales/zh_CN.yml
+++ b/config/locales/zh_CN.yml
@@ -29,10 +29,10 @@ zh_CN:
invalid: 无效的事件
signup:
disposable_email: 我们不允许可用的电子邮件
- blocked_domain: This domain is not allowed. If you believe this is a mistake, please contact support.
+ blocked_domain: 该域名不被允许。如果您认为这是一个错误,请联系支持团队。
invalid_email: 您输入了一个无效的电子邮件
email_already_exists: '您已经注册了 %{email} 的帐户'
- invalid_params: 'Invalid, please check the signup paramters and try again'
+ invalid_params: '无效,请检查注册参数并重试'
failed: 注册失败
data_import:
data_type:
@@ -63,50 +63,52 @@ zh_CN:
validations:
name: 不应该以符号开头或结尾,它不应该有 < > / \ @ 字符。
custom_filters:
- number_of_records: 已达到上限。每个帐户允许用户自定义过滤器的最大数目为50个。
- invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
- invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
- invalid_query_operator: Query operator must be either "AND" or "OR".
- invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ number_of_records: 已达到上限。每个账户允许用户自定义过滤器的最大数目为50个。
+ invalid_attribute: 无效的属性键 - [%{key}]。键应为 [%{allowed_keys}] 之一或帐户中定义的自定义属性。
+ invalid_operator: 无效的操作符。%{attribute_name} 允许的操作符为 [%{allowed_keys}]。
+ invalid_query_operator: 查询操作符必须为 "AND" 或 "OR"。
+ invalid_value: 无效的值。为 %{attribute_name} 提供的值无效
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
- period: Reporting period %{since} to %{until}
+ period: 报告周期 %{since} 至 %{until}
utc_warning: 生成的报表在 UTC 时区
agent_csv:
agent_name: 客服名称
- conversations_count: Assigned conversations
- avg_first_response_time: Avg first response time
- avg_resolution_time: Avg resolution time
+ conversations_count: 分配的对话数量
+ avg_first_response_time: 平均首次响应时间
+ avg_resolution_time: 平均解决时间
resolution_count: 已解决的数量
- avg_customer_waiting_time: Avg customer waiting time
+ avg_customer_waiting_time: 平均客户等待时间
inbox_csv:
inbox_name: 收件箱名称
inbox_type: 收件箱类型
- conversations_count: No. of conversations
- avg_first_response_time: Avg first response time
- avg_resolution_time: Avg resolution time
+ conversations_count: 对话数量
+ avg_first_response_time: 平均首次响应时间
+ avg_resolution_time: 平均解决时间
label_csv:
label_title: 标签
- conversations_count: No. of conversations
- avg_first_response_time: Avg first response time
- avg_resolution_time: Avg resolution time
+ conversations_count: 对话数量
+ avg_first_response_time: 平均首次响应时间
+ avg_resolution_time: 平均解决时间
team_csv:
team_name: 团队名称
conversations_count: 对话数量
- avg_first_response_time: Avg first response time
- avg_resolution_time: Avg resolution time
+ avg_first_response_time: 平均首次响应时间
+ avg_resolution_time: 平均解决时间
resolution_count: 已解决的数量
- avg_customer_waiting_time: Avg customer waiting time
+ avg_customer_waiting_time: 平均客户等待时间
conversation_traffic_csv:
timezone: 时区
sla_csv:
- conversation_id: Conversation ID
- sla_policy_breached: SLA Policy
- assignee: Assignee
- team: Team
+ conversation_id: 对话ID
+ sla_policy_breached: SLA 政策
+ assignee: 负责人
+ team: 团队
inbox: 收件箱
labels: 标签
- conversation_link: Link to the Conversation
- breached_events: Breached Events
+ conversation_link: 对话链接
+ breached_events: 违反的事件
default_group_by: 天
csat:
headers:
@@ -120,15 +122,15 @@ zh_CN:
recorded_at: 录制日期
notifications:
notification_title:
- conversation_creation: 'A conversation (#%{display_id}) has been created in %{inbox_name}'
- conversation_assignment: 'A conversation (#%{display_id}) has been assigned to you'
- assigned_conversation_new_message: 'A new message is created in conversation (#%{display_id})'
- conversation_mention: 'You have been mentioned in conversation (#%{display_id})'
- sla_missed_first_response: 'SLA target first response missed for conversation (#%{display_id})'
- sla_missed_next_response: 'SLA target next response missed for conversation (#%{display_id})'
- sla_missed_resolution: 'SLA target resolution missed for conversation (#%{display_id})'
- attachment: 'Attachment'
- no_content: 'No content'
+ conversation_creation: '在 %{inbox_name} 中创建了一个对话 (#%{display_id})'
+ conversation_assignment: '对话 (#%{display_id}) 已分配给您'
+ assigned_conversation_new_message: '在对话 (#%{display_id}) 中创建了一条新消息'
+ conversation_mention: '您在对话 (#%{display_id}) 中被提及'
+ sla_missed_first_response: '对话 (#%{display_id}) 的首次响应 SLA 目标未达成'
+ sla_missed_next_response: '对话 (#%{display_id}) 的下次响应 SLA 目标未达成'
+ sla_missed_resolution: '对话 (#%{display_id}) 的解决 SLA 目标未达成'
+ attachment: '附件'
+ no_content: '无内容'
conversations:
messages:
instagram_story_content: '%{story_sender} 会话中提到了你: '
@@ -142,7 +144,7 @@ zh_CN:
contact_resolved: '对话被 %{contact_name} 重新打开'
open: '对话被 %{user_name} 重新打开'
pending: '对话被标记由 %{user_name} 待处理'
- snoozed: 'Conversation was snoozed by %{user_name}'
+ snoozed: '对话被 %{user_name} 暂停'
auto_resolved: '对话被系统标记为已解决, 原因是 %{duration} 天不活跃'
system_auto_open: 由于收到新的消息,系统重新打开了对话。
priority:
@@ -156,16 +158,16 @@ zh_CN:
team:
assigned: '由 %{team_name} 分配给 %{user_name}'
assigned_with_assignee: '由 %{assignee_name} 分配给 %{team_name} 团队的 %{user_name}'
- removed: 'Unassigned from %{team_name} by %{user_name}'
+ removed: '由 %{user_name} 从 %{team_name} 中取消分配'
labels:
added: '%{user_name} 添加 %{labels}'
removed: '%{user_name} 移除 %{labels}'
sla:
- added: '%{user_name} added SLA policy %{sla_name}'
- removed: '%{user_name} removed SLA policy %{sla_name}'
+ added: '%{user_name} 添加了 SLA 策略 %{sla_name}'
+ removed: '%{user_name} 移除了 SLA 策略 %{sla_name}'
muted: '%{user_name} 已将会话静音'
unmuted: '%{user_name} 已将会话取消静音'
- auto_resolution_message: 'Resolving the conversation as it has been inactive for a while. Please start a new conversation if you need further assistance.'
+ auto_resolution_message: '由于对话长时间未活跃,已将其标记为已解决。如果您需要进一步帮助,请开始新的对话。'
templates:
greeting_message_body: '%{account_name} 通常在几小时内回复。'
ways_to_reach_you_message_body: '给团队一个联系您的方法。'
@@ -191,33 +193,33 @@ zh_CN:
delete: '%{contact_name} 在线, 请重试'
integration_apps:
dashboard_apps:
- name: 'Dashboard Apps'
- description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.'
+ name: '仪表板应用'
+ description: '仪表板应用允许您创建和嵌入显示用户信息、订单或支付历史的应用程序,为您的客户支持代理提供更多上下文。'
dyte:
name: 'Dyte'
- description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.'
+ description: 'Dyte 是一个将音频和视频功能集成到您的应用程序中的产品。通过此集成,您的代理可以直接从 Chatwoot 开始与客户的视频/语音通话。'
meeting_name: '%{agent_name} 已开始会议'
slack:
name: 'Slack'
- description: "Integrate Chatwoot with Slack to keep your team in sync. This integration allows you to receive notifications for new conversations and respond to them directly within Slack's interface."
+ description: "将 Chatwoot 与 Slack 集成,以保持团队的同步。此集成允许您接收新对话的通知并直接在 Slack 界面中响应它们。"
webhooks:
name: ''
- description: 'Webhook events provide real-time updates about activities in your Chatwoot account. You can subscribe to your preferred events, and Chatwoot will send you HTTP callbacks with the updates.'
+ description: 'Webhook 事件提供有关 Chatwoot 帐户中活动的实时更新。您可以订阅您喜欢的事件,Chatwoot 将向您发送带有更新的 HTTP 回调。'
dialogflow:
name: 'Dialogflow'
- description: 'Build chatbots with Dialogflow and easily integrate them into your inbox. These bots can handle initial queries before transferring them to a customer service agent.'
+ description: '使用 Dialogflow 构建聊天机器人,并轻松将其集成到您的收件箱中。这些机器人可以在将查询转移给客户服务代理之前处理初始查询。'
google_translate:
name: 'Google 翻译'
- description: "Integrate Google Translate to help agents easily translate customer messages. This integration automatically detects the language and converts it to the agent's or admin's preferred language."
+ description: "集成 Google 翻译以帮助代理轻松翻译客户消息。此集成会自动检测语言并将其转换为代理或管理员的首选语言。"
openai:
name: 'OpenAI'
- description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification.'
+ description: '利用 OpenAI 的大型语言模型功能,例如回复建议、摘要、消息改写、拼写检查和标签分类。'
linear:
name: 'Linear'
- description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
+ description: '直接从对话窗口在 Linear 中创建问题。或者,链接现有的 Linear 问题以简化问题跟踪过程。'
captain:
- copilot_error: 'Please connect an assistant to this inbox to use Copilot'
- copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
+ copilot_error: '请为该收件箱连接一个助手以使用 Copilot'
+ copilot_limit: '您的 Copilot 积分已用完。您可以从计费部分购买更多积分。'
public_portal:
search:
search_placeholder: 搜索文章的标题或正文...
@@ -256,7 +258,7 @@ zh_CN:
slack_unfurl:
fields:
name: 姓名:
- email: Email
+ email: 电子邮件
phone_number: 手机号码
company_name: 公司
inbox_name: 收件箱
@@ -264,10 +266,10 @@ zh_CN:
button: 重新打开会话
time_units:
days:
- other: '%{count} days'
+ other: '%{count} 天'
hours:
- other: '%{count} hours'
+ other: '%{count} 小时'
minutes:
- other: '%{count} minutes'
+ other: '%{count} 分钟'
seconds:
- other: '%{count} seconds'
+ other: '%{count} 秒'
diff --git a/config/locales/zh_TW.yml b/config/locales/zh_TW.yml
index f5648913b..d8ddc1bf0 100644
--- a/config/locales/zh_TW.yml
+++ b/config/locales/zh_TW.yml
@@ -68,6 +68,8 @@ zh_TW:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
+ custom_attribute_definition:
+ key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
diff --git a/config/routes.rb b/config/routes.rb
index 3e2616a46..56704d9aa 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -54,6 +54,7 @@ Rails.application.routes.draw do
end
resources :documents, only: [:index, :show, :create, :destroy]
resources :assistant_responses
+ resources :bulk_actions, only: [:create]
end
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
delete :avatar, on: :member
@@ -118,6 +119,7 @@ Rails.application.routes.draw do
post :custom_attributes
get :attachments
post :copilot
+ get :inbox_assistant
end
end
diff --git a/db/migrate/20250228185548_add_internal_attributes_to_accounts.rb b/db/migrate/20250228185548_add_internal_attributes_to_accounts.rb
new file mode 100644
index 000000000..6912c0825
--- /dev/null
+++ b/db/migrate/20250228185548_add_internal_attributes_to_accounts.rb
@@ -0,0 +1,5 @@
+class AddInternalAttributesToAccounts < ActiveRecord::Migration[7.0]
+ def change
+ add_column :accounts, :internal_attributes, :jsonb, null: false, default: {}
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 6bea44c8b..a68593c68 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.0].define(version: 2025_02_07_040150) do
+ActiveRecord::Schema[7.0].define(version: 2025_02_28_185548) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -57,6 +57,7 @@ ActiveRecord::Schema[7.0].define(version: 2025_02_07_040150) do
t.jsonb "limits", default: {}
t.jsonb "custom_attributes", default: {}
t.integer "status", default: 0
+ t.jsonb "internal_attributes", default: {}, null: false
t.index ["status"], name: "index_accounts_on_status"
end
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb
new file mode 100644
index 000000000..3130a68cc
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb
@@ -0,0 +1,51 @@
+class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController
+ before_action :current_account
+ before_action -> { check_authorization(Captain::Assistant) }
+ before_action :validate_params
+ before_action :type_matches?
+
+ MODEL_TYPE = ['AssistantResponse'].freeze
+
+ def create
+ @responses = process_bulk_action
+ end
+
+ private
+
+ def validate_params
+ return if params[:type].present? && params[:ids].present? && params[:fields].present?
+
+ render json: { success: false }, status: :unprocessable_entity
+ end
+
+ def type_matches?
+ return if MODEL_TYPE.include?(params[:type])
+
+ render json: { success: false }, status: :unprocessable_entity
+ end
+
+ def process_bulk_action
+ case params[:type]
+ when 'AssistantResponse'
+ handle_assistant_responses
+ end
+ end
+
+ def handle_assistant_responses
+ responses = Current.account.captain_assistant_responses.where(id: params[:ids])
+ return unless responses.exists?
+
+ case params[:fields][:status]
+ when 'approve'
+ responses.pending.update(status: 'approved')
+ responses
+ when 'delete'
+ responses.destroy_all
+ []
+ end
+ end
+
+ def permitted_params
+ params.permit(:type, ids: [], fields: [:status])
+ end
+end
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb
index e9113f4d0..a9dcb0b67 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb
@@ -5,9 +5,17 @@ module Enterprise::Api::V1::Accounts::ConversationsController
end
def copilot
- assistant = @conversation.inbox.captain_assistant
+ # First try to get the user's preferred assistant from UI settings or from the request
+ assistant_id = copilot_params[:assistant_id] || current_user.ui_settings&.dig('preferred_captain_assistant_id')
+
+ # Find the assistant either by ID or from inbox
+ assistant = if assistant_id.present?
+ Captain::Assistant.find_by(id: assistant_id, account_id: Current.account.id)
+ else
+ @conversation.inbox.captain_assistant
+ end
+
return render json: { message: I18n.t('captain.copilot_error') } unless assistant
- return render json: { message: I18n.t('captain.copilot_limit') } unless @conversation.inbox.captain_active?
response = Captain::Copilot::ChatService.new(
assistant,
@@ -18,6 +26,16 @@ module Enterprise::Api::V1::Accounts::ConversationsController
render json: { message: response['response'] }
end
+ def inbox_assistant
+ assistant = @conversation.inbox.captain_assistant
+
+ if assistant
+ render json: { assistant: { id: assistant.id, name: assistant.name } }
+ else
+ render json: { assistant: nil }
+ end
+ end
+
def permitted_update_params
super.merge(params.permit(:sla_policy_id))
end
diff --git a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb
index 21617aa83..1cbf2c6ca 100644
--- a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb
+++ b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb
@@ -33,6 +33,6 @@ module Enterprise::SuperAdmin::AppConfigsController
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS BLOCKED_EMAIL_DOMAINS
- CAPTAIN_CLOUD_PLAN_LIMITS]
+ CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL]
end
end
diff --git a/enterprise/app/helpers/super_admin/features.yml b/enterprise/app/helpers/super_admin/features.yml
index 506b95832..5aa5fc160 100644
--- a/enterprise/app/helpers/super_admin/features.yml
+++ b/enterprise/app/helpers/super_admin/features.yml
@@ -79,3 +79,9 @@ microsoft:
enabled: true
icon: 'icon-microsoft'
config_key: 'microsoft'
+linear:
+ name: 'Linear'
+ description: 'Configuration for setting up Linear'
+ enabled: true
+ icon: 'icon-linear'
+ config_key: 'linear'
diff --git a/enterprise/app/jobs/internal/account_analysis_job.rb b/enterprise/app/jobs/internal/account_analysis_job.rb
new file mode 100644
index 000000000..49ddaa2b5
--- /dev/null
+++ b/enterprise/app/jobs/internal/account_analysis_job.rb
@@ -0,0 +1,9 @@
+class Internal::AccountAnalysisJob < ApplicationJob
+ queue_as :low
+
+ def perform(account)
+ return if GlobalConfig.get_value('DEPLOYMENT_ENV') != 'cloud'
+
+ Internal::AccountAnalysis::ThreatAnalyserService.new(account).perform
+ end
+end
diff --git a/enterprise/app/services/captain/copilot/chat_service.rb b/enterprise/app/services/captain/copilot/chat_service.rb
index 019136540..3d53d7b5c 100644
--- a/enterprise/app/services/captain/copilot/chat_service.rb
+++ b/enterprise/app/services/captain/copilot/chat_service.rb
@@ -1,6 +1,6 @@
require 'openai'
-class Captain::Copilot::ChatService < Captain::Llm::BaseOpenAiService
+class Captain::Copilot::ChatService < Llm::BaseOpenAiService
include Captain::ChatHelper
def initialize(assistant, config)
diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb
index ef5ae2c0f..3d2b0da44 100644
--- a/enterprise/app/services/captain/llm/assistant_chat_service.rb
+++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb
@@ -1,6 +1,6 @@
require 'openai'
-class Captain::Llm::AssistantChatService < Captain::Llm::BaseOpenAiService
+class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
include Captain::ChatHelper
def initialize(assistant: nil)
diff --git a/enterprise/app/services/captain/llm/contact_attributes_service.rb b/enterprise/app/services/captain/llm/contact_attributes_service.rb
index e942a2a56..e7976bf27 100644
--- a/enterprise/app/services/captain/llm/contact_attributes_service.rb
+++ b/enterprise/app/services/captain/llm/contact_attributes_service.rb
@@ -1,4 +1,4 @@
-class Captain::Llm::ContactAttributesService < Captain::Llm::BaseOpenAiService
+class Captain::Llm::ContactAttributesService < Llm::BaseOpenAiService
def initialize(assistant, conversation)
super()
@assistant = assistant
diff --git a/enterprise/app/services/captain/llm/contact_notes_service.rb b/enterprise/app/services/captain/llm/contact_notes_service.rb
index 245e0d96a..75aee412f 100644
--- a/enterprise/app/services/captain/llm/contact_notes_service.rb
+++ b/enterprise/app/services/captain/llm/contact_notes_service.rb
@@ -1,4 +1,4 @@
-class Captain::Llm::ContactNotesService < Captain::Llm::BaseOpenAiService
+class Captain::Llm::ContactNotesService < Llm::BaseOpenAiService
def initialize(assistant, conversation)
super()
@assistant = assistant
diff --git a/enterprise/app/services/captain/llm/conversation_faq_service.rb b/enterprise/app/services/captain/llm/conversation_faq_service.rb
index ea33493f2..682818f73 100644
--- a/enterprise/app/services/captain/llm/conversation_faq_service.rb
+++ b/enterprise/app/services/captain/llm/conversation_faq_service.rb
@@ -1,4 +1,4 @@
-class Captain::Llm::ConversationFaqService < Captain::Llm::BaseOpenAiService
+class Captain::Llm::ConversationFaqService < Llm::BaseOpenAiService
DISTANCE_THRESHOLD = 0.3
def initialize(assistant, conversation)
diff --git a/enterprise/app/services/captain/llm/embedding_service.rb b/enterprise/app/services/captain/llm/embedding_service.rb
index ca2ebdc1b..74f3079bf 100644
--- a/enterprise/app/services/captain/llm/embedding_service.rb
+++ b/enterprise/app/services/captain/llm/embedding_service.rb
@@ -1,6 +1,6 @@
require 'openai'
-class Captain::Llm::EmbeddingService < Captain::Llm::BaseOpenAiService
+class Captain::Llm::EmbeddingService < Llm::BaseOpenAiService
class EmbeddingsError < StandardError; end
DEFAULT_MODEL = 'text-embedding-3-small'.freeze
diff --git a/enterprise/app/services/captain/llm/faq_generator_service.rb b/enterprise/app/services/captain/llm/faq_generator_service.rb
index 6462f5c47..c26ffa2a3 100644
--- a/enterprise/app/services/captain/llm/faq_generator_service.rb
+++ b/enterprise/app/services/captain/llm/faq_generator_service.rb
@@ -1,4 +1,4 @@
-class Captain::Llm::FaqGeneratorService < Captain::Llm::BaseOpenAiService
+class Captain::Llm::FaqGeneratorService < Llm::BaseOpenAiService
def initialize(content)
super()
@content = content
diff --git a/enterprise/app/services/captain/tools/firecrawl_service.rb b/enterprise/app/services/captain/tools/firecrawl_service.rb
index a32f1d630..397c3ad63 100644
--- a/enterprise/app/services/captain/tools/firecrawl_service.rb
+++ b/enterprise/app/services/captain/tools/firecrawl_service.rb
@@ -1,7 +1,7 @@
class Captain::Tools::FirecrawlService
def initialize
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
- raise 'Missing API key' if @api_key.nil?
+ raise 'Missing API key' if @api_key.empty?
end
def perform(url, webhook_url, crawl_limit = 10)
diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
index 6ed05653c..220147a89 100644
--- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
+++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
@@ -66,7 +66,7 @@ class Enterprise::Billing::HandleStripeEventService
end
def features_to_update
- %w[help_center campaigns team_management channel_twitter channel_facebook channel_email captain_integration]
+ %w[inbound_emails help_center campaigns team_management channel_twitter channel_facebook channel_email captain_integration]
end
def subscription
diff --git a/enterprise/app/services/internal/account_analysis/account_updater_service.rb b/enterprise/app/services/internal/account_analysis/account_updater_service.rb
new file mode 100644
index 000000000..feb54881b
--- /dev/null
+++ b/enterprise/app/services/internal/account_analysis/account_updater_service.rb
@@ -0,0 +1,54 @@
+class Internal::AccountAnalysis::AccountUpdaterService
+ def initialize(account)
+ @account = account
+ end
+
+ def update_with_analysis(analysis, error_message = nil)
+ if error_message
+ save_error(error_message)
+ notify_on_discord
+ return
+ end
+
+ save_analysis_results(analysis)
+ flag_account_if_needed(analysis)
+ end
+
+ private
+
+ def save_error(error_message)
+ @account.internal_attributes['security_flagged'] = true
+ @account.internal_attributes['security_flag_reason'] = "Error: #{error_message}"
+ @account.save
+ end
+
+ def save_analysis_results(analysis)
+ @account.internal_attributes['last_threat_scan_at'] = Time.current
+ @account.internal_attributes['last_threat_scan_level'] = analysis['threat_level']
+ @account.internal_attributes['last_threat_scan_summary'] = analysis['threat_summary']
+ @account.internal_attributes['last_threat_scan_recommendation'] = analysis['recommendation']
+ @account.save!
+ end
+
+ def flag_account_if_needed(analysis)
+ return if analysis['threat_level'] == 'none'
+
+ if %w[high medium].include?(analysis['threat_level']) ||
+ analysis['illegal_activities_detected'] == true ||
+ analysis['recommendation'] == 'block'
+
+ @account.internal_attributes['security_flagged'] = true
+ @account.internal_attributes['security_flag_reason'] = "Threat detected: #{analysis['threat_summary']}"
+ @account.save!
+
+ Rails.logger.info("Flagging account #{@account.id} due to threat level: #{analysis['threat_level']}")
+ end
+
+ notify_on_discord
+ end
+
+ def notify_on_discord
+ Rails.logger.info("Account #{@account.id} has been flagged for security review")
+ Internal::AccountAnalysis::DiscordNotifierService.new.notify_flagged_account(@account)
+ end
+end
diff --git a/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb b/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb
new file mode 100644
index 000000000..e88c46091
--- /dev/null
+++ b/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb
@@ -0,0 +1,73 @@
+class Internal::AccountAnalysis::ContentEvaluatorService < Llm::BaseOpenAiService
+ def initialize
+ super()
+
+ @model = 'gpt-4o-mini'.freeze
+ end
+
+ def evaluate(content)
+ return default_evaluation if content.blank?
+
+ begin
+ response = send_to_llm(content)
+ evaluation = handle_response(response)
+ log_evaluation_results(evaluation)
+ evaluation
+ rescue StandardError => e
+ handle_evaluation_error(e)
+ end
+ end
+
+ private
+
+ def send_to_llm(content)
+ Rails.logger.info('Sending content to LLM for security evaluation')
+ @client.chat(
+ parameters: {
+ model: @model,
+ messages: llm_messages(content),
+ response_format: { type: 'json_object' }
+ }
+ )
+ end
+
+ def handle_response(response)
+ return default_evaluation if response.nil?
+
+ parsed = JSON.parse(response.dig('choices', 0, 'message', 'content').strip)
+
+ {
+ 'threat_level' => parsed['threat_level'] || 'unknown',
+ 'threat_summary' => parsed['threat_summary'] || 'No threat summary provided',
+ 'detected_threats' => parsed['detected_threats'] || [],
+ 'illegal_activities_detected' => parsed['illegal_activities_detected'] || false,
+ 'recommendation' => parsed['recommendation'] || 'review'
+ }
+ end
+
+ def default_evaluation(error_type = nil)
+ {
+ 'threat_level' => 'unknown',
+ 'threat_summary' => 'Failed to complete content evaluation',
+ 'detected_threats' => error_type ? [error_type] : [],
+ 'illegal_activities_detected' => false,
+ 'recommendation' => 'review'
+ }
+ end
+
+ def log_evaluation_results(evaluation)
+ Rails.logger.info("LLM evaluation - Level: #{evaluation['threat_level']}, Illegal activities: #{evaluation['illegal_activities_detected']}")
+ end
+
+ def handle_evaluation_error(error)
+ Rails.logger.error("Error evaluating content: #{error.message}")
+ default_evaluation('evaluation_failure')
+ end
+
+ def llm_messages(content)
+ [
+ { role: 'system', content: 'You are a security analysis system that evaluates content for potential threats and scams.' },
+ { role: 'user', content: Internal::AccountAnalysis::PromptsService.threat_analyser(content.to_s[0...10_000]) }
+ ]
+ end
+end
diff --git a/enterprise/app/services/internal/account_analysis/discord_notifier_service.rb b/enterprise/app/services/internal/account_analysis/discord_notifier_service.rb
new file mode 100644
index 000000000..01ad09b36
--- /dev/null
+++ b/enterprise/app/services/internal/account_analysis/discord_notifier_service.rb
@@ -0,0 +1,47 @@
+class Internal::AccountAnalysis::DiscordNotifierService
+ def notify_flagged_account(account)
+ if webhook_url.blank?
+ Rails.logger.error('Cannot send Discord notification: No webhook URL configured')
+ return
+ end
+
+ HTTParty.post(
+ webhook_url,
+ body: build_message(account).to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ Rails.logger.info("Discord notification sent for flagged account #{account.id}")
+ rescue StandardError => e
+ Rails.logger.error("Error sending Discord notification: #{e.message}")
+ end
+
+ private
+
+ def build_message(account)
+ analysis = account.internal_attributes
+ user = account.users.order(id: :asc).first
+
+ content = <<~MESSAGE
+ ---
+ An account has been flagged in our security system with the following details:
+
+ 🆔 **Account Details:**
+ Account ID: #{account.id}
+ User Email: #{user&.email || 'N/A'}
+ Threat Level: #{analysis['last_threat_scan_level']}
+
+ 🔎 **System Recommendation:** #{analysis['last_threat_scan_recommendation']}
+ #{analysis['illegal_activities_detected'] ? '⚠️ Potential illegal activities detected' : 'No illegal activities detected'}
+
+ 📝 **Findings:**
+ #{analysis['last_threat_scan_summary']}
+ MESSAGE
+
+ { content: content }
+ end
+
+ def webhook_url
+ @webhook_url ||= InstallationConfig.find_by(name: 'ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL')&.value
+ end
+end
diff --git a/enterprise/app/services/internal/account_analysis/prompts_service.rb b/enterprise/app/services/internal/account_analysis/prompts_service.rb
new file mode 100644
index 000000000..3bd49f334
--- /dev/null
+++ b/enterprise/app/services/internal/account_analysis/prompts_service.rb
@@ -0,0 +1,31 @@
+class Internal::AccountAnalysis::PromptsService
+ class << self
+ def threat_analyser(content)
+ <<~PROMPT
+ Analyze the following website content for potential security threats, scams, or illegal activities.
+
+ Focus on identifying:
+ 1. Phishing attempts
+ 2. Fraudulent business practices
+ 3. Malware distribution
+ 4. Illegal product/service offerings
+ 5. Money laundering indicators
+ 6. Identity theft schemes
+
+ Always classify websites under construction or without content to be a medium.
+
+ Website content:
+ #{content}
+
+ Provide your analysis in the following JSON format:
+ {
+ "threat_level": "none|low|medium|high",
+ "threat_summary": "Brief summary of findings",
+ "detected_threats": ["threat1", "threat2"],
+ "illegal_activities_detected": true|false,
+ "recommendation": "approve|review|block"
+ }
+ PROMPT
+ end
+ end
+end
diff --git a/enterprise/app/services/internal/account_analysis/threat_analyser_service.rb b/enterprise/app/services/internal/account_analysis/threat_analyser_service.rb
new file mode 100644
index 000000000..2ec4845c4
--- /dev/null
+++ b/enterprise/app/services/internal/account_analysis/threat_analyser_service.rb
@@ -0,0 +1,43 @@
+class Internal::AccountAnalysis::ThreatAnalyserService
+ def initialize(account)
+ @account = account
+ @user = account.users.order(id: :asc).first
+ @domain = extract_domain_from_email(@user&.email)
+ end
+
+ def perform
+ if @domain.blank?
+ Rails.logger.info("Skipping threat analysis for account #{@account.id}: No domain found")
+ return
+ end
+
+ website_content = Internal::AccountAnalysis::WebsiteScraperService.new(@domain).perform
+ if website_content.blank?
+ Rails.logger.info("Skipping threat analysis for account #{@account.id}: No website content found")
+ Internal::AccountAnalysis::AccountUpdaterService.new(@account).update_with_analysis(nil, 'Scraping error: No content found')
+ return
+ end
+
+ content = <<~MESSAGE
+ Domain: #{@domain}
+ Content: #{website_content}
+ MESSAGE
+ threat_analysis = Internal::AccountAnalysis::ContentEvaluatorService.new.evaluate(content)
+ Rails.logger.info("Completed threat analysis: level=#{threat_analysis['threat_level']} for account-id: #{@account.id}")
+
+ Internal::AccountAnalysis::AccountUpdaterService.new(@account).update_with_analysis(threat_analysis)
+
+ threat_analysis
+ end
+
+ private
+
+ def extract_domain_from_email(email)
+ return nil if email.blank?
+
+ email.split('@').last
+ rescue StandardError => e
+ Rails.logger.error("Error extracting domain from email #{email}: #{e.message}")
+ nil
+ end
+end
diff --git a/enterprise/app/services/internal/account_analysis/website_scraper_service.rb b/enterprise/app/services/internal/account_analysis/website_scraper_service.rb
new file mode 100644
index 000000000..d03ef8049
--- /dev/null
+++ b/enterprise/app/services/internal/account_analysis/website_scraper_service.rb
@@ -0,0 +1,32 @@
+class Internal::AccountAnalysis::WebsiteScraperService
+ def initialize(domain)
+ @domain = domain
+ end
+
+ def perform
+ return nil if @domain.blank?
+
+ Rails.logger.info("Scraping website: #{external_link}")
+
+ begin
+ response = HTTParty.get(external_link, follow_redirects: true)
+ response.to_s
+ rescue StandardError => e
+ Rails.logger.error("Error scraping website for domain #{@domain}: #{e.message}")
+ nil
+ end
+ end
+
+ private
+
+ def external_link
+ sanitize_url(@domain)
+ end
+
+ def sanitize_url(domain)
+ url = domain
+ url = "https://#{domain}" unless domain.start_with?('http://', 'https://')
+ Rails.logger.info("Sanitized URL: #{url}")
+ url
+ end
+end
diff --git a/enterprise/app/services/captain/llm/base_open_ai_service.rb b/enterprise/app/services/llm/base_open_ai_service.rb
similarity index 93%
rename from enterprise/app/services/captain/llm/base_open_ai_service.rb
rename to enterprise/app/services/llm/base_open_ai_service.rb
index 7b542c0f2..1f229f182 100644
--- a/enterprise/app/services/captain/llm/base_open_ai_service.rb
+++ b/enterprise/app/services/llm/base_open_ai_service.rb
@@ -1,4 +1,4 @@
-class Captain::Llm::BaseOpenAiService
+class Llm::BaseOpenAiService
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
def initialize
diff --git a/enterprise/app/views/api/v1/accounts/captain/bulk_actions/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/bulk_actions/create.json.jbuilder
new file mode 100644
index 000000000..4ba4b1ad6
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/bulk_actions/create.json.jbuilder
@@ -0,0 +1,3 @@
+json.array! @responses do |response|
+ json.partial! 'api/v1/models/captain/assistant_response', formats: [:json], resource: response
+end
diff --git a/lib/integrations/slack/send_on_slack_service.rb b/lib/integrations/slack/send_on_slack_service.rb
index c1454b869..e68dd81af 100644
--- a/lib/integrations/slack/send_on_slack_service.rb
+++ b/lib/integrations/slack/send_on_slack_service.rb
@@ -18,6 +18,10 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
slack_client.chat_unfurl(
event
)
+ # You may wonder why we're not requesting reauthorization and disabling hooks when scope errors occur.
+ # Since link unfurling is just a nice-to-have feature that doesn't affect core functionality, we will silently ignore these errors.
+ rescue Slack::Web::Api::Errors::MissingScope => e
+ Rails.logger.warn "Slack: Missing scope error: #{e.message}"
end
private
diff --git a/package.json b/package.json
index 624b77686..935667071 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
- "version": "4.0.2",
+ "version": "4.0.3",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -92,7 +92,7 @@
"vue-chartjs": "5.3.1",
"vue-datepicker-next": "^1.0.3",
"vue-dompurify-html": "^5.1.0",
- "vue-i18n": "9.14.2",
+ "vue-i18n": "9.14.3",
"vue-letter": "^0.2.1",
"vue-multiselect": "3.1.0",
"vue-router": "~4.4.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1696ded6d..3df1bb7e6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -197,8 +197,8 @@ importers:
specifier: ^5.1.0
version: 5.1.0(vue@3.5.12(typescript@5.6.2))
vue-i18n:
- specifier: 9.14.2
- version: 9.14.2(vue@3.5.12(typescript@5.6.2))
+ specifier: 9.14.3
+ version: 9.14.3(vue@3.5.12(typescript@5.6.2))
vue-letter:
specifier: ^0.2.1
version: 0.2.1
@@ -903,6 +903,10 @@ packages:
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
engines: {node: '>= 16'}
+ '@intlify/core-base@9.14.3':
+ resolution: {integrity: sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==}
+ engines: {node: '>= 16'}
+
'@intlify/eslint-plugin-vue-i18n@3.2.0':
resolution: {integrity: sha512-TOIrD4tJE48WMyVIB8bNeQJJPYo1Prpqnm9Xpn1UZmcqlELhm8hmP8QyJnkgesfbG7hyiX/kvo63W7ClEQmhpg==}
engines: {node: '>=18.0.0'}
@@ -913,10 +917,18 @@ packages:
resolution: {integrity: sha512-YsKKuV4Qv4wrLNsvgWbTf0E40uRv+Qiw1BeLQ0LAxifQuhiMe+hfTIzOMdWj/ZpnTDj4RSZtkXjJM7JDiiB5LQ==}
engines: {node: '>= 16'}
+ '@intlify/message-compiler@9.14.3':
+ resolution: {integrity: sha512-ANwC226BQdd+MpJ36rOYkChSESfPwu3Ss2Faw0RHTOknYLoHTX6V6e/JjIKVDMbzs0/H/df/rO6yU0SPiWHqNg==}
+ engines: {node: '>= 16'}
+
'@intlify/shared@9.14.2':
resolution: {integrity: sha512-uRAHAxYPeF+G5DBIboKpPgC/Waecd4Jz8ihtkpJQD5ycb5PwXp0k/+hBGl5dAjwF7w+l74kz/PKA8r8OK//RUw==}
engines: {node: '>= 16'}
+ '@intlify/shared@9.14.3':
+ resolution: {integrity: sha512-hJXz9LA5VG7qNE00t50bdzDv8Z4q9fpcL81wj4y4duKavrv0KM8YNLTwXNEFINHjTsfrG9TXvPuEjVaAvZ7yWg==}
+ engines: {node: '>= 16'}
+
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -1973,6 +1985,11 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ acorn@8.14.1:
+ resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
activestorage@5.2.8:
resolution: {integrity: sha512-bueFOxBGIAUdrjbLyBZ8Xlkcecy8vr05sCk5VV37BbFi+RehPoEjfvKX3iYYPY7RFVhl+L43W9/ZbN3xNNLPtQ==}
@@ -4896,8 +4913,8 @@ packages:
peerDependencies:
eslint: '>=6.0.0'
- vue-i18n@9.14.2:
- resolution: {integrity: sha512-JK9Pm80OqssGJU2Y6F7DcM8RFHqVG4WkuCqOZTVsXkEzZME7ABejAUqUdA931zEBedc4thBgSUWxeQh4uocJAQ==}
+ vue-i18n@9.14.3:
+ resolution: {integrity: sha512-C+E0KE8ihKjdYCQx8oUkXX+8tBItrYNMnGJuzEPevBARQFUN2tKez6ZVOvBrWH0+KT5wEk3vOWjNk7ygb2u9ig==}
engines: {node: '>= 16'}
peerDependencies:
vue: ^3.0.0
@@ -5714,6 +5731,11 @@ snapshots:
'@intlify/message-compiler': 9.14.2
'@intlify/shared': 9.14.2
+ '@intlify/core-base@9.14.3':
+ dependencies:
+ '@intlify/message-compiler': 9.14.3
+ '@intlify/shared': 9.14.3
+
'@intlify/eslint-plugin-vue-i18n@3.2.0(eslint@8.57.0)':
dependencies:
'@eslint/eslintrc': 3.2.0
@@ -5744,8 +5766,15 @@ snapshots:
'@intlify/shared': 9.14.2
source-map-js: 1.2.1
+ '@intlify/message-compiler@9.14.3':
+ dependencies:
+ '@intlify/shared': 9.14.3
+ source-map-js: 1.2.1
+
'@intlify/shared@9.14.2': {}
+ '@intlify/shared@9.14.3': {}
+
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -6999,6 +7028,9 @@ snapshots:
acorn@8.14.0: {}
+ acorn@8.14.1:
+ optional: true
+
activestorage@5.2.8:
dependencies:
spark-md5: 3.0.2
@@ -9997,7 +10029,7 @@ snapshots:
terser@5.33.0:
dependencies:
'@jridgewell/source-map': 0.3.6
- acorn: 8.14.0
+ acorn: 8.14.1
commander: 2.20.3
source-map-support: 0.5.21
optional: true
@@ -10351,10 +10383,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
- vue-i18n@9.14.2(vue@3.5.12(typescript@5.6.2)):
+ vue-i18n@9.14.3(vue@3.5.12(typescript@5.6.2)):
dependencies:
- '@intlify/core-base': 9.14.2
- '@intlify/shared': 9.14.2
+ '@intlify/core-base': 9.14.3
+ '@intlify/shared': 9.14.3
'@vue/devtools-api': 6.6.4
vue: 3.5.12(typescript@5.6.2)
diff --git a/spec/controllers/api/v1/accounts/custom_attribute_definitions_controller_spec.rb b/spec/controllers/api/v1/accounts/custom_attribute_definitions_controller_spec.rb
index d434381cd..c5be392b6 100644
--- a/spec/controllers/api/v1/accounts/custom_attribute_definitions_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/custom_attribute_definitions_controller_spec.rb
@@ -89,6 +89,30 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
json_response = response.parsed_body
expect(json_response['attribute_key']).to eq 'developer_id'
end
+
+ context 'when creating with a conflicting attribute_key' do
+ let(:standard_key) { CustomAttributeDefinition::STANDARD_ATTRIBUTES[:conversation].first }
+ let(:conflicting_payload) do
+ {
+ custom_attribute_definition: {
+ attribute_display_name: 'Conflicting Key',
+ attribute_key: standard_key,
+ attribute_model: 'conversation_attribute',
+ attribute_display_type: 'text'
+ }
+ }
+ end
+
+ it 'returns error for conflicting key' do
+ post "/api/v1/accounts/#{account.id}/custom_attribute_definitions",
+ headers: user.create_new_auth_token,
+ params: conflicting_payload
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ json_response = response.parsed_body
+ expect(json_response['message']).to include('The provided key is not allowed as it might conflict with default attributes.')
+ end
+ end
end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb
new file mode 100644
index 000000000..968649085
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb
@@ -0,0 +1,143 @@
+require 'rails_helper'
+
+RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let!(:pending_responses) do
+ create_list(
+ :captain_assistant_response,
+ 2,
+ assistant: assistant,
+ account: account,
+ status: 'pending'
+ )
+ end
+
+ def json_response
+ JSON.parse(response.body, symbolize_names: true)
+ end
+
+ describe 'POST /api/v1/accounts/:account_id/captain/bulk_actions' do
+ context 'when approving responses' do
+ let(:valid_params) do
+ {
+ type: 'AssistantResponse',
+ ids: pending_responses.map(&:id),
+ fields: { status: 'approve' }
+ }
+ end
+
+ it 'approves the responses and returns the updated records' do
+ post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
+ params: valid_params,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(json_response).to be_an(Array)
+ expect(json_response.length).to eq(2)
+
+ # Verify responses were approved
+ pending_responses.each do |response|
+ expect(response.reload.status).to eq('approved')
+ end
+ end
+ end
+
+ context 'when deleting responses' do
+ let(:delete_params) do
+ {
+ type: 'AssistantResponse',
+ ids: pending_responses.map(&:id),
+ fields: { status: 'delete' }
+ }
+ end
+
+ it 'deletes the responses and returns an empty array' do
+ expect do
+ post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
+ params: delete_params,
+ headers: admin.create_new_auth_token,
+ as: :json
+ end.to change(Captain::AssistantResponse, :count).by(-2)
+
+ expect(response).to have_http_status(:ok)
+ expect(json_response).to eq([])
+
+ # Verify responses were deleted
+ pending_responses.each do |response|
+ expect { response.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+ end
+
+ context 'with invalid type' do
+ let(:invalid_params) do
+ {
+ type: 'InvalidType',
+ ids: pending_responses.map(&:id),
+ fields: { status: 'approve' }
+ }
+ end
+
+ it 'returns unprocessable entity status' do
+ post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
+ params: invalid_params,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(json_response[:success]).to be(false)
+
+ # Verify no changes were made
+ pending_responses.each do |response|
+ expect(response.reload.status).to eq('pending')
+ end
+ end
+ end
+
+ context 'with missing parameters' do
+ let(:missing_params) do
+ {
+ type: 'AssistantResponse',
+ fields: { status: 'approve' }
+ }
+ end
+
+ it 'returns unprocessable entity status' do
+ post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
+ params: missing_params,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(json_response[:success]).to be(false)
+
+ # Verify no changes were made
+ pending_responses.each do |response|
+ expect(response.reload.status).to eq('pending')
+ end
+ end
+ end
+
+ context 'with unauthorized user' do
+ let(:unauthorized_user) { create(:user, account: create(:account)) }
+
+ it 'returns unauthorized status' do
+ post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
+ params: { type: 'AssistantResponse', ids: [1], fields: { status: 'approve' } },
+ headers: unauthorized_user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+
+ # Verify no changes were made
+ pending_responses.each do |response|
+ expect(response.reload.status).to eq('pending')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
index 8b358a655..d6563b163 100644
--- a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
+++ b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
@@ -32,6 +32,16 @@ RSpec.describe Captain::Tools::FirecrawlService do
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: nil)
end
+ it 'raises an error' do
+ expect { described_class.new }.to raise_error(NoMethodError)
+ end
+ end
+
+ context 'when API key is empty' do
+ before do
+ InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: '')
+ end
+
it 'raises an error' do
expect { described_class.new }.to raise_error('Missing API key')
end
diff --git a/spec/enterprise/services/internal/account_analysis/account_updater_service_spec.rb b/spec/enterprise/services/internal/account_analysis/account_updater_service_spec.rb
new file mode 100644
index 000000000..37da7d070
--- /dev/null
+++ b/spec/enterprise/services/internal/account_analysis/account_updater_service_spec.rb
@@ -0,0 +1,130 @@
+require 'rails_helper'
+
+RSpec.describe Internal::AccountAnalysis::AccountUpdaterService do
+ let(:account) { create(:account) }
+ let(:service) { described_class.new(account) }
+ let(:discord_notifier) { instance_double(Internal::AccountAnalysis::DiscordNotifierService, notify_flagged_account: true) }
+
+ before do
+ allow(Internal::AccountAnalysis::DiscordNotifierService).to receive(:new).and_return(discord_notifier)
+ allow(Rails.logger).to receive(:info)
+ end
+
+ describe '#update_with_analysis' do
+ context 'when error_message is provided' do
+ it 'saves the error and notifies Discord' do
+ service.update_with_analysis({}, 'Analysis failed')
+
+ expect(account.internal_attributes['security_flagged']).to be true
+ expect(account.internal_attributes['security_flag_reason']).to eq('Error: Analysis failed')
+ expect(discord_notifier).to have_received(:notify_flagged_account).with(account)
+ end
+ end
+
+ context 'when analysis is successful' do
+ let(:analysis) do
+ {
+ 'threat_level' => 'none',
+ 'threat_summary' => 'No threats detected',
+ 'recommendation' => 'allow'
+ }
+ end
+
+ it 'saves the analysis results' do
+ allow(Time).to receive(:current).and_return('2023-01-01 12:00:00')
+
+ service.update_with_analysis(analysis)
+
+ expect(account.internal_attributes['last_threat_scan_at']).to eq('2023-01-01 12:00:00')
+ expect(account.internal_attributes['last_threat_scan_level']).to eq('none')
+ expect(account.internal_attributes['last_threat_scan_summary']).to eq('No threats detected')
+ expect(account.internal_attributes['last_threat_scan_recommendation']).to eq('allow')
+ end
+
+ it 'does not flag the account when threat level is none' do
+ service.update_with_analysis(analysis)
+
+ expect(account.internal_attributes).not_to include('security_flagged')
+ expect(discord_notifier).not_to have_received(:notify_flagged_account)
+ end
+ end
+
+ context 'when analysis detects high threat level' do
+ let(:analysis) do
+ {
+ 'threat_level' => 'high',
+ 'threat_summary' => 'Suspicious activity detected',
+ 'recommendation' => 'review',
+ 'illegal_activities_detected' => false
+ }
+ end
+
+ it 'flags the account and notifies Discord' do
+ service.update_with_analysis(analysis)
+
+ expect(account.internal_attributes['security_flagged']).to be true
+ expect(account.internal_attributes['security_flag_reason']).to eq('Threat detected: Suspicious activity detected')
+ expect(discord_notifier).to have_received(:notify_flagged_account).with(account)
+ expect(Rails.logger).to have_received(:info).with("Flagging account #{account.id} due to threat level: high")
+ expect(Rails.logger).to have_received(:info).with("Account #{account.id} has been flagged for security review")
+ end
+ end
+
+ context 'when analysis detects medium threat level' do
+ let(:analysis) do
+ {
+ 'threat_level' => 'medium',
+ 'threat_summary' => 'Potential issues found',
+ 'recommendation' => 'review',
+ 'illegal_activities_detected' => false
+ }
+ end
+
+ it 'flags the account and notifies Discord' do
+ service.update_with_analysis(analysis)
+
+ expect(account.internal_attributes['security_flagged']).to be true
+ expect(account.internal_attributes['security_flag_reason']).to eq('Threat detected: Potential issues found')
+ expect(discord_notifier).to have_received(:notify_flagged_account).with(account)
+ end
+ end
+
+ context 'when analysis detects illegal activities' do
+ let(:analysis) do
+ {
+ 'threat_level' => 'low',
+ 'threat_summary' => 'Minor issues found',
+ 'recommendation' => 'review',
+ 'illegal_activities_detected' => true
+ }
+ end
+
+ it 'flags the account and notifies Discord' do
+ service.update_with_analysis(analysis)
+
+ expect(account.internal_attributes['security_flagged']).to be true
+ expect(account.internal_attributes['security_flag_reason']).to eq('Threat detected: Minor issues found')
+ expect(discord_notifier).to have_received(:notify_flagged_account).with(account)
+ end
+ end
+
+ context 'when analysis recommends blocking' do
+ let(:analysis) do
+ {
+ 'threat_level' => 'low',
+ 'threat_summary' => 'Minor issues found',
+ 'recommendation' => 'block',
+ 'illegal_activities_detected' => false
+ }
+ end
+
+ it 'flags the account and notifies Discord' do
+ service.update_with_analysis(analysis)
+
+ expect(account.internal_attributes['security_flagged']).to be true
+ expect(account.internal_attributes['security_flag_reason']).to eq('Threat detected: Minor issues found')
+ expect(discord_notifier).to have_received(:notify_flagged_account).with(account)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/internal/account_analysis/content_evaluator_service_spec.rb b/spec/enterprise/services/internal/account_analysis/content_evaluator_service_spec.rb
new file mode 100644
index 000000000..d7bf26c35
--- /dev/null
+++ b/spec/enterprise/services/internal/account_analysis/content_evaluator_service_spec.rb
@@ -0,0 +1,111 @@
+require 'rails_helper'
+
+RSpec.describe Internal::AccountAnalysis::ContentEvaluatorService do
+ let(:service) { described_class.new }
+ let(:content) { 'This is some test content' }
+
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ end
+
+ describe '#evaluate' do
+ context 'when content is present' do
+ let(:llm_response) do
+ {
+ 'choices' => [
+ {
+ 'message' => {
+ 'content' => {
+ 'threat_level' => 'low',
+ 'threat_summary' => 'No significant threats detected',
+ 'detected_threats' => ['minor_concern'],
+ 'illegal_activities_detected' => false,
+ 'recommendation' => 'approve'
+ }.to_json
+ }
+ }
+ ]
+ }
+ end
+
+ before do
+ allow(service).to receive(:send_to_llm).and_return(llm_response)
+ allow(Rails.logger).to receive(:info)
+ end
+
+ it 'returns the evaluation results' do
+ result = service.evaluate(content)
+
+ expect(result).to include(
+ 'threat_level' => 'low',
+ 'threat_summary' => 'No significant threats detected',
+ 'detected_threats' => ['minor_concern'],
+ 'illegal_activities_detected' => false,
+ 'recommendation' => 'approve'
+ )
+ end
+
+ it 'logs the evaluation results' do
+ service.evaluate(content)
+
+ expect(Rails.logger).to have_received(:info).with('LLM evaluation - Level: low, Illegal activities: false')
+ end
+ end
+
+ context 'when content is blank' do
+ let(:blank_content) { '' }
+
+ it 'returns the default evaluation without calling the LLM' do
+ expect(service).not_to receive(:send_to_llm)
+
+ result = service.evaluate(blank_content)
+
+ expect(result).to include(
+ 'threat_level' => 'unknown',
+ 'threat_summary' => 'Failed to complete content evaluation',
+ 'detected_threats' => [],
+ 'illegal_activities_detected' => false,
+ 'recommendation' => 'review'
+ )
+ end
+ end
+
+ context 'when LLM response is nil' do
+ before do
+ allow(service).to receive(:send_to_llm).and_return(nil)
+ end
+
+ it 'returns the default evaluation' do
+ result = service.evaluate(content)
+
+ expect(result).to include(
+ 'threat_level' => 'unknown',
+ 'threat_summary' => 'Failed to complete content evaluation',
+ 'detected_threats' => [],
+ 'illegal_activities_detected' => false,
+ 'recommendation' => 'review'
+ )
+ end
+ end
+
+ context 'when error occurs during evaluation' do
+ before do
+ allow(service).to receive(:send_to_llm).and_raise(StandardError.new('Test error'))
+ allow(Rails.logger).to receive(:error)
+ end
+
+ it 'logs the error and returns default evaluation with error type' do
+ result = service.evaluate(content)
+
+ expect(Rails.logger).to have_received(:error).with('Error evaluating content: Test error')
+ expect(result).to include(
+ 'threat_level' => 'unknown',
+ 'threat_summary' => 'Failed to complete content evaluation',
+ 'detected_threats' => ['evaluation_failure'],
+ 'illegal_activities_detected' => false,
+ 'recommendation' => 'review'
+ )
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/internal/account_analysis/discord_notifier_service_spec.rb b/spec/enterprise/services/internal/account_analysis/discord_notifier_service_spec.rb
new file mode 100644
index 000000000..6591185d9
--- /dev/null
+++ b/spec/enterprise/services/internal/account_analysis/discord_notifier_service_spec.rb
@@ -0,0 +1,73 @@
+require 'rails_helper'
+
+RSpec.describe Internal::AccountAnalysis::DiscordNotifierService do
+ let(:service) { described_class.new }
+ let(:webhook_url) { 'https://discord.com/api/webhooks/123456789/some-token' }
+ let(:account) do
+ create(
+ :account,
+ internal_attributes: {
+ 'last_threat_scan_level' => 'high',
+ 'last_threat_scan_recommendation' => 'review',
+ 'illegal_activities_detected' => true,
+ 'last_threat_scan_summary' => 'Suspicious activity detected'
+ }
+ )
+ end
+ let!(:user) { create(:user, account: account) }
+
+ before do
+ allow(Rails.logger).to receive(:info)
+ allow(Rails.logger).to receive(:error)
+ end
+
+ describe '#notify_flagged_account' do
+ context 'when webhook URL is configured' do
+ before do
+ create(:installation_config, name: 'ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL', value: webhook_url)
+ stub_request(:post, webhook_url).to_return(status: 200)
+ end
+
+ it 'sends notification to Discord webhook' do
+ service.notify_flagged_account(account)
+ expect(WebMock).to have_requested(:post, webhook_url)
+ .with(
+ body: hash_including(
+ content: include(
+ "Account ID: #{account.id}",
+ "User Email: #{user.email}",
+ 'Threat Level: high',
+ '**System Recommendation:** review',
+ '⚠️ Potential illegal activities detected',
+ 'Suspicious activity detected'
+ )
+ )
+ )
+ end
+ end
+
+ context 'when webhook URL is not configured' do
+ it 'logs error and does not make HTTP request' do
+ service.notify_flagged_account(account)
+
+ expect(Rails.logger).to have_received(:error)
+ .with('Cannot send Discord notification: No webhook URL configured')
+ expect(WebMock).not_to have_requested(:post, webhook_url)
+ end
+ end
+
+ context 'when HTTP request fails' do
+ before do
+ create(:installation_config, name: 'ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL', value: webhook_url)
+ stub_request(:post, webhook_url).to_raise(StandardError.new('Connection failed'))
+ end
+
+ it 'catches exception and logs error' do
+ service.notify_flagged_account(account)
+
+ expect(Rails.logger).to have_received(:error)
+ .with('Error sending Discord notification: Connection failed')
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/internal/account_analysis/threat_analyser_service_spec.rb b/spec/enterprise/services/internal/account_analysis/threat_analyser_service_spec.rb
new file mode 100644
index 000000000..65d106215
--- /dev/null
+++ b/spec/enterprise/services/internal/account_analysis/threat_analyser_service_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Internal::AccountAnalysis::ThreatAnalyserService do
+ subject { described_class.new(account) }
+
+ let(:account) { create(:account) }
+ let(:user) { create(:user, email: 'test@example.com', account: account) }
+ let(:website_scraper) { instance_double(Internal::AccountAnalysis::WebsiteScraperService) }
+ let(:content_evaluator) { instance_double(Internal::AccountAnalysis::ContentEvaluatorService) }
+ let(:account_updater) { instance_double(Internal::AccountAnalysis::AccountUpdaterService) }
+ let(:website_content) { 'This is the website content' }
+ let(:threat_analysis) { { 'threat_level' => 'medium' } }
+
+ before do
+ user
+
+ allow(Internal::AccountAnalysis::WebsiteScraperService).to receive(:new).with('example.com').and_return(website_scraper)
+ allow(Internal::AccountAnalysis::ContentEvaluatorService).to receive(:new).and_return(content_evaluator)
+ allow(Internal::AccountAnalysis::AccountUpdaterService).to receive(:new).with(account).and_return(account_updater)
+ end
+
+ describe '#perform' do
+ before do
+ allow(website_scraper).to receive(:perform).and_return(website_content)
+ allow(content_evaluator).to receive(:evaluate).and_return(threat_analysis)
+ allow(account_updater).to receive(:update_with_analysis)
+ allow(Rails.logger).to receive(:info)
+ end
+
+ it 'performs threat analysis and updates the account' do
+ expected_content = <<~MESSAGE
+ Domain: example.com
+ Content: This is the website content
+ MESSAGE
+
+ expect(website_scraper).to receive(:perform)
+ expect(content_evaluator).to receive(:evaluate).with(expected_content)
+ expect(account_updater).to receive(:update_with_analysis).with(threat_analysis)
+ expect(Rails.logger).to receive(:info).with("Completed threat analysis: level=medium for account-id: #{account.id}")
+
+ result = subject.perform
+ expect(result).to eq(threat_analysis)
+ end
+
+ context 'when website content is blank' do
+ before do
+ allow(website_scraper).to receive(:perform).and_return(nil)
+ end
+
+ it 'logs info and updates account with error' do
+ expect(Rails.logger).to receive(:info).with("Skipping threat analysis for account #{account.id}: No website content found")
+ expect(account_updater).to receive(:update_with_analysis).with(nil, 'Scraping error: No content found')
+ expect(content_evaluator).not_to receive(:evaluate)
+
+ result = subject.perform
+ expect(result).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/internal/account_analysis/website_scraper_service_spec.rb b/spec/enterprise/services/internal/account_analysis/website_scraper_service_spec.rb
new file mode 100644
index 000000000..6d218e44d
--- /dev/null
+++ b/spec/enterprise/services/internal/account_analysis/website_scraper_service_spec.rb
@@ -0,0 +1,45 @@
+require 'rails_helper'
+
+RSpec.describe Internal::AccountAnalysis::WebsiteScraperService do
+ describe '#perform' do
+ let(:service) { described_class.new(domain) }
+ let(:html_content) { 'This is sample website content' }
+
+ before do
+ allow(Rails.logger).to receive(:info)
+ allow(Rails.logger).to receive(:error)
+ end
+
+ context 'when domain is nil' do
+ let(:domain) { nil }
+
+ it 'returns nil' do
+ expect(service.perform).to be_nil
+ end
+ end
+
+ context 'when domain is present' do
+ let(:domain) { 'example.com' }
+
+ before do
+ allow(HTTParty).to receive(:get).and_return(html_content)
+ end
+
+ it 'returns the stripped and normalized content' do
+ expect(service.perform).to eq(html_content)
+ end
+ end
+
+ context 'when an error occurs' do
+ let(:domain) { 'example.com' }
+
+ before do
+ allow(HTTParty).to receive(:get).and_raise(StandardError.new('Error'))
+ end
+
+ it 'returns nil' do
+ expect(service.perform).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/fixtures/files/email_with_inline_pdf.eml b/spec/fixtures/files/email_with_inline_pdf.eml
new file mode 100644
index 000000000..cea726f5b
--- /dev/null
+++ b/spec/fixtures/files/email_with_inline_pdf.eml
@@ -0,0 +1,261 @@
+From: user@icloud.com
+Content-Type: multipart/mixed;
+ boundary="Apple-Mail=_3D8DCB41-DA29-4339-A8F6-FC6D71948931"
+Mime-Version: 1.0 (Mac OS X Mail 16.0 \(3826.200.121\))
+Subject: Sample PDF
+Message-Id: <827-314S13-SSS2310@example.com>
+Date: Thu, 27 Feb 2025 18:17:31 -0800
+To: User
+X-Mailer: Apple Mail (2.3826.200.121)
+X-Proofpoint-ORIG-GUID: v-XMwNZMeEI5CGyn18czwpSt3GHLgcEC
+
+--Apple-Mail=_3D8DCB41-DA29-4339-A8F6-FC6D71948931
+Content-Transfer-Encoding: 7bit
+Content-Type: text/plain;
+ charset=us-ascii
+
+Here is an example mail with inline PDF
+
+
+--Apple-Mail=_3D8DCB41-DA29-4339-A8F6-FC6D71948931
+Content-Disposition: inline;
+ filename=dummy.pdf
+Content-Type: application/pdf;
+ x-unix-mode=0644;
+ name="dummy.pdf"
+Content-Transfer-Encoding: base64
+
+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
+USRuPC6uZTI6hUkHjGBBoU5ADiZ+I8AZj6cuEx8zjpm4eFQITuTkV/uewQl+EA3PcXwkUimfl/nI
+xJJC8fwSnKisjfV4PhV9JKegWvwUQR1YRV8Y650p5QAOFx4uP1w3VjhWPlZnFD+08BCQtofEURqp
+fEihoCMw4wiAwW6K/XQB9N0fycuXiscE4HB0OwLyN17ow6526L8jA6fPOjagSw1I8cGZgMTwAYoR
+xyYdoRmmkM4iJ0OSRSr8P1jbNhMKZW5kc3RyZWFtCmVuZG9iagoKNiAwIG9iagoxMDgyNQplbmRv
+YmoKCjcgMCBvYmoKPDwvVHlwZS9Gb250RGVzY3JpcHRvci9Gb250TmFtZS9CQUFBQUErQXJpYWwt
+Qm9sZE1UCi9GbGFncyA0Ci9Gb250QkJveFstNjI3IC0zNzYgMjAwMCAxMDExXS9JdGFsaWNBbmds
+ZSAwCi9Bc2NlbnQgOTA1Ci9EZXNjZW50IDIxMQovQ2FwSGVpZ2h0IDEwMTAKL1N0ZW1WIDgwCi9G
+b250RmlsZTIgNSAwIFI+PgplbmRvYmoKCjggMCBvYmoKPDwvTGVuZ3RoIDI3Mi9GaWx0ZXIvRmxh
+dGVEZWNvZGU+PgpzdHJlYW0KeJxdkc9uhCAQxu88BcftYQNadbuJMdm62cRD/6S2D6AwWpKKBPHg
+2xcG2yY9QH7DzDf5ZmB1c220cuzVzqIFRwelpYVlXq0A2sOoNElSKpVwe4S3mDpDmNe22+JgavQw
+lyVhbz63OLvRw0XOPdwR9mIlWKVHevioWx+3qzFfMIF2lJOqohIG3+epM8/dBAxVx0b6tHLb0Uv+
+Ct43AzTFOIlWxCxhMZ0A2+kRSMl5RcvbrSKg5b9cskv6QXx21pcmvpTzLKs8p8inPPA9cnENnMX3
+c+AcOeWBC+Qc+RT7FIEfohb5HBm1l8h14MfIOZrc3QS7YZ8/a6BitdavAJeOs4eplYbffzGzCSo8
+3zuVhO0KZW5kc3RyZWFtCmVuZG9iagoKOSAwIG9iago8PC9UeXBlL0ZvbnQvU3VidHlwZS9UcnVl
+VHlwZS9CYXNlRm9udC9CQUFBQUErQXJpYWwtQm9sZE1UCi9GaXJzdENoYXIgMAovTGFzdENoYXIg
+MTEKL1dpZHRoc1s3NTAgNzIyIDYxMCA4ODkgNTU2IDI3NyA2NjYgNjEwIDMzMyAyNzcgMjc3IDU1
+NiBdCi9Gb250RGVzY3JpcHRvciA3IDAgUgovVG9Vbmljb2RlIDggMCBSCj4+CmVuZG9iagoKMTAg
+MCBvYmoKPDwKL0YxIDkgMCBSCj4+CmVuZG9iagoKMTEgMCBvYmoKPDwvRm9udCAxMCAwIFIKL1By
+b2NTZXRbL1BERi9UZXh0XT4+CmVuZG9iagoKMSAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDQg
+MCBSL1Jlc291cmNlcyAxMSAwIFIvTWVkaWFCb3hbMCAwIDU5NSA4NDJdL0dyb3VwPDwvUy9UcmFu
+c3BhcmVuY3kvQ1MvRGV2aWNlUkdCL0kgdHJ1ZT4+L0NvbnRlbnRzIDIgMCBSPj4KZW5kb2JqCgox
+MiAwIG9iago8PC9Db3VudCAxL0ZpcnN0IDEzIDAgUi9MYXN0IDEzIDAgUgo+PgplbmRvYmoKCjEz
+IDAgb2JqCjw8L1RpdGxlPEZFRkYwMDQ0MDA3NTAwNkQwMDZEMDA3OTAwMjAwMDUwMDA0NDAwNDYw
+MDIwMDA2NjAwNjkwMDZDMDA2NT4KL0Rlc3RbMSAwIFIvWFlaIDU2LjcgNzczLjMgMF0vUGFyZW50
+IDEyIDAgUj4+CmVuZG9iagoKNCAwIG9iago8PC9UeXBlL1BhZ2VzCi9SZXNvdXJjZXMgMTEgMCBS
+Ci9NZWRpYUJveFsgMCAwIDU5NSA4NDIgXQovS2lkc1sgMSAwIFIgXQovQ291bnQgMT4+CmVuZG9i
+agoKMTQgMCBvYmoKPDwvVHlwZS9DYXRhbG9nL1BhZ2VzIDQgMCBSCi9PdXRsaW5lcyAxMiAwIFIK
+Pj4KZW5kb2JqCgoxNSAwIG9iago8PC9BdXRob3I8RkVGRjAwNDUwMDc2MDA2MTAwNkUwMDY3MDA2
+NTAwNkMwMDZGMDA3MzAwMjAwMDU2MDA2QzAwNjEwMDYzMDA2ODAwNkYwMDY3MDA2OTAwNjEwMDZF
+MDA2RTAwNjkwMDczPgovQ3JlYXRvcjxGRUZGMDA1NzAwNzIwMDY5MDA3NDAwNjUwMDcyPgovUHJv
+ZHVjZXI8RkVGRjAwNEYwMDcwMDA2NTAwNkUwMDRGMDA2NjAwNjYwMDY5MDA2MzAwNjUwMDJFMDA2
+RjAwNzIwMDY3MDAyMDAwMzIwMDJFMDAzMT4KL0NyZWF0aW9uRGF0ZShEOjIwMDcwMjIzMTc1NjM3
+KzAyJzAwJyk+PgplbmRvYmoKCnhyZWYKMCAxNgowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMTE5
+OTcgMDAwMDAgbiAKMDAwMDAwMDAxOSAwMDAwMCBuIAowMDAwMDAwMjI0IDAwMDAwIG4gCjAwMDAw
+MTIzMzAgMDAwMDAgbiAKMDAwMDAwMDI0NCAwMDAwMCBuIAowMDAwMDExMTU0IDAwMDAwIG4gCjAw
+MDAwMTExNzYgMDAwMDAgbiAKMDAwMDAxMTM2OCAwMDAwMCBuIAowMDAwMDExNzA5IDAwMDAwIG4g
+CjAwMDAwMTE5MTAgMDAwMDAgbiAKMDAwMDAxMTk0MyAwMDAwMCBuIAowMDAwMDEyMTQwIDAwMDAw
+IG4gCjAwMDAwMTIxOTYgMDAwMDAgbiAKMDAwMDAxMjQyOSAwMDAwMCBuIAowMDAwMDEyNDk0IDAw
+MDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSAxNi9Sb290IDE0IDAgUgovSW5mbyAxNSAwIFIKL0lEIFsg
+PEY3RDc3QjNEMjJCOUY5MjgyOUQ0OUZGNUQ3OEI4RjI4Pgo8RjdENzdCM0QyMkI5RjkyODI5RDQ5
+RkY1RDc4QjhGMjg+IF0KPj4Kc3RhcnR4cmVmCjEyNzg3CiUlRU9GCg==
+--Apple-Mail=_3D8DCB41-DA29-4339-A8F6-FC6D71948931--
diff --git a/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb b/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb
index ae4f540d4..18685a649 100644
--- a/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb
+++ b/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb
@@ -2,11 +2,19 @@ require 'rails_helper'
RSpec.describe Inboxes::FetchImapEmailInboxesJob do
let(:account) { create(:account) }
+ let(:suspended_account) { create(:account, status: 'suspended') }
+
let(:imap_email_channel) do
- create(:channel_email, imap_enabled: true, imap_address: 'imap.gmail.com', imap_port: 993, imap_login: 'imap@gmail.com',
- imap_password: 'password', account: account)
+ create(:channel_email, imap_enabled: true, account: account)
+ end
+
+ let(:imap_email_channel_suspended) do
+ create(:channel_email, imap_enabled: true, account: suspended_account)
+ end
+
+ let(:disabled_imap_channel) do
+ create(:channel_email, imap_enabled: false, account: account)
end
- let(:email_inbox) { create(:inbox, channel: imap_email_channel, account: account) }
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
@@ -14,9 +22,26 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do
end
context 'when called' do
- it 'fetch all the email channels' do
+ it 'fetches emails only for active accounts with imap enabled' do
+ # Should call perform_later only once for the active, imap-enabled inbox
expect(Inboxes::FetchImapEmailsJob).to receive(:perform_later).with(imap_email_channel).once
+ # Should not call for suspended account or disabled IMAP channels
+ expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(imap_email_channel_suspended)
+ expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(disabled_imap_channel)
+
+ described_class.perform_now
+ end
+
+ it 'skips suspended accounts' do
+ expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(imap_email_channel_suspended)
+
+ described_class.perform_now
+ end
+
+ it 'skips disabled imap channels' do
+ expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(disabled_imap_channel)
+
described_class.perform_now
end
end
diff --git a/spec/lib/integrations/slack/send_on_slack_service_spec.rb b/spec/lib/integrations/slack/send_on_slack_service_spec.rb
index 684f2bc54..848a683b4 100644
--- a/spec/lib/integrations/slack/send_on_slack_service_spec.rb
+++ b/spec/lib/integrations/slack/send_on_slack_service_spec.rb
@@ -269,6 +269,19 @@ describe Integrations::Slack::SendOnSlackService do
expect(hook).to be_disabled
expect(hook).to have_received(:prompt_reauthorization!)
end
+
+ it 'logs MissingScope error during link unfurl' do
+ unflur_payload = { channel: 'channel', ts: 'timestamp', unfurls: {} }
+ error = Slack::Web::Api::Errors::MissingScope.new('Missing required scope')
+
+ expect(slack_client).to receive(:chat_unfurl)
+ .with(unflur_payload)
+ .and_raise(error)
+
+ expect(Rails.logger).to receive(:warn).with('Slack: Missing scope error: Missing required scope')
+
+ link_builder.link_unfurl(unflur_payload)
+ end
end
context 'when message contains mentions' do
diff --git a/spec/mailboxes/imap/imap_mailbox_spec.rb b/spec/mailboxes/imap/imap_mailbox_spec.rb
index fdea5b9d4..cc72be18b 100644
--- a/spec/mailboxes/imap/imap_mailbox_spec.rb
+++ b/spec/mailboxes/imap/imap_mailbox_spec.rb
@@ -59,6 +59,22 @@ RSpec.describe Imap::ImapMailbox do
end
end
+ context 'when the email has inline attachments other than images' do
+ let(:inbound_mail) { create_inbound_email_from_fixture('email_with_inline_pdf.eml') }
+
+ it 'creates a conversation and a message with non-image files as regular attachments' do
+ expect do
+ class_instance.process(inbound_mail.mail, channel)
+ end.to change(Conversation, :count).by(1)
+
+ last_message = conversation.messages.last
+ expect(last_message.attachments.count).to be 1
+
+ attachment = last_message.attachments.first
+ expect(attachment.file.blob.filename.to_s).to eq 'dummy.pdf'
+ end
+ end
+
context 'when the email has 15 or more attachments' do
let(:inbound_mail) { create_inbound_email_from_fixture('multiple_attachments.eml') }
diff --git a/spec/models/concerns/switch_locale_spec.rb b/spec/models/concerns/switch_locale_spec.rb
new file mode 100644
index 000000000..98628b92c
--- /dev/null
+++ b/spec/models/concerns/switch_locale_spec.rb
@@ -0,0 +1,74 @@
+require 'rails_helper'
+
+RSpec.describe 'SwitchLocale Concern', type: :controller do
+ controller(ApplicationController) do
+ include SwitchLocale
+
+ def index
+ switch_locale { render plain: I18n.locale }
+ end
+
+ def account_locale
+ switch_locale_using_account_locale { render plain: I18n.locale }
+ end
+ end
+
+ let(:account) { create(:account, locale: 'es') }
+ let(:portal) { create(:portal, custom_domain: 'custom.example.com', config: { default_locale: 'fr_FR' }) }
+
+ describe '#switch_locale' do
+ context 'when locale is provided in params' do
+ it 'sets locale from params' do
+ get :index, params: { locale: 'es' }
+ expect(response.body).to eq('es')
+ end
+
+ it 'falls back to default locale if invalid' do
+ get :index, params: { locale: 'invalid' }
+ expect(response.body).to eq('en')
+ end
+ end
+
+ context 'when request is from custom domain' do
+ before { request.host = portal.custom_domain }
+
+ it 'sets locale from portal' do
+ get :index
+ expect(response.body).to eq('fr')
+ end
+
+ it 'overrides portal locale with param' do
+ get :index, params: { locale: 'es' }
+ expect(response.body).to eq('es')
+ end
+ end
+
+ context 'when locale is not provided anywhere' do
+ it 'sets locale from environment variable' do
+ with_modified_env(DEFAULT_LOCALE: 'de_DE') do
+ get :index
+ expect(response.body).to eq('de')
+ end
+ end
+
+ it 'falls back to default locale if env locale invalid' do
+ with_modified_env(DEFAULT_LOCALE: 'invalid') do
+ get :index
+ expect(response.body).to eq('en')
+ end
+ end
+ end
+ end
+
+ describe '#switch_locale_using_account_locale' do
+ before do
+ routes.draw { get 'account_locale' => 'anonymous#account_locale' }
+ end
+
+ it 'sets locale from account' do
+ controller.instance_variable_set(:@current_account, account)
+ get :account_locale
+ expect(response.body).to eq('es')
+ end
+ end
+end
diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb
index 45e5be143..53878c780 100644
--- a/spec/services/conversations/filter_service_spec.rb
+++ b/spec/services/conversations/filter_service_spec.rb
@@ -77,6 +77,63 @@ describe Conversations::FilterService do
expect(result[:count][:all_count]).to be conversations.count
end
+ it 'filter conversations by priority' do
+ conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high)
+ params[:payload] = [
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: ['high'],
+ query_operator: nil,
+ custom_attribute_type: ''
+ }.with_indifferent_access
+ ]
+ result = filter_service.new(params, user_1).perform
+ expect(result[:conversations].length).to eq 1
+ expect(result[:conversations][0][:id]).to eq conversation.id
+ end
+
+ it 'filter conversations by multiple priority values' do
+ high_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high)
+ urgent_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :urgent)
+ create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :low)
+
+ params[:payload] = [
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: %w[high urgent],
+ query_operator: nil,
+ custom_attribute_type: ''
+ }.with_indifferent_access
+ ]
+ result = filter_service.new(params, user_1).perform
+ expect(result[:conversations].length).to eq 2
+ expect(result[:conversations].pluck(:id)).to include(high_priority.id, urgent_priority.id)
+ end
+
+ it 'filter conversations with not_equal_to priority operator' do
+ create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high)
+ create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :urgent)
+ low_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :low)
+ medium_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :medium)
+
+ params[:payload] = [
+ {
+ attribute_key: 'priority',
+ filter_operator: 'not_equal_to',
+ values: %w[high urgent],
+ query_operator: nil,
+ custom_attribute_type: ''
+ }.with_indifferent_access
+ ]
+ result = filter_service.new(params, user_1).perform
+
+ # Only include conversations with medium and low priority, excluding high and urgent
+ expect(result[:conversations].length).to eq 2
+ expect(result[:conversations].pluck(:id)).to include(low_priority.id, medium_priority.id)
+ end
+
it 'filter conversations by additional_attributes and status with pagination' do
params[:payload] = payload
params[:page] = 2
diff --git a/spec/services/facebook/send_on_facebook_service_spec.rb b/spec/services/facebook/send_on_facebook_service_spec.rb
index 2fcea8b72..cddf527cb 100644
--- a/spec/services/facebook/send_on_facebook_service_spec.rb
+++ b/spec/services/facebook/send_on_facebook_service_spec.rb
@@ -117,5 +117,54 @@ describe Facebook::SendOnFacebookService do
expect(message.reload.status).to eq('failed')
end
end
+
+ context 'when deliver_message fails' do
+ let(:message) { create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation) }
+
+ it 'handles JSON parse errors' do
+ allow(bot).to receive(:deliver).and_return('invalid_json')
+ described_class.new(message: message).perform
+
+ expect(message.reload.status).to eq('failed')
+ expect(message.external_error).to eq('Facebook was unable to process this request')
+ end
+
+ it 'handles timeout errors' do
+ allow(bot).to receive(:deliver).and_raise(Net::OpenTimeout)
+ described_class.new(message: message).perform
+
+ expect(message.reload.status).to eq('failed')
+ expect(message.external_error).to eq('Request timed out, please try again later')
+ end
+
+ it 'handles facebook error with code' do
+ error_response = {
+ error: {
+ message: 'Invalid OAuth access token.',
+ type: 'OAuthException',
+ code: 190,
+ fbtrace_id: 'BLBz/WZt8dN'
+ }
+ }.to_json
+ allow(bot).to receive(:deliver).and_return(error_response)
+
+ described_class.new(message: message).perform
+
+ expect(message.reload.status).to eq('failed')
+ expect(message.external_error).to eq('190 - Invalid OAuth access token.')
+ end
+
+ it 'handles successful delivery with message_id' do
+ success_response = {
+ message_id: 'mid.1456970487936:c34767dfe57ee6e339'
+ }.to_json
+ allow(bot).to receive(:deliver).and_return(success_response)
+
+ described_class.new(message: message).perform
+
+ expect(message.reload.source_id).to eq('mid.1456970487936:c34767dfe57ee6e339')
+ expect(message.status).not_to eq('failed')
+ end
+ end
end
end
diff --git a/spec/services/instagram/send_on_instagram_service_spec.rb b/spec/services/instagram/send_on_instagram_service_spec.rb
index 8900171f3..3e88b1cdd 100644
--- a/spec/services/instagram/send_on_instagram_service_spec.rb
+++ b/spec/services/instagram/send_on_instagram_service_spec.rb
@@ -4,7 +4,7 @@ describe Instagram::SendOnInstagramService do
subject(:send_reply_service) { described_class.new(message: message) }
before do
- stub_request(:post, /graph.facebook.com/)
+ stub_request(:post, /graph\.facebook\.com/)
create(:message, message_type: :incoming, inbox: instagram_inbox, account: account, conversation: conversation)
end
@@ -15,16 +15,49 @@ describe Instagram::SendOnInstagramService do
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: instagram_inbox) }
let(:conversation) { create(:conversation, contact: contact, inbox: instagram_inbox, contact_inbox: contact_inbox) }
let(:response) { double }
+ let(:mock_response) do
+ instance_double(
+ HTTParty::Response,
+ :success? => true,
+ :body => { message_id: 'anyrandommessageid1234567890' }.to_json,
+ :parsed_response => { 'message_id' => 'anyrandommessageid1234567890' }
+ )
+ end
+
+ let(:error_body) do
+ {
+ 'error' => {
+ 'message' => 'The Instagram account is restricted.',
+ 'type' => 'OAuthException',
+ 'code' => 400,
+ 'fbtrace_id' => 'anyrandomfbtraceid1234567890'
+ }
+ }
+ end
+
+ let(:error_response) do
+ instance_double(
+ HTTParty::Response,
+ :success? => false,
+ :body => error_body.to_json,
+ :parsed_response => error_body
+ )
+ end
+
+ let(:response_with_error) do
+ instance_double(
+ HTTParty::Response,
+ :success? => true,
+ :body => error_body.to_json,
+ :parsed_response => error_body
+ )
+ end
describe '#perform' do
context 'with reply' do
before do
allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token')
- allow(HTTParty).to receive(:post).and_return(
- {
- 'message_id': 'anyrandommessageid1234567890'
- }
- )
+ allow(HTTParty).to receive(:post).and_return(mock_response)
end
context 'without message_tag HUMAN_AGENT' do
@@ -35,22 +68,8 @@ describe Instagram::SendOnInstagramService do
it 'if message is sent from chatwoot and is outgoing' do
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
- allow(HTTParty).to receive(:post).with(
- {
- recipient: { id: contact.get_source_id(instagram_inbox.id) },
- message: {
- text: message.content
- }
- }
- ).and_return(
- {
- 'message_id': 'anyrandommessageid1234567890'
- }
- )
-
response = described_class.new(message: message).perform
-
- expect(response).to eq({ message_id: 'anyrandommessageid1234567890' })
+ expect(response['message_id']).to eq('anyrandommessageid1234567890')
end
it 'if message is sent from chatwoot and is outgoing with multiple attachments' do
@@ -78,22 +97,13 @@ describe Instagram::SendOnInstagramService do
message.save!
response = described_class.new(message: message).perform
- expect(response).to eq({ message_id: 'anyrandommessageid1234567890' })
+ expect(response['message_id']).to eq('anyrandommessageid1234567890')
end
it 'if message sent from chatwoot is failed' do
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
- allow(HTTParty).to receive(:post).and_return(
- {
- 'error': {
- 'message': 'The Instagram account is restricted.',
- 'type': 'OAuthException',
- 'code': 400,
- 'fbtrace_id': 'anyrandomfbtraceid1234567890'
- }
- }
- )
+ allow(HTTParty).to receive(:post).and_return(response_with_error)
described_class.new(message: message).perform
expect(HTTParty).to have_received(:post)
expect(message.reload.status).to eq('failed')
@@ -129,5 +139,39 @@ describe Instagram::SendOnInstagramService do
end
end
end
+
+ context 'when handling errors' do
+ before do
+ allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token')
+ end
+
+ it 'handles HTTP errors' do
+ message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
+ allow(HTTParty).to receive(:post).and_return(error_response)
+
+ described_class.new(message: message).perform
+
+ expect(message.reload.status).to eq('failed')
+ expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.')
+ end
+
+ it 'handles response errors' do
+ message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
+
+ error_response = instance_double(
+ HTTParty::Response,
+ success?: true,
+ body: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }.to_json,
+ parsed_response: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }
+ )
+
+ allow(HTTParty).to receive(:post).and_return(error_response)
+
+ described_class.new(message: message).perform
+
+ expect(message.reload.status).to eq('failed')
+ expect(message.reload.external_error).to eq('100 - Invalid message format')
+ end
+ end
end
end
diff --git a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb
index 7c2b79b8d..134d69fee 100644
--- a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb
+++ b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb
@@ -263,4 +263,56 @@ describe Whatsapp::Providers::WhatsappCloudService do
end
end
end
+
+ describe '#handle_error' do
+ let(:error_message) { 'Invalid message format' }
+ let(:error_response) do
+ {
+ 'error' => {
+ 'message' => error_message,
+ 'code' => 100
+ }
+ }
+ end
+
+ let(:error_response_object) do
+ instance_double(
+ HTTParty::Response,
+ body: error_response.to_json,
+ parsed_response: error_response
+ )
+ end
+
+ before do
+ allow(Rails.logger).to receive(:error)
+ end
+
+ context 'when there is a message' do
+ it 'logs error and updates message status' do
+ service.instance_variable_set(:@message, message)
+ service.send(:handle_error, error_response_object)
+
+ expect(message.reload.status).to eq('failed')
+ expect(message.reload.external_error).to eq(error_message)
+ end
+ end
+
+ context 'when error message is blank' do
+ let(:error_response_object) do
+ instance_double(
+ HTTParty::Response,
+ body: '{}',
+ parsed_response: {}
+ )
+ end
+
+ it 'logs error but does not update message' do
+ service.instance_variable_set(:@message, message)
+ service.send(:handle_error, error_response_object)
+
+ expect(message.reload.status).not_to eq('failed')
+ expect(message.reload.external_error).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb
index 0df9316f5..88f3f7740 100644
--- a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb
+++ b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb
@@ -12,13 +12,30 @@ describe Whatsapp::SendOnWhatsappService do
describe '#perform' do
before do
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
+ stub_request(:post, 'https://waba.360dialog.io/v1/messages')
end
context 'when a valid message' do
- let(:whatsapp_request) { double }
+ let(:whatsapp_request) { instance_double(HTTParty::Response) }
let!(:whatsapp_channel) { create(:channel_whatsapp, sync_templates: false) }
let!(:contact_inbox) { create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: '123456789') }
let!(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: whatsapp_channel.inbox) }
+ let(:api_key) { 'test_key' }
+ let(:headers) { { 'D360-API-KEY' => api_key, 'Content-Type' => 'application/json' } }
+ let(:template_body) do
+ {
+ to: '123456789',
+ template: {
+ name: 'sample_shipping_confirmation',
+ namespace: '23423423_2342423_324234234_2343224',
+ language: { 'policy': 'deterministic', 'code': 'en_US' },
+ components: [{ 'type': 'body', 'parameters': [{ 'type': 'text', 'text': '3' }] }]
+ },
+ type: 'template'
+ }
+ end
+
+ let(:success_response) { { 'messages' => [{ 'id' => '123456789' }] }.to_json }
it 'calls channel.send_message when with in 24 hour limit' do
# to handle the case of 24 hour window limit.
@@ -26,14 +43,14 @@ describe Whatsapp::SendOnWhatsappService do
conversation: conversation)
message = create(:message, message_type: :outgoing, content: 'test',
conversation: conversation)
- allow(HTTParty).to receive(:post).and_return(whatsapp_request)
- allow(whatsapp_request).to receive(:success?).and_return(true)
- allow(whatsapp_request).to receive(:[]).with('messages').and_return([{ 'id' => '123456789' }])
- expect(HTTParty).to receive(:post).with(
- 'https://waba.360dialog.io/v1/messages',
- headers: { 'D360-API-KEY' => 'test_key', 'Content-Type' => 'application/json' },
- body: { 'to' => '123456789', 'text' => { 'body' => 'test' }, 'type' => 'text' }.to_json
- )
+
+ stub_request(:post, 'https://waba.360dialog.io/v1/messages')
+ .with(
+ headers: headers,
+ body: { 'to' => '123456789', 'text' => { 'body' => 'test' }, 'type' => 'text' }.to_json
+ )
+ .to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
+
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('123456789')
end
@@ -41,23 +58,13 @@ describe Whatsapp::SendOnWhatsappService do
it 'calls channel.send_template when after 24 hour limit' do
message = create(:message, message_type: :outgoing, content: 'Your package has been shipped. It will be delivered in 3 business days.',
conversation: conversation)
- allow(HTTParty).to receive(:post).and_return(whatsapp_request)
- allow(whatsapp_request).to receive(:success?).and_return(true)
- allow(whatsapp_request).to receive(:[]).with('messages').and_return([{ 'id' => '123456789' }])
- expect(HTTParty).to receive(:post).with(
- 'https://waba.360dialog.io/v1/messages',
- headers: { 'D360-API-KEY' => 'test_key', 'Content-Type' => 'application/json' },
- body: {
- to: '123456789',
- template: {
- name: 'sample_shipping_confirmation',
- namespace: '23423423_2342423_324234234_2343224',
- language: { 'policy': 'deterministic', 'code': 'en_US' },
- components: [{ 'type': 'body', 'parameters': [{ 'type': 'text', 'text': '3' }] }]
- },
- type: 'template'
- }.to_json
- )
+
+ stub_request(:post, 'https://waba.360dialog.io/v1/messages')
+ .with(
+ headers: headers,
+ body: template_body.to_json
+ ).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
+
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('123456789')
end
@@ -65,23 +72,12 @@ describe Whatsapp::SendOnWhatsappService do
it 'calls channel.send_template if template_params are present' do
message = create(:message, additional_attributes: { template_params: template_params },
content: 'Your package will be delivered in 3 business days.', conversation: conversation, message_type: :outgoing)
- allow(HTTParty).to receive(:post).and_return(whatsapp_request)
- allow(whatsapp_request).to receive(:success?).and_return(true)
- allow(whatsapp_request).to receive(:[]).with('messages').and_return([{ 'id' => '123456789' }])
- expect(HTTParty).to receive(:post).with(
- 'https://waba.360dialog.io/v1/messages',
- headers: { 'D360-API-KEY' => 'test_key', 'Content-Type' => 'application/json' },
- body: {
- to: '123456789',
- template: {
- name: 'sample_shipping_confirmation',
- namespace: '23423423_2342423_324234234_2343224',
- language: { 'policy': 'deterministic', 'code': 'en_US' },
- components: [{ 'type': 'body', 'parameters': [{ 'type': 'text', 'text': '3' }] }]
- },
- type: 'template'
- }.to_json
- )
+ stub_request(:post, 'https://waba.360dialog.io/v1/messages')
+ .with(
+ headers: headers,
+ body: template_body.to_json
+ ).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
+
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('123456789')
end
@@ -93,23 +89,22 @@ describe Whatsapp::SendOnWhatsappService do
content: 'عميلنا العزيز الرجاء الرد على هذه الرسالة بكلمة *نعم* للرد على إستفساركم من قبل خدمة العملاء.',
conversation: conversation
)
- allow(HTTParty).to receive(:post).and_return(whatsapp_request)
- allow(whatsapp_request).to receive(:success?).and_return(true)
- allow(whatsapp_request).to receive(:[]).with('messages').and_return([{ 'id' => '123456789' }])
- expect(HTTParty).to receive(:post).with(
- 'https://waba.360dialog.io/v1/messages',
- headers: { 'D360-API-KEY' => 'test_key', 'Content-Type' => 'application/json' },
- body: {
- to: '123456789',
- template: {
- name: 'customer_yes_no',
- namespace: '2342384942_32423423_23423fdsdaf23',
- language: { 'policy': 'deterministic', 'code': 'ar' },
- components: [{ 'type': 'body', 'parameters': [] }]
- },
- type: 'template'
- }.to_json
- )
+
+ stub_request(:post, 'https://waba.360dialog.io/v1/messages')
+ .with(
+ headers: headers,
+ body: {
+ to: '123456789',
+ template: {
+ name: 'customer_yes_no',
+ namespace: '2342384942_32423423_23423fdsdaf23',
+ language: { 'policy': 'deterministic', 'code': 'ar' },
+ components: [{ 'type': 'body', 'parameters': [] }]
+ },
+ type: 'template'
+ }.to_json
+ ).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
+
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('123456789')
end
diff --git a/swagger/paths/application/conversation/custom_attributes.yml b/swagger/paths/application/conversation/custom_attributes.yml
new file mode 100644
index 000000000..bfd416138
--- /dev/null
+++ b/swagger/paths/application/conversation/custom_attributes.yml
@@ -0,0 +1,36 @@
+tags:
+ - Conversations
+operationId: update-custom-attributes-of-a-conversation
+summary: Update Custom Attributes
+description: Updates the custom attributes of a conversation
+security:
+ - userApiKey: []
+ - agentBotApiKey: []
+parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ type: object
+ required:
+ - custom_attributes
+ properties:
+ custom_attributes:
+ type: object
+ description: The custom attributes to be set for the conversation
+ example:
+ order_id: "12345"
+ previous_conversation: "67890"
+responses:
+ 200:
+ description: Success
+ schema:
+ type: object
+ properties:
+ custom_attributes:
+ type: object
+ description: The custom attributes of the conversation
+ 404:
+ description: Conversation not found
+ 401:
+ description: Unauthorized
\ No newline at end of file
diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml
index 6df22d06e..769f38c27 100644
--- a/swagger/paths/index.yml
+++ b/swagger/paths/index.yml
@@ -354,6 +354,13 @@
post:
$ref: ./application/conversation/toggle_priority.yml
+/api/v1/accounts/{account_id}/conversations/{conversation_id}/custom_attributes:
+ parameters:
+ - $ref: '#/parameters/account_id'
+ - $ref: '#/parameters/conversation_id'
+ post:
+ $ref: ./application/conversation/custom_attributes.yml
+
# Conversations Assignments
/api/v1/accounts/{account_id}/conversations/{conversation_id}/assignments:
diff --git a/swagger/swagger.json b/swagger/swagger.json
index d5f94f730..19ef5c904 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -3514,6 +3514,79 @@
}
}
},
+ "/api/v1/accounts/{account_id}/conversations/{conversation_id}/custom_attributes": {
+ "parameters": [
+ {
+ "$ref": "#/parameters/account_id"
+ },
+ {
+ "$ref": "#/parameters/conversation_id"
+ }
+ ],
+ "post": {
+ "tags": [
+ "Conversations"
+ ],
+ "operationId": "update-custom-attributes-of-a-conversation",
+ "summary": "Update Custom Attributes",
+ "description": "Updates the custom attributes of a conversation",
+ "security": [
+ {
+ "userApiKey": [
+
+ ]
+ },
+ {
+ "agentBotApiKey": [
+
+ ]
+ }
+ ],
+ "parameters": [
+ {
+ "name": "data",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "type": "object",
+ "required": [
+ "custom_attributes"
+ ],
+ "properties": {
+ "custom_attributes": {
+ "type": "object",
+ "description": "The custom attributes to be set for the conversation",
+ "example": {
+ "order_id": "12345",
+ "previous_conversation": "67890"
+ }
+ }
+ }
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "custom_attributes": {
+ "type": "object",
+ "description": "The custom attributes of the conversation"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Conversation not found"
+ },
+ "401": {
+ "description": "Unauthorized"
+ }
+ }
+ }
+ },
"/api/v1/accounts/{account_id}/conversations/{conversation_id}/assignments": {
"parameters": [
{