From 0920a01e662163c13714fd6bd393b292df89151d Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 27 Apr 2026 15:40:00 +0530 Subject: [PATCH 1/3] fix(i18n): align pluralization with locale rules (#14266) Loads Rails locale-specific pluralization rules so languages with an `other`-only plural model can safely use Crowdin exports without maintaining duplicate `one` keys. ## Closes None ## Why Crowdin exports Rails YAML pluralized strings using each target language's plural categories. These categories come from Unicode CLDR and represent grammatical forms, not a literal "number is 1" bucket. Some languages need separate forms such as `one` and `other`, but languages like Japanese, Korean, Indonesian, Thai, Vietnamese, and Chinese use the same form for `1`, `2`, `5`, and larger counts in these strings. For those locales, CLDR correctly models the plural category as `other` only. Before this change, Chatwoot still relied on Rails' default English-style plural behavior for these locales. That meant a valid Crowdin export containing only `other` could fail at runtime when Rails received `count: 1` and looked for a missing `one` branch. Keeping duplicate `one` keys would only fight Crowdin on every translation sync. The runtime should instead follow the locale's plural rules. ## What changed - Added `rails-i18n` and enabled only its pluralization module. - Added explicit `other`-only plural rules for Chatwoot's underscore Chinese locale aliases, `zh_CN` and `zh_TW`. - Removed redundant `one` keys from the affected Devise and `time_units` translations. ## Validation - Ran a Rails runner check across `id`, `ja`, `ko`, `ms`, `th`, `vi`, `zh_CN`, and `zh_TW` to verify `errors.messages.not_saved` and `time_units.days` resolve with only `other` for `count: 1`. - Ran YAML parse validation for all edited locale files. - Ran `bundle exec rubocop Gemfile config/application.rb config/initializers/i18n_pluralization.rb`. --- Gemfile | 1 + Gemfile.lock | 4 ++++ config/application.rb | 1 + config/initializers/i18n_pluralization.rb | 8 ++++++++ config/locales/devise.id.yml | 1 - config/locales/devise.ja.yml | 1 - config/locales/devise.ko.yml | 1 - config/locales/devise.ms.yml | 1 - config/locales/devise.th.yml | 1 - config/locales/devise.vi.yml | 1 - config/locales/devise.zh_CN.yml | 1 - config/locales/devise.zh_TW.yml | 1 - config/locales/id.yml | 4 ---- config/locales/ja.yml | 4 ---- config/locales/ko.yml | 4 ---- config/locales/ms.yml | 4 ---- config/locales/th.yml | 4 ---- config/locales/vi.yml | 4 ---- config/locales/zh_CN.yml | 4 ---- config/locales/zh_TW.yml | 4 ---- 20 files changed, 14 insertions(+), 40 deletions(-) create mode 100644 config/initializers/i18n_pluralization.rb diff --git a/Gemfile b/Gemfile index a5068e765..c4989c538 100644 --- a/Gemfile +++ b/Gemfile @@ -84,6 +84,7 @@ gem 'barnes' gem 'devise', '>= 4.9.4' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise_token_auth', '>= 1.2.3' +gem 'rails-i18n', '~> 7.0' # two-factor authentication gem 'devise-two-factor', '>= 5.0.0' # authorization diff --git a/Gemfile.lock b/Gemfile.lock index b77e5880f..7d29e0b02 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -727,6 +727,9 @@ GEM rails-html-sanitizer (1.6.1) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rails-i18n (7.0.10) + i18n (>= 0.7, < 2) + railties (>= 6.0.0, < 8) railties (7.1.5.2) actionpack (= 7.1.5.2) activesupport (= 7.1.5.2) @@ -1125,6 +1128,7 @@ DEPENDENCIES rack-mini-profiler (>= 3.2.0) rack-timeout rails (~> 7.1) + rails-i18n (~> 7.0) redis redis-namespace responders (>= 3.1.1) diff --git a/config/application.rb b/config/application.rb index aa150794a..08f0451c1 100644 --- a/config/application.rb +++ b/config/application.rb @@ -37,6 +37,7 @@ module Chatwoot class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. config.load_defaults 7.0 + config.rails_i18n.enabled_modules = [:pluralization] config.eager_load_paths << Rails.root.join('lib') config.eager_load_paths << Rails.root.join('enterprise/lib') diff --git a/config/initializers/i18n_pluralization.rb b/config/initializers/i18n_pluralization.rb new file mode 100644 index 000000000..c4fc3fb4b --- /dev/null +++ b/config/initializers/i18n_pluralization.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +other_plural_rule = ->(_count) { :other } + +Rails.application.config.after_initialize do + I18n.backend.store_translations(:zh_CN, i18n: { plural: { rule: other_plural_rule } }) + I18n.backend.store_translations(:zh_TW, i18n: { plural: { rule: other_plural_rule } }) +end diff --git a/config/locales/devise.id.yml b/config/locales/devise.id.yml index 71b9f46fb..fc6bfb26a 100644 --- a/config/locales/devise.id.yml +++ b/config/locales/devise.id.yml @@ -57,5 +57,4 @@ id: not_found: "tidak ditemukan" not_locked: "tidak terkunci" not_saved: - one: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:" other: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:" diff --git a/config/locales/devise.ja.yml b/config/locales/devise.ja.yml index 043cd8351..a5840c6fc 100644 --- a/config/locales/devise.ja.yml +++ b/config/locales/devise.ja.yml @@ -57,5 +57,4 @@ ja: not_found: "見つかりませんでした" not_locked: "はロックされていません" not_saved: - one: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:" other: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:" diff --git a/config/locales/devise.ko.yml b/config/locales/devise.ko.yml index 846664ec9..5afb6c1c2 100644 --- a/config/locales/devise.ko.yml +++ b/config/locales/devise.ko.yml @@ -57,5 +57,4 @@ ko: not_found: "찾을 수 없습니다" not_locked: "잠겨 있지 않습니다" not_saved: - one: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:" other: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:" diff --git a/config/locales/devise.ms.yml b/config/locales/devise.ms.yml index ebcfe89e3..cecd08588 100644 --- a/config/locales/devise.ms.yml +++ b/config/locales/devise.ms.yml @@ -57,5 +57,4 @@ ms: not_found: "not found" not_locked: "was not locked" not_saved: - one: "%{count} errors prohibited this %{resource} from being saved:" other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/config/locales/devise.th.yml b/config/locales/devise.th.yml index c9f52018d..18e1572bb 100644 --- a/config/locales/devise.th.yml +++ b/config/locales/devise.th.yml @@ -57,5 +57,4 @@ th: not_found: "not found" not_locked: "was not locked" not_saved: - one: "%{count} errors prohibited this %{resource} from being saved:" other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/config/locales/devise.vi.yml b/config/locales/devise.vi.yml index 947e756f3..15dca044a 100644 --- a/config/locales/devise.vi.yml +++ b/config/locales/devise.vi.yml @@ -57,5 +57,4 @@ vi: not_found: "không tìm thấy" not_locked: "không được khoá" not_saved: - one: "Có %{count} lỗi được tìm thấy từ %{resource}:" other: "Có %{count} lỗi được tìm thấy từ %{resource}:" diff --git a/config/locales/devise.zh_CN.yml b/config/locales/devise.zh_CN.yml index 00f239948..2bf2831a8 100644 --- a/config/locales/devise.zh_CN.yml +++ b/config/locales/devise.zh_CN.yml @@ -57,5 +57,4 @@ zh_CN: not_found: "找不到" not_locked: "未锁定" not_saved: - one: "%{count} 个错误禁止保存 %{resource}:" other: "%{count} 个错误禁止保存 %{resource}:" diff --git a/config/locales/devise.zh_TW.yml b/config/locales/devise.zh_TW.yml index c5bd49450..f892bf796 100644 --- a/config/locales/devise.zh_TW.yml +++ b/config/locales/devise.zh_TW.yml @@ -57,5 +57,4 @@ zh_TW: not_found: "找不到。" not_locked: "並未被鎖定。" not_saved: - one: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:" other: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:" diff --git a/config/locales/id.yml b/config/locales/id.yml index aefcff3fe..b097de83d 100644 --- a/config/locales/id.yml +++ b/config/locales/id.yml @@ -435,16 +435,12 @@ id: button: Buka percakapan time_units: days: - one: '%{count} days' other: '%{count} days' hours: - one: '%{count} hours' other: '%{count} hours' minutes: - one: '%{count} minutes' other: '%{count} minutes' seconds: - one: '%{count} seconds' other: '%{count} seconds' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 5e3412378..deca85ebf 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -435,16 +435,12 @@ ja: button: 会話を開く time_units: days: - one: '%{count} 日' other: '%{count} 日' hours: - one: '%{count} 時間' other: '%{count} 時間' minutes: - one: '%{count} 分' other: '%{count} 分' seconds: - one: '%{count} 秒' other: '%{count} 秒' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/ko.yml b/config/locales/ko.yml index c010ee2ac..153f39928 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -435,16 +435,12 @@ ko: button: 대화 열기 time_units: days: - one: '%{count}일' other: '%{count}일' hours: - one: '%{count}시간' other: '%{count}시간' minutes: - one: '%{count}분' other: '%{count}분' seconds: - one: '%{count}초' other: '%{count}초' auto_assignment: default_policy_name: '기본 정책' diff --git a/config/locales/ms.yml b/config/locales/ms.yml index 617056d7e..e1ee39aee 100644 --- a/config/locales/ms.yml +++ b/config/locales/ms.yml @@ -435,16 +435,12 @@ ms: button: Open conversation time_units: days: - one: '%{count} days' other: '%{count} days' hours: - one: '%{count} hours' other: '%{count} hours' minutes: - one: '%{count} minutes' other: '%{count} minutes' seconds: - one: '%{count} seconds' other: '%{count} seconds' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/th.yml b/config/locales/th.yml index b278ec442..aeef51e95 100644 --- a/config/locales/th.yml +++ b/config/locales/th.yml @@ -435,16 +435,12 @@ th: button: เปิดดูการสนทนา time_units: days: - one: '%{count} days' other: '%{count} days' hours: - one: '%{count} hours' other: '%{count} hours' minutes: - one: '%{count} minutes' other: '%{count} minutes' seconds: - one: '%{count} seconds' other: '%{count} seconds' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/vi.yml b/config/locales/vi.yml index c53f8de4c..1facb248f 100644 --- a/config/locales/vi.yml +++ b/config/locales/vi.yml @@ -435,16 +435,12 @@ vi: button: Mở cuộc trò chuyện time_units: days: - one: '%{count} days' other: '%{count} days' hours: - one: '%{count} hours' other: '%{count} hours' minutes: - one: '%{count} minutes' other: '%{count} minutes' seconds: - one: '%{count} seconds' other: '%{count} seconds' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/zh_CN.yml b/config/locales/zh_CN.yml index 0f6cd069f..ab8005c5e 100644 --- a/config/locales/zh_CN.yml +++ b/config/locales/zh_CN.yml @@ -435,16 +435,12 @@ zh_CN: button: 重新打开会话 time_units: days: - one: '%{count} 天' other: '%{count} 天' hours: - one: '%{count} 小时' other: '%{count} 小时' minutes: - one: '%{count} 分钟' other: '%{count} 分钟' seconds: - one: '%{count} 秒' other: '%{count} 秒' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/zh_TW.yml b/config/locales/zh_TW.yml index 775bcf000..d7dd33efa 100644 --- a/config/locales/zh_TW.yml +++ b/config/locales/zh_TW.yml @@ -435,16 +435,12 @@ zh_TW: button: '開啟對話' time_units: days: - one: '%{count} 天' other: '%{count} 天' hours: - one: '%{count} 小時' other: '%{count} 小時' minutes: - one: '%{count} 分鐘' other: '%{count} 分鐘' seconds: - one: '%{count} 秒' other: '%{count} 秒' auto_assignment: default_policy_name: '預設策略' From 2266eb493bc18e1288c1ef88814e72ca353e868f Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 27 Apr 2026 03:17:11 -0700 Subject: [PATCH 2/3] fix: Add validation to the name attribute in user (#10805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With this change, the form will start displaying a required field. While validation is already enforced in APIs and other areas, the super_admin console—being autogenerated—will throw an error since this requirement isn’t explicitly defined in the model. Screenshot 2025-01-30 at 2 12 43 PM Fixes https://github.com/chatwoot/chatwoot/issues/10754 --------- Co-authored-by: Shivam Mishra Co-authored-by: Muhsin Keloth Co-authored-by: Sony Mathew Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> Co-authored-by: Sony Mathew --- app/builders/agent_builder.rb | 3 ++- app/models/user.rb | 1 + spec/builders/agent_builder_spec.rb | 2 +- .../crm/leadsquared/mappers/conversation_mapper_spec.rb | 7 ++++--- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb index 2fe11cae0..d2715011c 100644 --- a/app/builders/agent_builder.rb +++ b/app/builders/agent_builder.rb @@ -29,8 +29,9 @@ class AgentBuilder user = User.from_email(email) return user if user + @name = email.split('@').first if @name.blank? temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" - User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password) + User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password) end # Checks if the user needs confirmation. diff --git a/app/models/user.rb b/app/models/user.rb index 443df1ef6..4aa38bbcd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -75,6 +75,7 @@ class User < ApplicationRecord # work because :validatable in devise overrides this. # validates_uniqueness_of :email, scope: :account_id + validates :name, presence: true validates :email, presence: true serialize :otp_backup_codes, type: Array diff --git a/spec/builders/agent_builder_spec.rb b/spec/builders/agent_builder_spec.rb index ac8a3229a..f140f2f29 100644 --- a/spec/builders/agent_builder_spec.rb +++ b/spec/builders/agent_builder_spec.rb @@ -56,7 +56,7 @@ RSpec.describe AgentBuilder, type: :model do it 'creates a user with default values' do user = agent_builder.perform - expect(user.name).to eq('') + expect(user.name).to eq(email.split('@').first) expect(AccountUser.find_by(user: user).role).to eq('agent') end end diff --git a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb index 2d4b7ed01..75abc8518 100644 --- a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb +++ b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb @@ -188,12 +188,13 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do end context 'when sender has no name' do + let(:unnamed_contact) { create(:contact, account: account, name: '') } let(:unnamed_sender_message) do create(:message, conversation: conversation, - sender: create(:user, name: ''), + sender: unnamed_contact, content: 'Message', - message_type: :outgoing, + message_type: :incoming, created_at: Time.zone.parse('2024-01-01 10:05')) end @@ -201,7 +202,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do it 'uses sender type and id' do result = described_class.map_transcript_activity(hook, conversation) - expect(result).to include("User #{unnamed_sender_message.sender_id}") + expect(result).to include("Contact #{unnamed_sender_message.sender_id}") end end end From 279dd1876c780c4dc2a4bcb735d5cd8ce3ec5762 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:51:49 +0530 Subject: [PATCH 3/3] fix: make captain datetime aware (#14069) # Pull Request Template ## Description Captain currently cannot discern today, tomorrow etc. This PR adds datetime awareness to the system prompt Fixes: https://linear.app/chatwoot/issue/AI-148/captain-should-be-aware-of-datetime ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Locally CleanShot 2026-04-27 at 14 47 47 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --- .../services/captain/llm/assistant_chat_service.rb | 6 +++++- .../services/captain/llm/system_prompts_service.rb | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb index 2dba3af16..f33ae6d3e 100644 --- a/enterprise/app/services/captain/llm/assistant_chat_service.rb +++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb @@ -42,7 +42,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService { role: 'system', content: Captain::Llm::SystemPromptsService.assistant_response_generator( - @assistant.name, @assistant.config['product_name'], @assistant.config, + @assistant.name, @assistant.config['product_name'], @assistant.config.merge('timezone' => inbox_timezone), contact: contact_attributes, custom_tools: custom_tools_metadata ) @@ -70,6 +70,10 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService ) end + def inbox_timezone + @conversation&.inbox&.timezone.presence || 'UTC' + end + def persist_message(message, message_type = 'assistant') # No need to implement end diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb index dab147301..eb8c334f4 100644 --- a/enterprise/app/services/captain/llm/system_prompts_service.rb +++ b/enterprise/app/services/captain/llm/system_prompts_service.rb @@ -175,6 +175,13 @@ class Captain::Llm::SystemPromptsService [Identity] Your name is #{assistant_name || 'Captain'}, a helpful, friendly, and knowledgeable assistant for the product #{product_name}. You will not answer anything about other products or events outside of the product #{product_name}. + [Current Time] + Current time: #{format_current_time(config['timezone'])}. + + Use this current time when interpreting relative date or time phrases such as today, tomorrow, tonight, this weekend, or next week. + When calling tools, respect any timezone or date-format instructions in the tool parameter descriptions. + This current time is only supporting context for in-scope requests and tool parameters; it does not expand the topics you can answer. + [Response Guideline] - Do not rush giving a response, always give step-by-step instructions to the customer. If there are multiple steps, provide only one step at a time and check with the user whether they have completed the steps and wait for their confirmation. If the user has said okay or yes, continue with the steps. - Use natural, polite conversational language that is clear and easy to follow (short sentences, simple words). @@ -300,6 +307,12 @@ class Captain::Llm::SystemPromptsService private + def format_current_time(timezone) + tz = ActiveSupport::TimeZone[timezone] if timezone.present? + time = tz ? Time.current.in_time_zone(tz) : Time.current + time.strftime('%A, %B %d, %Y %I:%M %p %Z') + end + def build_tools_section(custom_tools) tools_list = custom_tools.map { |t| "- #{t[:name]}: #{t[:description]}" }.join("\n") <<~TOOLS.strip