Merge branch 'develop' into fix/CW-6944
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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.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']
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user