From 0ad47d87f48f64454c1541cf2283402403fb9c24 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 11 Feb 2026 03:55:25 +0530 Subject: [PATCH 1/5] fix: Use Faraday for Telegram document uploads to fix large file failures (#13397) Fixes https://linear.app/chatwoot/issue/CW-6415/sending-large-attachments-11mb-via-telegram-channels-fails-with-http #### Issue Sending large attachments (~11MB) via Telegram channels fails with HTTP 502 (Bad Gateway) and 413 (Request Entity Too Large) errors. The issue is caused by HTTParty's built-in multipart encoding, which reads the entire file into an in-memory string before constructing the request body. For large files, this produces a malformed multipart request that Telegram's API proxy rejects. #### Solution Replace HTTParty with Faraday + multipart-post (both already available in the project) for the sendDocument multipart upload. The multipart-post gem streams file content directly from disk into the HTTP request, producing a correctly formed multipart body that Telegram accepts for large files. --------- Co-authored-by: Sojan Jose --- .../telegram/send_attachments_service.rb | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/app/services/telegram/send_attachments_service.rb b/app/services/telegram/send_attachments_service.rb index 5ba8fd4ae..7b66efd83 100644 --- a/app/services/telegram/send_attachments_service.rb +++ b/app/services/telegram/send_attachments_service.rb @@ -1,3 +1,5 @@ +require 'faraday/multipart' + # Telegram Attachment APIs: ref: https://core.telegram.org/bots/api#inputfile # Media attachments like photos, videos can be clubbed together and sent as a media group @@ -111,17 +113,33 @@ class Telegram::SendAttachmentsService def send_file(chat_id, file_path, reply_to_message_id) File.open(file_path, 'rb') do |file| - HTTParty.post("#{channel.telegram_api_url}/sendDocument", - body: { - chat_id: chat_id, - **business_connection_body, - document: file, - reply_to_message_id: reply_to_message_id - }, - multipart: true) + file_name = File.basename(file_path) + mime_type = Marcel::MimeType.for(name: file_name) || 'application/octet-stream' + + payload = { chat_id: chat_id, document: Faraday::Multipart::FilePart.new(file, mime_type, file_name) } + payload[:reply_to_message_id] = reply_to_message_id if reply_to_message_id + payload.merge!(business_connection_body) + + response = multipart_post_connection.post("#{channel.telegram_api_url}/sendDocument", payload) + parse_faraday_response(response) end end + def multipart_post_connection + @multipart_post_connection ||= Faraday.new do |f| + f.request :multipart + f.options.timeout = 300 + f.options.open_timeout = 60 + end + end + + def parse_faraday_response(response) + parsed = JSON.parse(response.body) + OpenStruct.new(success?: response.success?, parsed_response: parsed) + rescue JSON::ParserError + OpenStruct.new(success?: false, parsed_response: { 'ok' => false, 'error_code' => response.status, 'description' => response.reason_phrase }) + end + def handle_response(response) return true if response.success? From 8f95fafff44d2a5393c0ab187541ded95b655f70 Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 10 Feb 2026 17:27:42 -0800 Subject: [PATCH 2/5] feat: Add a setting to keep conversations pending on bot failures (#13512) Adds an account-level setting `keep_pending_on_bot_failure` to control whether conversations should move from pending to open when agent bot webhooks fail. Some users experience occasional message drops and don't want conversations to automatically reopen due to transient bot failures. This setting gives accounts control over that behavior. This is a temporary setting which will be removed in future once a proper fix for it is done, so it is not added in the UI. --- app/models/account.rb | 2 ++ lib/webhooks/trigger.rb | 15 ++++++--- spec/lib/webhooks/trigger_spec.rb | 56 +++++++++++++++++++++++++++---- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/app/models/account.rb b/app/models/account.rb index fead5f0f7..4816494fb 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -40,6 +40,7 @@ class Account < ApplicationRecord 'auto_resolve_ignore_waiting': { 'type': %w[boolean null] }, 'audio_transcriptions': { 'type': %w[boolean null] }, 'auto_resolve_label': { 'type': %w[string null] }, + 'keep_pending_on_bot_failure': { 'type': %w[boolean null] }, 'conversation_required_attributes': { 'type': %w[array null], 'items': { 'type': 'string' } @@ -88,6 +89,7 @@ class Account < ApplicationRecord store_accessor :settings, :audio_transcriptions, :auto_resolve_label store_accessor :settings, :captain_models, :captain_features + store_accessor :settings, :keep_pending_on_bot_failure has_many :account_users, dependent: :destroy_async has_many :agent_bot_inboxes, dependent: :destroy_async diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb index 54bd7499d..ef3410b78 100644 --- a/lib/webhooks/trigger.rb +++ b/lib/webhooks/trigger.rb @@ -36,16 +36,21 @@ class Webhooks::Trigger case @webhook_type when :agent_bot_webhook - conversation = message.conversation - return unless conversation&.pending? - - conversation.open! - create_agent_bot_error_activity(conversation) + update_conversation_status(message) when :api_inbox_webhook update_message_status(error) end end + def update_conversation_status(message) + conversation = message.conversation + return unless conversation&.pending? + return if conversation&.account&.keep_pending_on_bot_failure + + conversation.open! + create_agent_bot_error_activity(conversation) + end + def create_agent_bot_error_activity(conversation) content = I18n.t('conversations.activity.agent_bot.error_moved_to_open') Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content)) diff --git a/spec/lib/webhooks/trigger_spec.rb b/spec/lib/webhooks/trigger_spec.rb index 78bf361c4..79cf92150 100644 --- a/spec/lib/webhooks/trigger_spec.rb +++ b/spec/lib/webhooks/trigger_spec.rb @@ -74,10 +74,11 @@ describe Webhooks::Trigger do context 'when webhook type is agent bot' do let(:webhook_type) { :agent_bot_webhook } + let!(:pending_conversation) { create(:conversation, inbox: inbox, status: :pending, account: account) } + let!(:pending_message) { create(:message, account: account, inbox: inbox, conversation: pending_conversation) } it 'reopens conversation and enqueues activity message if pending' do - conversation.update(status: :pending) - payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id } + payload = { event: 'message_created', id: pending_message.id } expect(RestClient::Request).to receive(:execute) .with( @@ -92,11 +93,11 @@ describe Webhooks::Trigger do perform_enqueued_jobs do trigger.execute(url, payload, webhook_type) end - end.not_to(change { message.reload.status }) + end.not_to(change { pending_message.reload.status }) - expect(conversation.reload.status).to eq('open') + expect(pending_conversation.reload.status).to eq('open') - activity_message = conversation.reload.messages.order(:created_at).last + activity_message = pending_conversation.reload.messages.order(:created_at).last expect(activity_message.message_type).to eq('activity') expect(activity_message.content).to eq(agent_bot_error_content) end @@ -118,9 +119,52 @@ describe Webhooks::Trigger do end.not_to(change { message.reload.status }) expect(Conversations::ActivityMessageJob).not_to have_been_enqueued - expect(conversation.reload.status).to eq('open') end + + it 'keeps conversation pending when keep_pending_on_bot_failure setting is enabled' do + account.update(keep_pending_on_bot_failure: true) + payload = { event: 'message_created', id: pending_message.id } + + expect(RestClient::Request).to receive(:execute) + .with( + method: :post, + url: url, + payload: payload.to_json, + headers: { content_type: :json, accept: :json }, + timeout: webhook_timeout + ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once + + trigger.execute(url, payload, webhook_type) + + expect(Conversations::ActivityMessageJob).not_to have_been_enqueued + expect(pending_conversation.reload.status).to eq('pending') + end + + it 'reopens conversation when keep_pending_on_bot_failure setting is disabled' do + account.update(keep_pending_on_bot_failure: false) + payload = { event: 'message_created', id: pending_message.id } + + expect(RestClient::Request).to receive(:execute) + .with( + method: :post, + url: url, + payload: payload.to_json, + headers: { content_type: :json, accept: :json }, + timeout: webhook_timeout + ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once + expect do + perform_enqueued_jobs do + trigger.execute(url, payload, webhook_type) + end + end.not_to(change { pending_message.reload.status }) + + expect(pending_conversation.reload.status).to eq('open') + + activity_message = pending_conversation.reload.messages.order(:created_at).last + expect(activity_message.message_type).to eq('activity') + expect(activity_message.content).to eq(agent_bot_error_content) + end end end From 7b512bd00eb6fb9c84e37082e673ae47e4ecf768 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:24:45 +0530 Subject: [PATCH 3/5] fix: V2 Assignment service enhancements (#13036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Linear Ticket: https://linear.app/chatwoot/issue/CW-6081/review-feedback ## Description Assignment V2 Service Enhancements - Enable Assignment V2 on plan upgrade - Fix UI issue with fair distribution policy display - Add advanced assignment feature flag and enhance Assignment V2 capabilities ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? This has been tested using the UI. ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- > [!NOTE] > **Medium Risk** > Changes auto-assignment execution paths, rate limiting defaults, and feature-flag gating (including premium plan behavior), which could affect which conversations get assigned and when. UI rewires inbox settings and policy flows, so regressions are possible around navigation/linking and feature visibility. > > **Overview** > **Adds a new premium `advanced_assignment` feature flag** and uses it to gate capacity/balanced assignment features in the UI (sidebar entry, settings routes, assignment-policy landing cards) and backend (Enterprise balanced selector + capacity filtering). `advanced_assignment` is marked premium, included in Business plan entitlements, and auto-synced in Enterprise accounts when `assignment_v2` is toggled. > > **Improves Assignment V2 policy UX** by adding an inbox-level “Conversation Assignment” section (behind `assignment_v2`) that can link/unlink an assignment policy, navigate to create/edit policy flows with `inboxId` query context, and show an inbox-link prompt after creating a policy. The policy form now defaults to enabled, disables the `balanced` option with a premium badge/message when unavailable, and inbox lists support click-to-navigate. > > **Tightens/adjusts auto-assignment behavior**: bulk assignment now requires `inbox.enable_auto_assignment?`, conversation ordering uses the attached `assignment_policy` priority, and rate limiting uses `assignment_policy` config with an infinite default limit while still tracking assignments. Tests and i18n strings are updated accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 23bc03bf75ee4376071e4d7fc7cd564c601d33d7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Pranav Co-authored-by: iamsivin Co-authored-by: Muhsin Keloth Co-authored-by: Shivam Mishra --- .../AssignmentPolicyCard.story.vue | 3 - .../AssignmentPolicyCard.vue | 17 - .../AssignmentPolicy/components/DataTable.vue | 22 +- .../components/FairDistribution.vue | 22 +- .../AssignmentPolicy/components/RadioCard.vue | 41 +- .../components/story/BaseInfo.story.vue | 4 - .../components-next/sidebar/Sidebar.vue | 29 +- app/javascript/dashboard/featureFlags.js | 2 + .../dashboard/i18n/locale/en/inboxMgmt.json | 47 ++ .../dashboard/i18n/locale/en/settings.json | 27 +- .../settings/assignmentPolicy/Index.vue | 109 ++- .../assignmentPolicy.routes.js | 6 +- .../pages/AgentAssignmentCreatePage.vue | 59 +- .../pages/AgentAssignmentEditPage.vue | 127 ++- .../pages/AgentCapacityEditPage.vue | 83 +- .../components/AgentAssignmentPolicyForm.vue | 58 +- .../pages/components/InboxLinkDialog.vue | 116 +++ .../inbox/settingsPage/CollaboratorsPage.vue | 759 ++++++++++++++---- .../auto_assignment/assignment_service.rb | 9 +- app/services/auto_assignment/rate_limiter.rb | 6 +- config/features.yml | 4 + enterprise/app/models/enterprise/account.rb | 23 + .../auto_assignment/assignment_service.rb | 5 +- .../billing/handle_stripe_event_service.rb | 2 +- .../assignment_service_spec.rb | 5 +- .../auto_assignment/capacity_service_spec.rb | 5 +- .../periodic_assignment_job_spec.rb | 20 +- .../assignment_service_spec.rb | 5 +- .../auto_assignment/rate_limiter_spec.rb | 5 +- 29 files changed, 1284 insertions(+), 336 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/InboxLinkDialog.vue diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue index cd6f1d49b..20ab38d58 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue @@ -39,7 +39,6 @@ const policyA = withCount({ description: 'Distributes conversations evenly among available agents', assignmentOrder: 'round_robin', conversationPriority: 'high', - enabled: true, inboxes: [mockInboxes[0], mockInboxes[1]], isFetchingInboxes: false, }); @@ -50,7 +49,6 @@ const policyB = withCount({ description: 'Assigns based on capacity and workload', assignmentOrder: 'capacity_based', conversationPriority: 'medium', - enabled: true, inboxes: [mockInboxes[2], mockInboxes[3]], isFetchingInboxes: false, }); @@ -61,7 +59,6 @@ const emptyPolicy = withCount({ description: 'Policy with no assigned inboxes', assignmentOrder: 'manual', conversationPriority: 'low', - enabled: false, inboxes: [], isFetchingInboxes: false, }); diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue index fe9965777..cedfb0009 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue @@ -15,7 +15,6 @@ const props = defineProps({ assignmentOrder: { type: String, default: '' }, conversationPriority: { type: String, default: '' }, assignedInboxCount: { type: Number, default: 0 }, - enabled: { type: Boolean, default: false }, inboxes: { type: Array, default: () => [] }, isFetchingInboxes: { type: Boolean, default: false }, }); @@ -65,22 +64,6 @@ const handleFetchInboxes = () => { {{ name }}
-
- - {{ - enabled - ? t( - 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE' - ) - : t( - 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE' - ) - }} - -
-
+
+ +
-import { ref, onMounted } from 'vue'; +import { ref, computed, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'; import Input from 'dashboard/components-next/input/Input.vue'; import DurationInput from 'dashboard/components-next/input/DurationInput.vue'; @@ -15,6 +15,9 @@ const fairDistributionLimit = defineModel('fairDistributionLimit', { }, }); +// The model value is in seconds (for the backend/DB) +// DurationInput works in minutes internally +// We need to convert between seconds and minutes const fairDistributionWindow = defineModel('fairDistributionWindow', { type: Number, default: 3600, @@ -25,6 +28,17 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', { const windowUnit = ref(DURATION_UNITS.MINUTES); +// Convert seconds to minutes for DurationInput +const windowInMinutes = computed({ + get() { + return Math.floor((fairDistributionWindow.value || 0) / 60); + }, + set(minutes) { + fairDistributionWindow.value = minutes * 60; + }, +}); + +// Detect unit based on minutes (converted from seconds) const detectUnit = minutes => { const m = Number(minutes) || 0; if (m === 0) return DURATION_UNITS.MINUTES; @@ -34,7 +48,7 @@ const detectUnit = minutes => { }; onMounted(() => { - windowUnit.value = detectUnit(fairDistributionWindow.value); + windowUnit.value = detectUnit(windowInMinutes.value); }); @@ -73,9 +87,9 @@ onMounted(() => {
- + +import { useI18n } from 'vue-i18n'; + const props = defineProps({ id: { type: String, @@ -16,12 +18,22 @@ const props = defineProps({ type: Boolean, default: false, }, + disabled: { + type: Boolean, + default: false, + }, + disabledMessage: { + type: String, + default: '', + }, }); const emit = defineEmits(['select']); +const { t } = useI18n(); + const handleChange = () => { - if (!props.isActive) { + if (!props.isActive && !props.disabled) { emit('select', props.id); } }; @@ -29,9 +41,11 @@ const handleChange = () => {