From 2dee7457cdcf55008fdeb8a569464a0bbf730284 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Mon, 4 May 2026 17:56:25 +0530 Subject: [PATCH 1/4] fix: set minimal top-level permissions on workflows (#14358) - Fix CodeQL alerts by declaring read-only GITHUB_TOKEN scope at the workflow level. The codespace image publish workflow additionally needs packages: write to push to ghcr.io. --- .github/workflows/deploy_check.yml | 3 +++ .github/workflows/frontend-fe.yml | 3 +++ .github/workflows/logging_percentage_check.yml | 3 +++ .github/workflows/nightly_installer.yml | 3 +++ .github/workflows/publish_codespace_image.yml | 4 ++++ .github/workflows/publish_ee_docker.yml | 3 +++ .github/workflows/publish_foss_docker.yml | 3 +++ .github/workflows/size-limit.yml | 3 +++ .github/workflows/test_docker_build.yml | 3 +++ 9 files changed, 28 insertions(+) diff --git a/.github/workflows/deploy_check.yml b/.github/workflows/deploy_check.yml index 9f295a6c8..9f2ae42d8 100644 --- a/.github/workflows/deploy_check.yml +++ b/.github/workflows/deploy_check.yml @@ -11,6 +11,9 @@ concurrency: group: pr-${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + jobs: deployment_check: name: Check Deployment diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 1d1116d0c..3d992662a 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -8,6 +8,9 @@ on: branches: - develop +permissions: + contents: read + jobs: test: runs-on: ubuntu-22.04 diff --git a/.github/workflows/logging_percentage_check.yml b/.github/workflows/logging_percentage_check.yml index 5c45ba635..cef07cc2f 100644 --- a/.github/workflows/logging_percentage_check.yml +++ b/.github/workflows/logging_percentage_check.yml @@ -10,6 +10,9 @@ concurrency: group: pr-${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + jobs: log_lines_check: runs-on: ubuntu-latest diff --git a/.github/workflows/nightly_installer.yml b/.github/workflows/nightly_installer.yml index beef5727c..e0c5ed88e 100644 --- a/.github/workflows/nightly_installer.yml +++ b/.github/workflows/nightly_installer.yml @@ -14,6 +14,9 @@ on: - cron: "0 0 * * *" workflow_dispatch: +permissions: + contents: read + jobs: nightly: runs-on: ubuntu-24.04 diff --git a/.github/workflows/publish_codespace_image.yml b/.github/workflows/publish_codespace_image.yml index 5da4fda05..c1b0e4e28 100644 --- a/.github/workflows/publish_codespace_image.yml +++ b/.github/workflows/publish_codespace_image.yml @@ -3,6 +3,10 @@ name: Publish Codespace Base Image on: workflow_dispatch: +permissions: + contents: read + packages: write + jobs: publish-code-space-image: runs-on: ubuntu-latest diff --git a/.github/workflows/publish_ee_docker.yml b/.github/workflows/publish_ee_docker.yml index 8e2c22481..982054a18 100644 --- a/.github/workflows/publish_ee_docker.yml +++ b/.github/workflows/publish_ee_docker.yml @@ -18,6 +18,9 @@ on: env: DOCKER_REPO: chatwoot/chatwoot +permissions: + contents: read + jobs: build: strategy: diff --git a/.github/workflows/publish_foss_docker.yml b/.github/workflows/publish_foss_docker.yml index 3075a7f3d..994e5cef8 100644 --- a/.github/workflows/publish_foss_docker.yml +++ b/.github/workflows/publish_foss_docker.yml @@ -18,6 +18,9 @@ on: env: DOCKER_REPO: chatwoot/chatwoot +permissions: + contents: read + jobs: build: strategy: diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 7869bf89c..909636a75 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -10,6 +10,9 @@ concurrency: group: pr-${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test: runs-on: ubuntu-22.04 diff --git a/.github/workflows/test_docker_build.yml b/.github/workflows/test_docker_build.yml index b27d90408..96a6c69ac 100644 --- a/.github/workflows/test_docker_build.yml +++ b/.github/workflows/test_docker_build.yml @@ -7,6 +7,9 @@ on: - master workflow_dispatch: +permissions: + contents: read + jobs: test-build: strategy: From 0bd0cab868a15f4861bf08ecb720c5fee8d2a70d Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 4 May 2026 17:14:01 +0400 Subject: [PATCH 2/4] feat(voice): Attach call recordings + show call duration on the bubble (#14344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an inbound voice call ends, the conversation bubble now (1) renders an inline audio player as soon as Twilio finishes the recording and (2) shows the call duration alongside "Call ended" so the agent gets the at-a-glance summary without opening the recording. Fixes https://linear.app/chatwoot/issue/PLA-118/feat-recordings-on-calls-should-be-attached-on-the-conversation and https://linear.app/chatwoot/issue/PLA-119/duration-of-the-call-is-not-visible-on-the-chat-bubble ## How to test 1. Set up a Twilio voice inbox and trigger an inbound call. 2. Answer the call from an agent, talk for a few seconds, then hang up. 3. As soon as the call ends, the bubble should read **"Call ended — 0:NN"** (where NN is the call duration in seconds). 4. Wait a few seconds for Twilio to finish processing the recording (usually <30s after hangup). 5. The same bubble should now show an inline audio player below the duration. Press play; the recording should be audible. 6. Refresh the page — both the duration and the player should still be there. 7. End a second call on the same conversation — its bubble should get its own duration + player, independent of the first. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../message/bubbles/VoiceCall.vue | 27 +++- app/javascript/shared/helpers/timeHelper.js | 23 ++++ config/routes.rb | 1 + .../controllers/twilio/voice_controller.rb | 29 ++++ .../twilio/recording_attachment_job.rb | 17 +++ enterprise/app/models/call.rb | 3 +- .../twilio/recording_attachment_service.rb | 81 ++++++++++++ .../voice/recording_status_service.rb | 40 ++++++ .../recording_attachment_service_spec.rb | 124 ++++++++++++++++++ .../voice/recording_status_service_spec.rb | 89 +++++++++++++ 10 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 enterprise/app/jobs/voice/provider/twilio/recording_attachment_job.rb create mode 100644 enterprise/app/services/voice/provider/twilio/recording_attachment_service.rb create mode 100644 enterprise/app/services/voice/recording_status_service.rb create mode 100644 spec/enterprise/services/voice/provider/twilio/recording_attachment_service_spec.rb create mode 100644 spec/enterprise/services/voice/recording_status_service_spec.rb diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index 229fbec03..a0f950ad4 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -5,9 +5,11 @@ import { useStore } from 'vuex'; import { useMessageContext } from '../provider.js'; import { VOICE_CALL_STATUS } from '../constants'; import { useCallSession } from 'dashboard/composables/useCallSession'; +import { formatDuration } from 'shared/helpers/timeHelper'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import BaseBubble from 'next/message/bubbles/Base.vue'; +import AudioChip from 'next/message/chips/Audio.vue'; const LABEL_MAP = { [VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS', @@ -76,12 +78,16 @@ const labelKey = computed(() => { : 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; }); +const formattedDuration = computed(() => + formatDuration(call.value?.durationSeconds) +); + const subtext = computed(() => { if (status.value === VOICE_CALL_STATUS.RINGING) { return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); } if (status.value === VOICE_CALL_STATUS.COMPLETED) { - return t('CONVERSATION.VOICE_CALL.CALL_ENDED'); + return formattedDuration.value; } if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED'); @@ -128,6 +134,17 @@ const canJoinCall = computed(() => { return true; }); +const recordingAttachment = computed(() => { + const url = call.value?.recordingUrl; + if (!url) return null; + return { + dataUrl: url, + fileType: 'audio', + extension: 'wav', + transcribedText: call.value?.transcript || '', + }; +}); + const handleJoinCall = async () => { if (!canJoinCall.value || isJoining.value) return; @@ -149,7 +166,7 @@ const handleJoinCall = async () => { diff --git a/app/javascript/shared/helpers/timeHelper.js b/app/javascript/shared/helpers/timeHelper.js index 07b302776..db5609d89 100644 --- a/app/javascript/shared/helpers/timeHelper.js +++ b/app/javascript/shared/helpers/timeHelper.js @@ -94,6 +94,29 @@ export const shortTimestamp = (time, withAgo = false) => { return convertToShortTime; }; +/** + * Formats a duration in seconds into mm:ss or hh:mm:ss. + * @param {number|string} durationInSeconds - Duration in seconds. + * @returns {string} Formatted duration string. Empty string for invalid input. + */ +export const formatDuration = durationInSeconds => { + if (durationInSeconds === null || durationInSeconds === undefined) return ''; + + const totalSeconds = Number(durationInSeconds); + if (Number.isNaN(totalSeconds) || totalSeconds < 0) return ''; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const mm = minutes.toString().padStart(2, '0'); + const ss = seconds.toString().padStart(2, '0'); + if (hours > 0) { + return `${hours.toString().padStart(2, '0')}:${mm}:${ss}`; + } + return `${mm}:${ss}`; +}; + /** * Calculates the difference in days between now and a given timestamp. * @param {Date} now - Current date/time. diff --git a/config/routes.rb b/config/routes.rb index eff17e714..c1a34bc85 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -607,6 +607,7 @@ Rails.application.routes.draw do post 'voice/call/:phone', to: 'voice#call_twiml', as: :voice_call post 'voice/status/:phone', to: 'voice#status', as: :voice_status post 'voice/conference_status/:phone', to: 'voice#conference_status', as: :voice_conference_status + post 'voice/recording_status/:phone', to: 'voice#recording_status', as: :voice_recording_status end end diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb index 6024e2b77..37317d9de 100644 --- a/enterprise/app/controllers/twilio/voice_controller.rb +++ b/enterprise/app/controllers/twilio/voice_controller.rb @@ -38,6 +38,7 @@ class Twilio::VoiceController < ApplicationController end call = find_call_for_conference!(params[:FriendlyName], twilio_call_sid) + persist_twilio_conference_sid!(call, params[:ConferenceSid]) Voice::Conference::Manager.new( call: call, @@ -48,6 +49,15 @@ class Twilio::VoiceController < ApplicationController head :no_content end + def recording_status + Voice::RecordingStatusService.new( + account: current_account, + payload: params.to_unsafe_h + ).perform + + head :no_content + end + private def twilio_call_sid @@ -125,6 +135,10 @@ class Twilio::VoiceController < ApplicationController conference_sid, start_conference_on_enter: agent_leg?(twilio_from), end_conference_on_exit: false, + record: 'record-from-start', + recording_status_callback: recording_status_callback_url, + recording_status_callback_event: 'completed', + recording_status_callback_method: 'POST', status_callback: conference_status_callback_url, status_callback_event: 'start end join leave', status_callback_method: 'POST', @@ -152,12 +166,27 @@ class Twilio::VoiceController < ApplicationController Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits) end + def recording_status_callback_url + phone_digits = inbox_channel.phone_number.delete_prefix('+') + Rails.application.routes.url_helpers.twilio_voice_recording_status_url(phone: phone_digits) + end + def find_call_for_conference!(friendly_name, call_sid) name = friendly_name.to_s call = inbox_calls.by_conference_sid(name).first if name.present? call || inbox_calls.find_by!(provider_call_id: call_sid) end + # Twilio's recording webhook only sends its internal ConferenceSid (CF...), + # not our FriendlyName. Persist Twilio's id the first time we see it on a + # conference event so the recording lookup can match later. + def persist_twilio_conference_sid!(call, sid) + return if sid.blank? + return if call.twilio_conference_sid == sid + + call.update!(twilio_conference_sid: sid) + end + def set_inbox! digits = params[:phone].to_s.gsub(/\D/, '') phone_number = "+#{digits}" diff --git a/enterprise/app/jobs/voice/provider/twilio/recording_attachment_job.rb b/enterprise/app/jobs/voice/provider/twilio/recording_attachment_job.rb new file mode 100644 index 000000000..26b849d04 --- /dev/null +++ b/enterprise/app/jobs/voice/provider/twilio/recording_attachment_job.rb @@ -0,0 +1,17 @@ +class Voice::Provider::Twilio::RecordingAttachmentJob < ApplicationJob + queue_as :low + + retry_on Down::Error, wait: 5.seconds, attempts: 3 + + def perform(call_id, recording_sid, recording_url, recording_duration = nil) + call = Call.find_by(id: call_id) + return if call.blank? + + Voice::Provider::Twilio::RecordingAttachmentService.new( + call: call, + recording_sid: recording_sid, + recording_url: recording_url, + recording_duration: recording_duration + ).perform + end +end diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb index f421f1e94..e5c98baa1 100644 --- a/enterprise/app/models/call.rb +++ b/enterprise/app/models/call.rb @@ -34,7 +34,7 @@ class Call < ApplicationRecord # Statuses where the call is finished and won't change again TERMINAL_STATUSES = %w[completed no_answer failed].freeze - store_accessor :meta, :conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at + store_accessor :meta, :conference_sid, :twilio_conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at enum :provider, { twilio: 0, whatsapp: 1 } enum :direction, { incoming: 0, outgoing: 1 } @@ -55,6 +55,7 @@ class Call < ApplicationRecord scope :active, -> { where.not(status: TERMINAL_STATUSES) } scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) } + scope :by_twilio_conference_sid, ->(sid) { where("meta->>'twilio_conference_sid' = ?", sid) } def self.find_by_provider_call_id(provider, sid) find_by(provider: provider, provider_call_id: sid) diff --git a/enterprise/app/services/voice/provider/twilio/recording_attachment_service.rb b/enterprise/app/services/voice/provider/twilio/recording_attachment_service.rb new file mode 100644 index 000000000..05cd3d227 --- /dev/null +++ b/enterprise/app/services/voice/provider/twilio/recording_attachment_service.rb @@ -0,0 +1,81 @@ +class Voice::Provider::Twilio::RecordingAttachmentService + DEFAULT_FILENAME_EXTENSION = 'wav'.freeze + ALLOWED_CONTENT_TYPE_PREFIXES = %w[audio/].freeze + + pattr_initialize [:call!, :recording_sid!, :recording_url!, { recording_duration: nil }] + + def perform + return if recording_sid.blank? || recording_url.blank? + return if already_attached? + + SafeFetch.fetch( + recording_url, + http_basic_authentication: [account_sid, auth_token], + allowed_content_type_prefixes: ALLOWED_CONTENT_TYPE_PREFIXES + ) do |result| + persist_recording!(result) + end + + # Bump the message updated_at so the message.updated dispatcher rebroadcasts + # the embedded Call payload (now with recording_url) to connected clients. + call.message&.touch # rubocop:disable Rails/SkipsModelValidations + end + + private + + def persist_recording!(result) + call.with_lock do + next if already_attached? + + attach_recording!(result) + call.recording_sid = recording_sid + call.duration_seconds ||= normalized_recording_duration + call.save! + end + end + + def already_attached? + call.recording.attached? && call.recording_sid.to_s == recording_sid.to_s + end + + def attach_recording!(result) + call.recording.attach( + io: result.tempfile, + filename: recording_filename(result), + content_type: recording_content_type(result) + ) + end + + def normalized_recording_duration + return if recording_duration.blank? + + recording_duration.to_i + end + + def recording_filename(result) + return result.original_filename if result.original_filename.present? + + "call-recording-#{recording_sid}.#{recording_extension(result)}" + end + + def recording_extension(result) + content_type = recording_content_type(result) + Rack::Mime::MIME_TYPES.invert[content_type].to_s.delete_prefix('.').presence || DEFAULT_FILENAME_EXTENSION + end + + def recording_content_type(result) + result.content_type.presence || 'audio/wav' + end + + def account_sid + @account_sid ||= channel.account_sid + end + + def auth_token + @auth_token ||= channel.auth_token + end + + def channel + @channel ||= call.inbox.channel + end +end diff --git a/enterprise/app/services/voice/recording_status_service.rb b/enterprise/app/services/voice/recording_status_service.rb new file mode 100644 index 000000000..4dbbd44b8 --- /dev/null +++ b/enterprise/app/services/voice/recording_status_service.rb @@ -0,0 +1,40 @@ +class Voice::RecordingStatusService + pattr_initialize [:account!, { payload: {} }] + + def perform + return unless completed_recording? + return if conference_sid.blank? || recording_sid.blank? || recording_url.blank? + + call = Call.where(account_id: account.id).by_twilio_conference_sid(conference_sid).first + return if call.blank? + + Voice::Provider::Twilio::RecordingAttachmentJob.perform_later( + call.id, + recording_sid, + recording_url, + recording_duration + ) + end + + private + + def completed_recording? + payload['RecordingStatus'].to_s.casecmp('completed').zero? + end + + def conference_sid + payload['ConferenceSid'].to_s + end + + def recording_sid + payload['RecordingSid'].to_s + end + + def recording_url + payload['RecordingUrl'].to_s + end + + def recording_duration + payload['RecordingDuration'] + end +end diff --git a/spec/enterprise/services/voice/provider/twilio/recording_attachment_service_spec.rb b/spec/enterprise/services/voice/provider/twilio/recording_attachment_service_spec.rb new file mode 100644 index 000000000..b67d5b7cd --- /dev/null +++ b/spec/enterprise/services/voice/provider/twilio/recording_attachment_service_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Voice::Provider::Twilio::RecordingAttachmentService do + let(:account) { create(:account) } + let(:channel) do + create(:channel_twilio_sms, :with_voice, + account: account, + phone_number: '+15551238888', + account_sid: 'AC_account_sid', + auth_token: 'auth_token_value') + end + let(:inbox) { channel.inbox } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + let(:call) do + create( + :call, + account: account, + inbox: inbox, + conversation: conversation, + contact: conversation.contact + ) + end + let!(:message) do + msg = conversation.messages.create!( + account_id: account.id, + inbox_id: inbox.id, + message_type: :incoming, + sender: conversation.contact, + content: 'Voice Call', + content_type: 'voice_call' + ) + call.update!(message_id: msg.id) + msg + end + + let(:recording_sid) { 'RE9999' } + let(:recording_url) { 'https://api.twilio.com/2010-04-01/Accounts/AC1/Recordings/RE9999' } + let(:recording_duration) { '47' } + + let(:downloaded_tempfile) do + file = Tempfile.new(['call-recording', '.wav']) + file.binmode + file.write('FAKE_AUDIO_BYTES') + file.rewind + file + end + + let(:safe_fetch_result) do + SafeFetch::Result.new( + tempfile: downloaded_tempfile, + filename: 'recording.wav', + content_type: 'audio/wav' + ) + end + + before do + allow(Twilio::VoiceWebhookSetupService).to receive(:new) + .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}")) + + allow(SafeFetch).to receive(:fetch) + .with(recording_url, http_basic_authentication: %w[AC_account_sid auth_token_value], + allowed_content_type_prefixes: %w[audio/]) + .and_yield(safe_fetch_result) + end + + def perform_service(overrides = {}) + described_class.new( + call: call, + recording_sid: overrides.fetch(:recording_sid, recording_sid), + recording_url: overrides.fetch(:recording_url, recording_url), + recording_duration: overrides.fetch(:recording_duration, recording_duration) + ).perform + end + + describe '#perform' do + it 'attaches the recording to the call and persists recording_sid + duration_seconds' do + previous_updated_at = message.updated_at + travel 1.second + + perform_service + + call.reload + message.reload + + aggregate_failures do + expect(call.recording).to be_attached + expect(call.recording_sid).to eq(recording_sid) + expect(call.duration_seconds).to eq(47) + expect(message.updated_at).to be > previous_updated_at + end + end + + it 'preserves a duration_seconds value that was already set on the call' do + call.update!(duration_seconds: 99) + + perform_service + + expect(call.reload.duration_seconds).to eq(99) + end + + it 'is idempotent when the same recording_sid is already attached' do + perform_service + + expect(SafeFetch).to have_received(:fetch).once + + perform_service + + expect(SafeFetch).to have_received(:fetch).once + expect(call.reload.recording.blob.checksum).to be_present + end + + it 'is a no-op when recording_sid is blank' do + expect { perform_service(recording_sid: '') }.not_to change { call.reload.recording.attached? }.from(false) + expect(SafeFetch).not_to have_received(:fetch) + end + + it 'is a no-op when recording_url is blank' do + expect { perform_service(recording_url: '') }.not_to change { call.reload.recording.attached? }.from(false) + expect(SafeFetch).not_to have_received(:fetch) + end + end +end diff --git a/spec/enterprise/services/voice/recording_status_service_spec.rb b/spec/enterprise/services/voice/recording_status_service_spec.rb new file mode 100644 index 000000000..3d9227709 --- /dev/null +++ b/spec/enterprise/services/voice/recording_status_service_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Voice::RecordingStatusService do + let(:account) { create(:account) } + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551237777') } + let(:inbox) { channel.inbox } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + let(:conference_sid) { 'CFabc123def456' } + let!(:call) do + create( + :call, + account: account, + inbox: inbox, + conversation: conversation, + contact: conversation.contact, + meta: { 'twilio_conference_sid' => conference_sid } + ) + end + + let(:recording_sid) { 'RE1234567890abcdef' } + let(:recording_url) { 'https://api.twilio.com/2010-04-01/Accounts/AC1/Recordings/RE1' } + let(:recording_duration) { '12' } + + let(:complete_payload) do + { + 'RecordingStatus' => 'completed', + 'ConferenceSid' => conference_sid, + 'RecordingSid' => recording_sid, + 'RecordingUrl' => recording_url, + 'RecordingDuration' => recording_duration + } + end + + before do + allow(Twilio::VoiceWebhookSetupService).to receive(:new) + .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}")) + end + + describe '#perform' do + it 'enqueues the recording attachment job for the matching call' do + expect do + described_class.new(account: account, payload: complete_payload).perform + end.to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob) + .with(call.id, recording_sid, recording_url, recording_duration) + end + + it 'is a no-op when RecordingStatus is not completed' do + payload = complete_payload.merge('RecordingStatus' => 'in-progress') + + expect do + described_class.new(account: account, payload: payload).perform + end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob) + end + + it 'is a no-op when ConferenceSid is missing' do + payload = complete_payload.except('ConferenceSid') + + expect do + described_class.new(account: account, payload: payload).perform + end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob) + end + + it 'is a no-op when RecordingSid is missing' do + payload = complete_payload.except('RecordingSid') + + expect do + described_class.new(account: account, payload: payload).perform + end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob) + end + + it 'is a no-op when RecordingUrl is missing' do + payload = complete_payload.except('RecordingUrl') + + expect do + described_class.new(account: account, payload: payload).perform + end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob) + end + + it 'is a no-op when no Call matches the ConferenceSid' do + payload = complete_payload.merge('ConferenceSid' => 'CFunknown') + + expect do + described_class.new(account: account, payload: payload).perform + end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob) + end + end +end From 624c6c90fdaef1f952066a966fba3de513e85df5 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Mon, 4 May 2026 23:37:14 +0530 Subject: [PATCH 3/4] chore: sync GitHub security advisories to Linear (#14359) Sync new GHSA reports to Linear. Fixes https://linear.app/chatwoot/issue/CW-7006 --- .github/workflows/ghsa-linear-sync.yml | 97 ++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 .github/workflows/ghsa-linear-sync.yml diff --git a/.github/workflows/ghsa-linear-sync.yml b/.github/workflows/ghsa-linear-sync.yml new file mode 100644 index 000000000..961114bc9 --- /dev/null +++ b/.github/workflows/ghsa-linear-sync.yml @@ -0,0 +1,97 @@ +name: Sync GHSA advisories to Linear + +on: + schedule: + - cron: '0 4 * * *' # daily at 09:30 IST + workflow_dispatch: {} + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + security-events: read + steps: + - name: Fetch triage advisories + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api --paginate \ + -H "Accept: application/vnd.github+json" \ + "/repos/${{ github.repository }}/security-advisories?state=triage&per_page=100" \ + | jq -cs 'add | [.[] | { + ghsa_id, cve_id, summary, severity, state, html_url, + description, created_at, + cvss_score: .cvss.score, + reporter: ([.credits[]?.user.login] | first // "unknown") + }]' > advisories.json + echo "Fetched $(jq 'length' advisories.json) triage advisories" + + - name: Create Linear issues for new advisories + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} + LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }} + LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }} + run: | + created_count=0 + skipped_count=0 + failed_count=0 + while read -r advisory; do + ghsa_id=$(printf '%s' "$advisory" | jq -r '.ghsa_id') + summary=$(printf '%s' "$advisory" | jq -r '.summary') + severity=$(printf '%s' "$advisory" | jq -r '.severity // "unknown"') + cve_id=$(printf '%s' "$advisory" | jq -r '.cve_id // "n/a"') + cvss=$(printf '%s' "$advisory" | jq -r '.cvss_score // "n/a"') + reporter=$(printf '%s' "$advisory" | jq -r '.reporter') + html_url=$(printf '%s' "$advisory" | jq -r '.html_url') + created_date=$(printf '%s' "$advisory" | jq -r '.created_at' | cut -dT -f1) + description=$(printf '%s' "$advisory" | jq -r '.description // "No description provided."') + + existing=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n --arg q "$ghsa_id" '{query: "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) { nodes { id } } }", variables: {q: $q}}')" \ + | jq '.data.issues.nodes | length') + + if [ "${existing:-0}" -gt 0 ] 2>/dev/null; then + skipped_count=$((skipped_count+1)) + continue + fi + + priority=3 + case "$severity" in + critical) priority=1 ;; + high) priority=2 ;; + medium) priority=3 ;; + low) priority=4 ;; + esac + + title="[$ghsa_id] $summary" + body=$(printf '**GHSA:** %s\n**CVE:** %s\n**Severity:** %s (CVSS %s)\n**Reporter:** %s\n**Reported:** %s\n**Advisory:** %s\n\n---\n\n%s' \ + "$ghsa_id" "$cve_id" "$severity" "$cvss" "$reporter" "$created_date" "$html_url" "$description") + + success=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n \ + --arg title "$title" \ + --arg body "$body" \ + --arg teamId "$LINEAR_TEAM_ID" \ + --arg projectId "$LINEAR_PROJECT_ID" \ + --arg labelId "$LINEAR_LABEL_ID" \ + --argjson priority "$priority" \ + '{ + query: "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success } }", + variables: {input: {title: $title, description: $body, teamId: $teamId, projectId: $projectId, labelIds: [$labelId], priority: $priority}} + }')" | jq -r '.data.issueCreate.success // false') + + if [ "$success" = "true" ]; then + created_count=$((created_count+1)) + else + failed_count=$((failed_count+1)) + fi + done < <(jq -c '.[]' advisories.json) + echo "Created $created_count, skipped $skipped_count, failed $failed_count" + if [ "$failed_count" -gt 0 ]; then + exit 1 + fi From 21c0f4dc522b3e3bd1e16a68af46669edca9c37d Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 5 May 2026 11:49:28 +0700 Subject: [PATCH 4/4] fix(transcription): guard Whisper 25MB limit and zero temperature for stable output (#14335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two production-grade fixes to the existing audio transcription service. **Independent of the WhatsApp Calling work** — these affect every audio attachment that goes through Whisper (voice notes, call recordings, voicemails, etc.). ## Closes - [PLA-151 — PR-5: Recording Upload + Transcription Pipeline](https://linear.app/chatwoot/issue/PLA-151/pr-5-recording-upload-transcription-pipeline) ## Why this is needed ### 1. Whisper rejects payloads larger than 25 MB OpenAI's [Whisper API](https://platform.openai.com/docs/guides/speech-to-text) hard-caps file uploads at 25 MB. Long audio recordings — voice notes from chatty contacts, ~70+ min Opus call recordings — currently hit OpenAI with the full payload and 413 (\`Payload Too Large\`). The job retries via the existing \`Faraday::BadRequestError\` discard path, but the agent still sees a transcription failure for an attachment we knew was too big up front. This PR adds a pre-flight \`audio_too_large?\` check via the blob's \`byte_size\` and returns a controlled error without hitting OpenAI. The audio attachment is preserved (agents can still listen), only the transcription is skipped. ### 2. Whisper hallucinates on silence at non-zero temperature At \`temperature: 0.4\` (the previous value), Whisper produces well-documented hallucinated repeats on silence and near-silent segments — e.g. \`Oh, dear. Oh, dear. Oh, dear.\` filling the transcript. This shows up in real recordings whenever there's a hold or quiet moment. \`temperature: 0.0\` matches OpenAI's recommended default for transcription and eliminates the spirals. Reference: [openai/whisper#928](https://github.com/openai/whisper/discussions/928), [openai-python#1010](https://github.com/openai/openai-python/issues/1010). ## Are WhatsApp call recordings already handled? Yes — by the existing pipeline, **before this PR**: \`\`\` Browser MediaRecorder → upload_recording (PR-4) → @call.message.attachments.create!(file_type: :audio, ...) → Enterprise::Concerns::Attachment#enqueue_audio_transcription (after_create_commit hook) → Messages::AudioTranscriptionJob.perform_later(attachment.id) → Messages::AudioTranscriptionService → Whisper \`\`\` The \`after_create_commit\` hook already fires for every audio attachment regardless of source. PR-4's \`upload_recording\` endpoint creates the attachment; the existing job/service take it from there. No new wiring needed. This PR just makes the existing service more robust: - Calls longer than ~70 min (Opus 48 kbps) no longer 413 against OpenAI - Quiet recordings no longer produce hallucinated transcripts ## How to test \`\`\`ruby # In rails console with a real audio attachment: service = Messages::AudioTranscriptionService.new(Attachment.audio.last) # Normal-sized audio: unchanged behaviour service.perform # => { success: true, transcriptions: ... } # Large audio: new guard returns error instead of 413-ing OpenAI allow(attachment.file.blob).to receive(:byte_size).and_return(30.megabytes) service.perform # => { error: 'Audio too large for Whisper' } \`\`\` Existing transcription specs cover the happy path; one new spec exercises the byte-limit guard. ## Risk Low. Both changes are pre-flight guards or parameter values — they reduce the surface of OpenAI calls that can fail. Failure to transcribe is already non-fatal (the audio attachment is preserved either way). --- .../messages/audio_transcription_service.rb | 17 ++++++++++++++++- .../audio_transcription_service_spec.rb | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb index 0e574cb03..c502b717a 100644 --- a/enterprise/app/services/messages/audio_transcription_service.rb +++ b/enterprise/app/services/messages/audio_transcription_service.rb @@ -2,6 +2,10 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService include Integrations::LlmInstrumentation WHISPER_MODEL = 'whisper-1'.freeze + # Whisper's hard limit is 25 MB *decimal* (25_000_000), not binary (25.megabytes + # = 26_214_400) — using the binary form leaks the 25.0–26.2 MB range to the API + # as 413s. Long audio (~70+ min Opus) keeps the attachment but skips transcription. + WHISPER_BYTE_LIMIT = 25_000_000 attr_reader :attachment, :message, :account @@ -15,6 +19,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService def perform return { error: 'Transcription limit exceeded' } unless can_transcribe? return { error: 'Message not found' } if message.blank? + return { error: 'Audio too large for Whisper' } if audio_too_large? transcriptions = transcribe_audio Rails.logger.info "Audio transcription successful: #{transcriptions}" @@ -33,6 +38,13 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService account.usage_limits[:captain][:responses][:current_available].positive? end + def audio_too_large? + blob = attachment.file&.blob + return false unless blob + + blob.byte_size > WHISPER_BYTE_LIMIT + end + def fetch_audio_file blob = attachment.file.blob temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions') @@ -63,11 +75,14 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService transcribed_text = nil File.open(temp_file_path, 'rb') do |file| + # temperature: 0.0 minimises Whisper's hallucinations on silence / + # near-silent audio; non-zero values trigger spiraling repeats like + # "Oh, dear. Oh, dear. Oh, dear." — well-documented Whisper behaviour. response = @client.audio.transcribe( parameters: { model: WHISPER_MODEL, file: file, - temperature: 0.4 + temperature: 0.0 } ) transcribed_text = response['text'] diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb index 7ece2540a..212c0bf01 100644 --- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb +++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb @@ -63,6 +63,23 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do expect(result).to eq({ success: true, transcriptions: 'Existing transcription' }) end end + + context 'when the audio exceeds Whisper byte limit' do + before do + attachment.file.attach( + io: File.open(Rails.public_path.join('audio/widget/ding.mp3')), + filename: 'large.mp3', + content_type: 'audio/mpeg' + ) + allow(service).to receive(:can_transcribe?).and_return(true) + allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::WHISPER_BYTE_LIMIT + 1) + end + + it 'returns an error without calling Whisper' do + expect(service).not_to receive(:transcribe_audio) + expect(service.perform).to eq({ error: 'Audio too large for Whisper' }) + end + end end describe '#fetch_audio_file' do