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/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 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: 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/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/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/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 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