Merge branch 'develop' into fix/CW-6944

This commit is contained in:
Sivin Varghese
2026-05-05 10:50:28 +05:30
committed by GitHub
22 changed files with 588 additions and 5 deletions
+3
View File
@@ -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
+3
View File
@@ -8,6 +8,9 @@ on:
branches:
- develop
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-22.04
+97
View File
@@ -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
@@ -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
+3
View File
@@ -14,6 +14,9 @@ on:
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
nightly:
runs-on: ubuntu-24.04
@@ -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
+3
View File
@@ -18,6 +18,9 @@ on:
env:
DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs:
build:
strategy:
@@ -18,6 +18,9 @@ on:
env:
DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs:
build:
strategy:
+3
View File
@@ -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
+3
View File
@@ -7,6 +7,9 @@ on:
- master
workflow_dispatch:
permissions:
contents: read
jobs:
test-build:
strategy:
@@ -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 () => {
<template>
<BaseBubble class="p-0 border-none" hide-meta>
<div class="flex overflow-hidden flex-col w-full max-w-xs">
<div class="flex overflow-hidden flex-col w-full max-w-sm">
<div class="flex gap-3 items-center p-3 w-full">
<div
class="flex justify-center items-center rounded-full size-10 shrink-0"
@@ -169,7 +186,7 @@ const handleJoinCall = async () => {
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
</span>
<span class="text-xs text-n-slate-11">
<span v-if="subtext" class="text-xs text-n-slate-11">
{{ subtext }}
</span>
<button
@@ -183,6 +200,10 @@ const handleJoinCall = async () => {
</button>
</div>
</div>
<div v-if="recordingAttachment" class="px-3 pb-3">
<AudioChip :attachment="recordingAttachment" />
</div>
</div>
</BaseBubble>
</template>
@@ -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.
+1
View File
@@ -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
@@ -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}"
@@ -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
+2 -1
View File
@@ -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)
@@ -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.026.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']
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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