From 624c6c90fdaef1f952066a966fba3de513e85df5 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Mon, 4 May 2026 23:37:14 +0530 Subject: [PATCH 1/8] 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 2/8] 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 From 6386eec5e7683f64ea6c944f8738d101612c04cd Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 5 May 2026 12:55:53 +0530 Subject: [PATCH 3/8] fix: regex validation not applied for custom text attributes in UI (#14110) # Pull Request Template ## Description This PR fixes multiple issues related to regex patterns and validation for custom attributes. 1. Fixed regex patterns being double-escaped when saving from Add and Edit flows 2. Fixed regex validation not being enforced in the widget pre-chat form 3. Minor UI improvements in the Add/Edit custom attribute dialog Fixes [CW-6625](https://linear.app/chatwoot/issue/CW-6625/bug-report-custom-attribute-regex-validation-not-working-in-ui), https://github.com/chatwoot/chatwoot/issues/13771 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? **Loom video** **Before** https://www.loom.com/share/14f1983a8bc84f9fabc3663afd83cd50 **After** https://www.loom.com/share/867c0484741140c1944fcbd43914c9c0 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../CustomAttributes/OtherAttribute.vue | 6 +- .../dashboard/components/CustomAttribute.vue | 20 +++--- .../settings/attributes/AddAttribute.vue | 22 ++---- .../settings/attributes/EditAttribute.vue | 21 ++---- app/javascript/shared/helpers/Validators.js | 16 +++++ .../helpers/specs/ValidatorsHelper.spec.js | 25 +++++++ .../widget/components/PreChat/Form.vue | 72 ++++++++++--------- 7 files changed, 109 insertions(+), 73 deletions(-) diff --git a/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue b/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue index 7f0379b9a..913e3e381 100644 --- a/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue +++ b/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue @@ -49,7 +49,11 @@ const rules = computed(() => ({ props.attribute.regexPattern && { regexValidation: value => { if (!value) return true; - return getRegexp(props.attribute.regexPattern).test(value); + try { + return getRegexp(props.attribute.regexPattern).test(value); + } catch { + return false; + } }, }), }, diff --git a/app/javascript/dashboard/components/CustomAttribute.vue b/app/javascript/dashboard/components/CustomAttribute.vue index 8f70a9fa2..232cc9aac 100644 --- a/app/javascript/dashboard/components/CustomAttribute.vue +++ b/app/javascript/dashboard/components/CustomAttribute.vue @@ -102,13 +102,14 @@ export default { return this.v$.editedValue.$error; }, errorMessage() { - if (this.v$.editedValue.url) { + if (this.v$.editedValue.url?.$invalid) { return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL'); } - if (!this.v$.editedValue.regexValidation) { - return this.regexCue - ? this.regexCue - : this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT'); + if (this.v$.editedValue.regexValidation?.$invalid) { + return ( + this.regexCue || + this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT') + ); } return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED'); }, @@ -134,9 +135,12 @@ export default { editedValue: { required, regexValidation: value => { - return !( - this.attributeRegex && !getRegexp(this.attributeRegex).test(value) - ); + if (!this.attributeRegex || !value) return true; + try { + return getRegexp(this.attributeRegex).test(value); + } catch { + return false; + } }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue index 9b097f6a1..8d85d56af 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue @@ -4,6 +4,7 @@ import { required, minLength } from '@vuelidate/validators'; import { mapGetters } from 'vuex'; import { useAlert } from 'dashboard/composables'; import { convertToAttributeSlug } from 'dashboard/helper/commons.js'; +import { normalizeRegexPattern } from 'shared/helpers/Validators'; import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants'; import NextButton from 'dashboard/components-next/button/Button.vue'; @@ -99,19 +100,10 @@ export default { }, validations: { - displayName: { - required, - minLength: minLength(1), - }, - description: { - required, - }, - attributeModel: { - required, - }, - attributeType: { - required, - }, + displayName: { required, minLength: minLength(1) }, + description: { required }, + attributeModel: { required }, + attributeType: { required }, attributeKey: { required, isKey(value) { @@ -151,9 +143,7 @@ export default { attribute_display_type: this.attributeType, attribute_key: this.attributeKey, attribute_values: this.attributeListValues, - regex_pattern: this.regexPattern - ? new RegExp(this.regexPattern).toString() - : null, + regex_pattern: normalizeRegexPattern(this.regexPattern), regex_cue: this.regexCue, }); this.alertMessage = this.$t('ATTRIBUTES_MGMT.ADD.API.SUCCESS_MESSAGE'); diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue index 35cba6a86..308ea35c7 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue @@ -2,7 +2,7 @@ import { useVuelidate } from '@vuelidate/core'; import { useAlert } from 'dashboard/composables'; import { required, minLength } from '@vuelidate/validators'; -import { getRegexp } from 'shared/helpers/Validators'; +import { getRegexp, normalizeRegexPattern } from 'shared/helpers/Validators'; import { ATTRIBUTE_TYPES } from './constants'; import NextButton from 'dashboard/components-next/button/Button.vue'; import TagInput from 'dashboard/components-next/taginput/TagInput.vue'; @@ -41,16 +41,9 @@ export default { }; }, validations: { - displayName: { - required, - }, - attributeType: { - required, - }, - description: { - required, - minLength: minLength(1), - }, + displayName: { required }, + attributeType: { required }, + description: { required, minLength: minLength(1) }, attributeKey: { required, isKey(value) { @@ -118,7 +111,7 @@ export default { }, setFormValues() { const regexPattern = this.selectedAttribute.regex_pattern - ? getRegexp(this.selectedAttribute.regex_pattern).source + ? getRegexp(this.selectedAttribute.regex_pattern).toString() : null; this.displayName = this.selectedAttribute.attribute_display_name; this.description = this.selectedAttribute.attribute_description; @@ -144,9 +137,7 @@ export default { attribute_description: this.description, attribute_display_name: this.displayName, attribute_values: this.updatedAttributeListValues, - regex_pattern: this.regexPattern - ? new RegExp(this.regexPattern).toString() - : null, + regex_pattern: normalizeRegexPattern(this.regexPattern), regex_cue: this.regexCue, }); this.alertMessage = this.$t('ATTRIBUTES_MGMT.EDIT.API.SUCCESS_MESSAGE'); diff --git a/app/javascript/shared/helpers/Validators.js b/app/javascript/shared/helpers/Validators.js index 1c10121f1..a3547a5ca 100644 --- a/app/javascript/shared/helpers/Validators.js +++ b/app/javascript/shared/helpers/Validators.js @@ -101,6 +101,22 @@ export const getRegexp = regexPatternValue => { ); }; +/** + * Normalises a user-entered regex pattern into canonical `/source/flags` form. + * Strips `/.../flags` wrapping if the user included it, so `new RegExp` does + * not double-escape the slashes on save. + * + * @param {string} pattern - Raw pattern string from the form. + * @returns {?string} Canonical `/source/flags` string, or null when empty. + */ +export const normalizeRegexPattern = pattern => { + if (!pattern) return null; + const match = pattern.match(/^\/(.+)\/([gimsuy]*)$/); + const source = match ? match[1] : pattern; + const flags = match ? match[2] : ''; + return new RegExp(source, flags).toString(); +}; + /** * Checks if a string is a valid slug (letters, numbers, hyphens only, no spaces or other symbols). * @param {string} value - The slug to validate. diff --git a/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js b/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js index 4546b43a8..62c8f8bee 100644 --- a/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js +++ b/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js @@ -9,6 +9,7 @@ import { isNumber, isDomain, getRegexp, + normalizeRegexPattern, isValidSlug, } from '../Validators'; @@ -155,6 +156,30 @@ describe('#getRegexp', () => { }); }); +describe('#normalizeRegexPattern', () => { + it('returns null for empty values', () => { + expect(normalizeRegexPattern('')).toBeNull(); + expect(normalizeRegexPattern(null)).toBeNull(); + expect(normalizeRegexPattern(undefined)).toBeNull(); + }); + + it('canonicalises a bare source', () => { + expect(normalizeRegexPattern('^[0-9]+$')).toBe('/^[0-9]+$/'); + }); + + it('strips slash wrapping the user may include', () => { + expect(normalizeRegexPattern('/^[0-9]+$/')).toBe('/^[0-9]+$/'); + }); + + it('preserves flags on wrapped input', () => { + expect(normalizeRegexPattern('/hello/gi')).toBe('/hello/gi'); + }); + + it('throws for an invalid regex source', () => { + expect(() => normalizeRegexPattern('[')).toThrow(); + }); +}); + describe('#isValidSlug', () => { it('should return true for valid slugs', () => { expect(isValidSlug('abc')).toEqual(true); diff --git a/app/javascript/widget/components/PreChat/Form.vue b/app/javascript/widget/components/PreChat/Form.vue index 2868d6c3c..280f08363 100644 --- a/app/javascript/widget/components/PreChat/Form.vue +++ b/app/javascript/widget/components/PreChat/Form.vue @@ -140,24 +140,15 @@ export default { }, }, methods: { - labelClass(input) { - const { state } = input.context; - const hasErrors = state.invalid; - return !hasErrors ? 'text-n-slate-12' : 'text-n-ruby-10'; - }, inputClass(input) { const { state, family: classification, type } = input.context; - const hasErrors = state.invalid; if (classification === 'box' && type === 'checkbox') { return ''; } if (type === 'phoneInput') { - this.hasErrorInPhoneInput = hasErrors; + this.hasErrorInPhoneInput = state.invalid; } - if (!hasErrors) { - return `mt-1 rounded w-full py-2 px-3`; - } - return `mt-1 rounded w-full py-2 px-3 error`; + return 'mt-1 rounded w-full py-2 px-3'; }, isContactFieldRequired(field) { return this.preChatFields.find(option => option.name === field).required; @@ -176,7 +167,12 @@ export default { return this.formValues[name] || null; }, getValidation({ type, name, field_type, regex_pattern }) { - let regex = regex_pattern ? getRegexp(regex_pattern) : null; + const regex = regex_pattern ? getRegexp(regex_pattern) : null; + // FormKit caches the RegExp and calls .test() across keystrokes, so + // drop stateful g/y flags to stop lastIndex mutation flipping validity. + const matchRegex = regex + ? new RegExp(regex.source, regex.flags.replace(/[gy]/g, '')) + : null; const validations = { emailAddress: 'email', phoneNumber: ['startsWithPlus', 'isValidPhoneNumber'], @@ -186,27 +182,32 @@ export default { select: null, number: null, checkbox: false, - contact_attribute: regex ? [['matches', regex]] : null, - conversation_attribute: regex ? [['matches', regex]] : null, + contact_attribute: matchRegex ? [['matches', matchRegex]] : null, + conversation_attribute: matchRegex ? [['matches', matchRegex]] : null, }; const validationKeys = Object.keys(validations); const isRequired = this.isContactFieldRequired(name); - const validation = isRequired ? ['required'] : ['optional']; + const baseRules = isRequired ? [['required']] : [['optional']]; if ( - validationKeys.includes(name) || - validationKeys.includes(type) || - validationKeys.includes(field_type) + !validationKeys.includes(name) && + !validationKeys.includes(type) && + !validationKeys.includes(field_type) ) { - const validationType = - validations[type] || validations[name] || validations[field_type]; - const allValidations = validationType - ? validation.concat(validationType) - : validation; - return allValidations.join('|'); + return ''; } - return ''; + const validationType = + validations[type] || validations[name] || validations[field_type]; + if (!validationType) return baseRules; + + // Normalise into array-of-arrays so RegExp objects in `['matches', regex]` + // survive without being stringified by FormKit. + const extraRules = Array.isArray(validationType) + ? validationType.map(rule => (Array.isArray(rule) ? rule : [rule])) + : [[validationType]]; + + return baseRules.concat(extraRules); }, findFieldType(type) { if (type === 'link') { @@ -283,7 +284,7 @@ export default { } : undefined " - :label-class="context => `text-sm font-medium ${labelClass(context)}`" + label-class="text-sm font-medium text-n-slate-12" :input-class="context => inputClass(context)" :validation-messages="{ startsWithPlus: $t( @@ -302,7 +303,7 @@ export default { v-if="!hasActiveCampaign" name="message" type="textarea" - :label-class="context => `text-sm font-medium ${labelClass(context)}`" + label-class="text-sm font-medium text-n-slate-12" :input-class="context => inputClass(context)" :label="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.LABEL')" :placeholder="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.PLACEHOLDER')" @@ -330,16 +331,21 @@ export default { @apply mt-2; .formkit-inner { - input.error, - textarea.error, - select.error { - @apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 focus:outline-n-ruby-9 dark:focus:outline-n-ruby-9; - } - input[type='checkbox'] { @apply size-4 outline-none; } } + + &[data-invalid] { + .formkit-label { + @apply text-n-ruby-10; + } + .formkit-inner input, + .formkit-inner textarea, + .formkit-inner select { + @apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 focus:outline-n-ruby-9 dark:focus:outline-n-ruby-9; + } + } } [data-invalid] .formkit-message { From c1d167bd647297a5995e627a7dac56c6cfce4e16 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 5 May 2026 13:00:27 +0530 Subject: [PATCH 4/8] fix: prevent `--` signature delimiter rendering as `\` in bubble (#14134) # Pull Request Template ## Description Fixes https://linear.app/chatwoot/issue/CW-6903/signature-delimiter-renders-as-h2-when-using-enter-line-before **1**. Fixes an issue where the signature delimiter `--` gets parsed as an H2 when using **Enter** (new paragraph) before or after it, causing it to render as a bold `\` in the message bubble. * Ensures `--` renders as plain text * Aligns renderer with parser behavior (both disable `lheading`) * Prevents stray `\` from appearing as heading text **2**. Also fixes a related editor issue where toggling signature **off** leaves behind a stray `\` or `-- \`. * Strips blank paragraph markers (`\`) and dangling hard breaks (`\`) from ProseMirror serializer * Applied in both `appendSignature` and `removeSignature` * Replaces `trimEnd()` with shared helpers (`trimTrailingBlanks` / `stripTrailingBlankMarkers`) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? #### Screenshots **Before** image **After** image **Editor** https://linear.app/chatwoot/issue/CW-6903/signature-delimiter-renders-as-in-h2-when-using-enter-line#comment-5814b882 ### Steps #### Editor 1. Enable agent signature 2. Add and remove new lines around the signature using Enter/shift enter 3. Toggle signature off 4. Notice stray `\` or `-- \` remains #### Bubble 1. Enable agent signature 2. Send a message using Enter between lines 3. Verify `--` renders correctly (no H2, no bold `\`) ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth --- .../dashboard/helper/editorHelper.js | 15 ++++++++- .../helper/specs/editorHelper.spec.js | 32 +++++++++++++++++++ .../shared/helpers/MessageFormatter.js | 1 + .../helpers/specs/MessageFormatter.spec.js | 7 ++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index 9839a04e0..491dd9d44 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -123,6 +123,13 @@ export function cleanSignature(signature) { } } +// Strip `\` hardbreak markers trailing `--` after a signature slice +const stripDelimiterHardbreaks = body => + body.replace(/(--)\s*(?:\\\s*)+$/, '$1'); + +// Strip standalone blank-paragraph markers (`\` on their own lines). +const stripTrailingBlankLine = body => body.replace(/\n(?:\s*\\\n)+$/, ''); + /** * Adds the signature delimiter to the beginning of the signature. * @@ -227,13 +234,19 @@ export function removeSignature(body, signature, channelType) { // trimming will ensure any spaces or new lines before the signature are removed // This means we will have the delimiter at the end if (signatureIndex > -1) { - newBody = newBody.substring(0, signatureIndex).trimEnd(); + newBody = stripDelimiterHardbreaks( + newBody.substring(0, signatureIndex) + ).trimEnd(); } // Remove delimiter if it's at the end if (newBody.endsWith(SIGNATURE_DELIMITER)) { // if the delimiter is at the end, remove it newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length); + // strip any trailing blank-line markers + if (signatureIndex > -1) { + newBody = stripTrailingBlankLine(newBody); + } } return newBody; diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index b06c36d42..c7822bde7 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -336,6 +336,38 @@ describe('removeSignature', () => { 'This is a test\n\n' ); }); + it('strips blank-paragraph marker before the delimiter', () => { + expect(removeSignature('hey\n\n\\\n--\n\nHello there', 'Hello there')).toBe( + 'hey' + ); + }); + it('strips multiple consecutive blank-paragraph markers before the delimiter', () => { + expect( + removeSignature('wewe\n\n\\\n\\\n\\\n--\n\nHello there', 'Hello there') + ).toBe('wewe'); + }); + it('strips dangling hardbreak when signature shared a paragraph with "--"', () => { + expect(removeSignature('hey\n\n--\\\nHello there', 'Hello there')).toBe( + 'hey\n\n' + ); + }); + it('preserves trailing backslash in user text when appending', () => { + expect(appendSignature('The path is C:\\', 'Best\nAgent')).toContain( + 'C:\\' + ); + expect(appendSignature('C:\\\n', 'Best\nAgent')).toContain('C:\\'); + expect(appendSignature('C:\\\n\n', 'Best\nAgent')).toContain('C:\\'); + }); + it('preserves trailing backslash in user text when removing', () => { + expect(removeSignature('C:\\\n--\n\nBest\nAgent', 'Best\nAgent')).toContain( + 'C:\\' + ); + expect(removeSignature('C:\\\n--', 'no matching sig')).toContain('C:\\'); + expect(removeSignature('C:\\\nBest\\\nAgent', 'Best\nAgent')).toContain( + 'C:\\' + ); + expect(removeSignature('notes\n\\\n--', 'no matching sig')).toContain('\\'); + }); }); describe('removeSignature with stripped signature', () => { diff --git a/app/javascript/shared/helpers/MessageFormatter.js b/app/javascript/shared/helpers/MessageFormatter.js index c87209fd4..825132e13 100644 --- a/app/javascript/shared/helpers/MessageFormatter.js +++ b/app/javascript/shared/helpers/MessageFormatter.js @@ -42,6 +42,7 @@ const createMarkdownInstance = (linkify = true) => { quotes: '\u201c\u201d\u2018\u2019', maxNesting: 20, }) + .disable(['lheading']) .use(mentionPlugin) .use(imgResizeManager) .use(mila, { diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js index a685cb0da..12b84085c 100644 --- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js +++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js @@ -33,6 +33,13 @@ describe('#MessageFormatter', () => {

tool

` ); }); + + it('should not render a setext heading when text is followed by "--"', () => { + const message = 'hy\n\n\\\n\\-\\-\n\nHello there'; + const result = new MessageFormatter(message).formattedMessage; + expect(result).not.toMatch('

'); + expect(result).not.toMatch('

'); + }); }); describe('content with image and has "cw_image_height" query at the end of URL', () => { From 8cc36e19382943830c3f392a7c64f9c8208d7789 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 5 May 2026 14:16:24 +0530 Subject: [PATCH 5/8] feat: inline url embeds in article editor (#14284) --- .../widgets/WootWriter/FullEditor.vue | 16 ++++ .../dashboard/helper/markdownEmbeds.js | 11 +++ package.json | 3 +- pnpm-lock.yaml | 81 +++++++++++++++---- vite.config.ts | 5 +- 5 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 app/javascript/dashboard/helper/markdownEmbeds.js diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue index 1e1ab9756..8bd3e9457 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue @@ -13,6 +13,9 @@ import { triggerCharacters, } from '@chatwoot/prosemirror-schema/src/mentions/plugin'; import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image'; +import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview'; +import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph'; +import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds'; import { toggleMark } from 'prosemirror-commands'; import { wrapInList } from 'prosemirror-schema-list'; import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common'; @@ -77,6 +80,8 @@ export default { plugins: [ imagePastePlugin(this.handleImageUpload), this.createSlashPlugin(), + embedPreviewPlugin(markdownEmbeds), + trailingParagraphPlugin(), ], isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection showSlashMenu: false, @@ -113,6 +118,12 @@ export default { this.focusEditorInputField(); } }, + beforeUnmount() { + if (editorView) { + editorView.destroy(); + editorView = null; + } + }, methods: { createSlashPlugin() { return suggestionsPlugin({ @@ -488,4 +499,9 @@ export default { max-height: 7.5rem; overflow: auto; } + +.ProseMirror .cw-embed-preview { + max-width: 36rem; + margin: 0.5rem 0 1rem; +} diff --git a/app/javascript/dashboard/helper/markdownEmbeds.js b/app/javascript/dashboard/helper/markdownEmbeds.js new file mode 100644 index 000000000..116290220 --- /dev/null +++ b/app/javascript/dashboard/helper/markdownEmbeds.js @@ -0,0 +1,11 @@ +import config from '../../../../config/markdown_embeds.yml'; + +// Gists rely on document.write() and can't render inline in the editor. +const NON_PREVIEWABLE_EMBEDS = new Set(['github_gist']); + +export const embeds = Object.entries(config) + .filter(([key]) => !NON_PREVIEWABLE_EMBEDS.has(key)) + .map(([, { regex, template }]) => ({ + regex: new RegExp(regex), + template, + })); diff --git a/package.json b/package.json index 8d45183e0..bb5b59ba3 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.3.10", + "@chatwoot/prosemirror-schema": "1.3.11", "@chatwoot/utils": "^0.0.52", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", @@ -121,6 +121,7 @@ "@iconify-json/ri": "^1.2.6", "@iconify-json/teenyicons": "^1.2.2", "@intlify/eslint-plugin-vue-i18n": "^3.2.0", + "@rollup/plugin-yaml": "^4.1.2", "@size-limit/file": "^8.2.4", "@vitest/coverage-v8": "3.0.5", "@vue/test-utils": "^2.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab9e7b241..2a3aee897 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,8 +26,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.10 - version: 1.3.10 + specifier: 1.3.11 + version: 1.3.11 '@chatwoot/utils': specifier: ^0.0.52 version: 0.0.52 @@ -281,6 +281,9 @@ importers: '@intlify/eslint-plugin-vue-i18n': specifier: ^3.2.0 version: 3.2.0(eslint@8.57.0) + '@rollup/plugin-yaml': + specifier: ^4.1.2 + version: 4.1.2(rollup@4.59.0) '@size-limit/file': specifier: ^8.2.4 version: 8.2.6(size-limit@8.2.6) @@ -460,8 +463,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.3.10': - resolution: {integrity: sha512-MtOXqFPHptFHu/AoIPhQ9TskVXOxOXCgBY/tgAtCAQRut978F7I3QxozQBECBz83ubsoXnBecpNjGNq0OPgONw==} + '@chatwoot/prosemirror-schema@1.3.11': + resolution: {integrity: sha512-+GptIqY73/EtojrhAKX4UKwZF7NUB47DMzgYxmx8Vu07DLKCGe+8JnbHJnR9oBy8p5sn5VOO1ihNf/4Pg2RzIQ==} '@chatwoot/utils@0.0.52': resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==} @@ -1122,6 +1125,24 @@ packages: '@rails/ujs@7.1.400': resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==} + '@rollup/plugin-yaml@4.1.2': + resolution: {integrity: sha512-RpupciIeZMUqhgFE97ba0s98mOFS7CWzN3EJNhJkqSv9XLlWYtwVdtE6cDw6ASOF/sZVFS7kRJXftaqM2Vakdw==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: '>=4.59.0' + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: '>=4.59.0' + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] @@ -3016,8 +3037,8 @@ packages: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true jsdom@20.0.3: @@ -3551,6 +3572,10 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -4315,6 +4340,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tosource@2.0.0-alpha.3: + resolution: {integrity: sha512-KAB2lrSS48y91MzFPFuDg4hLbvDiyTjOVgaK7Erw+5AmZXNq4sFRVn8r6yxSLuNs15PaokrDRpS61ERY9uZOug==} + engines: {node: '>=10'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -4976,7 +5005,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.3.10': + '@chatwoot/prosemirror-schema@1.3.11': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.7.1 @@ -5328,7 +5357,7 @@ snapshots: globals: 13.24.0 ignore: 5.2.4 import-fresh: 3.3.0 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -5342,7 +5371,7 @@ snapshots: globals: 14.0.0 ignore: 5.2.4 import-fresh: 3.3.0 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -5547,7 +5576,7 @@ snapshots: ignore: 6.0.2 import-fresh: 3.3.0 is-language-code: 3.1.0 - js-yaml: 4.1.0 + js-yaml: 4.1.1 json5: 2.2.3 jsonc-eslint-parser: 2.4.0 lodash: 4.17.21 @@ -5689,6 +5718,22 @@ snapshots: '@rails/ujs@7.1.400': {} + '@rollup/plugin-yaml@4.1.2(rollup@4.59.0)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + js-yaml: 4.1.1 + tosource: 2.0.0-alpha.3 + optionalDependencies: + rollup: 4.59.0 + + '@rollup/pluginutils@5.3.0(rollup@4.59.0)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.59.0 + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -7184,7 +7229,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 - js-yaml: 4.1.0 + js-yaml: 4.1.1 json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 @@ -7851,7 +7896,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -8421,6 +8466,8 @@ snapshots: picomatch@2.3.1: {} + picomatch@4.0.4: {} + pidtree@0.6.0: {} pify@2.3.0: {} @@ -8723,7 +8770,7 @@ snapshots: prosemirror-dropcursor@1.8.1: dependencies: prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 prosemirror-view: 1.34.1 prosemirror-gapcursor@1.3.2: @@ -8736,14 +8783,14 @@ snapshots: prosemirror-history@1.4.1: dependencies: prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 prosemirror-view: 1.34.1 rope-sequence: 1.3.2 prosemirror-inputrules@1.4.0: dependencies: prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 prosemirror-keymap@1.2.2: dependencies: @@ -8783,7 +8830,7 @@ snapshots: prosemirror-keymap: 1.2.2 prosemirror-model: 1.22.3 prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 prosemirror-view: 1.34.1 prosemirror-transform@1.10.0: @@ -9308,6 +9355,8 @@ snapshots: dependencies: is-number: 7.0.0 + tosource@2.0.0-alpha.3: {} + totalist@3.0.1: {} tough-cookie@4.1.4: diff --git a/vite.config.ts b/vite.config.ts index e06b0f1fb..17d47fc76 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,6 +22,7 @@ import { defineConfig } from 'vite'; import ruby from 'vite-plugin-ruby'; import path from 'path'; import vue from '@vitejs/plugin-vue'; +import yaml from '@rollup/plugin-yaml'; const isLibraryMode = process.env.BUILD_MODE === 'library'; const isTestMode = process.env.TEST === 'true'; @@ -34,12 +35,12 @@ const vueOptions = { }, }; -let plugins = [ruby(), vue(vueOptions)]; +let plugins = [ruby(), vue(vueOptions), yaml()]; if (isLibraryMode) { plugins = []; } else if (isTestMode) { - plugins = [vue(vueOptions)]; + plugins = [vue(vueOptions), yaml()]; } export default defineConfig({ From 70f799ab355c1200dc6b7dea4f3039b0db477b08 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 5 May 2026 14:34:31 +0530 Subject: [PATCH 6/8] fix(captain): add v1 handoff classifier [AI-137] (#14337) # Pull Request Template ## Description Captain (v1) makes false promises by saying it will handoff but doesn't. This happens due to an exact string match comparison and the prompt gives the model a lot of responsibilities: - identity - what to respond - obey custom instructions - decide on tool calls This PR decouples responsibility, the core prompt responds, and an additional llm call evaluates if handoff was needed or not after that message. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Locally ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --- config/features.yml | 7 +- ...4_flag_for_captain_v1_action_classifier.rb | 15 ++ db/schema.rb | 2 +- .../helpers/captain/chat_response_helper.rb | 5 +- .../conversation/response_builder_job.rb | 35 ++++- .../conversation/v1_action_classifier.rb | 57 +++++++ .../assistant_action_classifier_service.rb | 148 ++++++++++++++++++ .../captain/llm/system_prompts_service.rb | 71 ++++++++- .../lib/captain/assistant_action_schema.rb | 20 +++ .../conversation/response_builder_job_spec.rb | 106 +++++++++++++ ...ssistant_action_classifier_service_spec.rb | 95 +++++++++++ .../llm/assistant_chat_service_spec.rb | 25 +++ 12 files changed, 579 insertions(+), 7 deletions(-) create mode 100644 db/migrate/20260430114500_repurpose_report_v4_flag_for_captain_v1_action_classifier.rb create mode 100644 enterprise/app/jobs/captain/conversation/v1_action_classifier.rb create mode 100644 enterprise/app/services/captain/llm/assistant_action_classifier_service.rb create mode 100644 enterprise/lib/captain/assistant_action_schema.rb create mode 100644 spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb diff --git a/config/features.yml b/config/features.yml index f16199932..92d004f81 100644 --- a/config/features.yml +++ b/config/features.yml @@ -144,10 +144,11 @@ - name: chatwoot_v4 display_name: Chatwoot V4 enabled: true -- name: report_v4 - display_name: Report V4 +- name: captain_v1_action_classifier + display_name: Captain V1 Action Classifier enabled: false - deprecated: true + premium: true + chatwoot_internal: true - name: contact_chatwoot_support_team display_name: Contact Chatwoot Support Team enabled: true diff --git a/db/migrate/20260430114500_repurpose_report_v4_flag_for_captain_v1_action_classifier.rb b/db/migrate/20260430114500_repurpose_report_v4_flag_for_captain_v1_action_classifier.rb new file mode 100644 index 000000000..8f7056ba7 --- /dev/null +++ b/db/migrate/20260430114500_repurpose_report_v4_flag_for_captain_v1_action_classifier.rb @@ -0,0 +1,15 @@ +class RepurposeReportV4FlagForCaptainV1ActionClassifier < ActiveRecord::Migration[7.1] + def up + Account.feature_captain_v1_action_classifier.find_each(batch_size: 100) do |account| + account.disable_features(:captain_v1_action_classifier) + account.save!(validate: false) + end + + config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS') + return if config&.value.blank? + + config.value = config.value.reject { |feature| feature['name'] == 'report_v4' } + config.save! + GlobalConfig.clear_cache + end +end diff --git a/db/schema.rb b/db/schema.rb index 4ff250ada..9ce734ba7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do +ActiveRecord::Schema[7.1].define(version: 2026_04_30_114500) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" diff --git a/enterprise/app/helpers/captain/chat_response_helper.rb b/enterprise/app/helpers/captain/chat_response_helper.rb index bfb8adc11..e8ac1044c 100644 --- a/enterprise/app/helpers/captain/chat_response_helper.rb +++ b/enterprise/app/helpers/captain/chat_response_helper.rb @@ -35,7 +35,10 @@ module Captain::ChatResponseHelper def credit_used_for_response?(parsed_response) response = parsed_response['response'] - response.present? && response != 'conversation_handoff' + + # The classifier can still decide to hand off after this trace is written. + # Actual response usage is charged later in ResponseBuilderJob, so billing stays correct. + response.present? && response != 'conversation_handoff' && parsed_response['action'] != 'handoff' end def captain_v1_assistant? diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index 5e7c5b3c2..5050f11b2 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -1,4 +1,6 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob + include Captain::Conversation::V1ActionClassifier + MAX_MESSAGE_LENGTH = 10_000 retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds @@ -31,9 +33,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob delegate :account, :inbox, to: :@conversation def generate_and_process_response + message_history = collect_previous_messages @response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response( - message_history: collect_previous_messages + message_history: message_history ) + classify_v1_response_action(message_history) if conversation_pending? process_response end @@ -102,6 +106,14 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob end def v1_handoff_requested? + legacy_v1_handoff_token? || classifier_v1_handoff_requested? + end + + def classifier_v1_handoff_requested? + @response['action'] == 'handoff' + end + + def legacy_v1_handoff_token? @response['response'] == 'conversation_handoff' end @@ -111,8 +123,13 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob def process_v1_handoff I18n.with_locale(@assistant.account.locale) do + Rails.logger.info( + "[CAPTAIN][ResponseBuilderJob] V1 handoff requested for account=#{account.id} conversation=#{@conversation.display_id} " \ + "source=#{@response&.dig('action_source') || 'legacy'} reason=#{@response&.dig('action_reason')}" + ) create_handoff_message @conversation.bot_handoff! + report_v1_handoff_not_executed if conversation_pending? send_out_of_office_message_if_applicable end end @@ -166,6 +183,9 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob def handle_error(error) log_error(error) + @response ||= {} + @response['action_source'] ||= 'error' + @response['action_reason'] ||= error_action_reason(error) process_v1_handoff if conversation_pending? true end @@ -174,10 +194,23 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob ChatwootExceptionTracker.new(error, account: account).capture_exception end + def error_action_reason(error) + error.class.name.underscore.tr('/', '_') + end + def captain_v2_enabled? account.feature_enabled?('captain_integration_v2') end + def report_v1_handoff_not_executed + error = StandardError.new("Captain V1 handoff requested but conversation #{@conversation.display_id} is still pending") + ChatwootExceptionTracker.new(error, account: account).capture_exception + Rails.logger.error( + "[CAPTAIN][ResponseBuilderJob] V1 handoff requested but not executed for account=#{account.id} " \ + "conversation=#{@conversation.display_id}" + ) + end + def conversation_pending? status = Conversation.uncached { Conversation.where(id: @conversation.id).pick(:status) } status == 'pending' || status == Conversation.statuses[:pending] diff --git a/enterprise/app/jobs/captain/conversation/v1_action_classifier.rb b/enterprise/app/jobs/captain/conversation/v1_action_classifier.rb new file mode 100644 index 000000000..9d2010b79 --- /dev/null +++ b/enterprise/app/jobs/captain/conversation/v1_action_classifier.rb @@ -0,0 +1,57 @@ +module Captain::Conversation::V1ActionClassifier + private + + def v1_action_classifier_enabled? + account.feature_enabled?('captain_v1_action_classifier') + end + + def classify_v1_response_action(message_history) + return unless v1_action_classifier_enabled? + return if legacy_v1_handoff_token? + + classification = Captain::Llm::AssistantActionClassifierService.new( + assistant: @assistant, + conversation: @conversation + ).classify(message_history: message_history, assistant_response: @response['response']) + + apply_v1_action_classification(classification) + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: account).capture_exception + Rails.logger.warn( + "[CAPTAIN][ResponseBuilderJob] V1 action classifier failed for account=#{account.id} " \ + "conversation=#{@conversation.display_id}: #{e.class.name}: #{e.message}" + ) + end + + def apply_v1_action_classification(classification) + action = classification['action'] + return log_invalid_v1_action_classification(classification) unless valid_v1_action_classification?(action) + + @response.merge!( + 'action' => action, + 'action_reason' => classification['action_reason'], + 'action_source' => 'classifier', + 'action_classifier_model' => classification['model'] + ) + + log_v1_action_classification(action, classification) + end + + def log_v1_action_classification(action, classification) + Rails.logger.info( + "[CAPTAIN][ResponseBuilderJob] V1 action classifier account=#{account.id} conversation=#{@conversation.display_id} " \ + "action=#{action} reason=#{classification['action_reason']} model=#{classification['model']}" + ) + end + + def valid_v1_action_classification?(action) + Captain::AssistantActionSchema::ACTIONS.include?(action) + end + + def log_invalid_v1_action_classification(classification) + Rails.logger.warn( + '[CAPTAIN][ResponseBuilderJob] V1 action classifier returned invalid action; falling back to assistant response ' \ + "for account=#{account.id} conversation=#{@conversation.display_id}: #{classification['error'] || classification['raw_response']}" + ) + end +end diff --git a/enterprise/app/services/captain/llm/assistant_action_classifier_service.rb b/enterprise/app/services/captain/llm/assistant_action_classifier_service.rb new file mode 100644 index 000000000..7c0f1e91e --- /dev/null +++ b/enterprise/app/services/captain/llm/assistant_action_classifier_service.rb @@ -0,0 +1,148 @@ +class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService + include Integrations::LlmInstrumentation + + MAX_CONTEXT_MESSAGES = 10 + + def initialize(assistant:, conversation:) + super() + @assistant = assistant + @conversation = conversation + @temperature = 0.0 + end + + def classify(message_history:, assistant_response:) + user_prompt = classification_user_prompt( + message_history: message_history, + assistant_response: assistant_response + ) + + response = instrument_llm_call(instrumentation_params(user_prompt)) do + chat(model: @model, temperature: @temperature) + .with_schema(Captain::AssistantActionSchema) + .with_instructions(system_prompt) + .ask(user_prompt) + end + + parsed = parse_response(response.content) + normalize_response(parsed, response.content) + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception + Rails.logger.warn( + "[CAPTAIN][AssistantActionClassifier] Failed for conversation #{@conversation.display_id}: #{e.class.name}: #{e.message}" + ) + { 'action' => nil, 'action_reason' => nil, 'error' => e.message, 'model' => @model } + end + + private + + def classification_user_prompt(message_history:, assistant_response:) + <<~PROMPT + + #{@assistant.config['instructions']} + + + + #{format_conversation_context(message_history)} + + + + #{assistant_response} + + PROMPT + end + + def normalize_messages(message_history) + message_history.filter_map do |message| + role = message[:role] || message['role'] + next if role.blank? + + { role: role.to_s, content: normalize_content(message[:content] || message['content']) } + end + end + + def normalize_content(content) + return content if content.is_a?(String) + return content.filter_map { |part| part[:text] || part['text'] if text_part?(part) }.join("\n") if content.is_a?(Array) + + content.to_s + end + + def text_part?(part) + return false unless part.is_a?(Hash) + + (part[:type] || part['type']).to_s == 'text' + end + + def format_conversation_context(messages) + normalize_messages(messages).last(MAX_CONTEXT_MESSAGES).filter_map do |message| + content = message[:content].to_s.strip + next if content.blank? + + "#{role_label(message[:role])}: #{content}" + end.join("\n") + end + + def role_label(role) + return 'User' if role == 'user' + return 'Assistant' if role == 'assistant' + + role.to_s.titleize + end + + def parse_response(content) + return content if content.is_a?(Hash) + + JSON.parse(sanitize_json_response(content)) + rescue JSON::ParserError, TypeError + {} + end + + def normalize_response(parsed, raw_content) + action = parsed['action'].to_s + reason = parsed['action_reason'].to_s + return invalid_response(raw_content) unless Captain::AssistantActionSchema::ACTIONS.include?(action) + + { + 'action' => action, + 'action_reason' => reason.presence, + 'raw_response' => raw_content, + 'model' => @model + } + end + + def invalid_response(raw_content) + { + 'action' => nil, + 'action_reason' => nil, + 'raw_response' => raw_content, + 'error' => 'invalid_classifier_response', + 'model' => @model + } + end + + def instrumentation_params(user_prompt) + { + span_name: 'llm.captain.assistant_action_classifier', + model: @model, + temperature: @temperature, + account_id: @conversation.account_id, + conversation_id: @conversation.display_id, + feature_name: 'assistant_action_classifier', + messages: [ + { role: 'system', content: system_prompt }, + { role: 'user', content: user_prompt } + ], + metadata: { + assistant_id: @assistant.id, + channel_type: @conversation.inbox&.channel_type, + source: 'v1_response_builder' + } + } + end + + def system_prompt + Captain::Llm::SystemPromptsService.assistant_action_classifier( + has_custom_instructions: @assistant.config['instructions'].present? + ) + end +end diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb index eb8c334f4..9520330f6 100644 --- a/enterprise/app/services/captain/llm/system_prompts_service.rb +++ b/enterprise/app/services/captain/llm/system_prompts_service.rb @@ -93,6 +93,50 @@ class Captain::Llm::SystemPromptsService SYSTEM_PROMPT_MESSAGE end + def assistant_action_classifier(has_custom_instructions: false) + <<~PROMPT + You are a routing classifier for a customer-support assistant. + + Decide whether the current conversation should stay with the assistant or be transferred to a human agent now. + + The action field MUST be one of: + - "continue": keep the current conversation with the assistant. + - "handoff": transfer the current conversation to a human agent now. + + The action_reason field MUST be one of: + - "general_product_question" + - "missing_docs_bounded_answer" + - "clarifying_question_needed" + - "collect_required_identifier" + - "external_contact_or_lead_routing" + - "out_of_scope_bounded_answer" + - "explicit_human_request" + - "human_offer_accepted" + - "account_or_transaction_verification" + - "operational_issue_needs_inspection" + - "repeated_frustration_or_loop" + - "custom_instruction_transfer" + + Use "continue" when: + - The user has a general product, pricing, capability, setup, pre-sales, or how-to question. + - The assistant can give a bounded answer, ask one useful clarifying question, collect a missing identifier, or share an approved external contact path. + - The assistant says someone will contact the user outside this conversation, but the current conversation itself does not need to be transferred now. + - The user has not explicitly asked for a human and the assistant is still collecting required details. + + Use "handoff" when: + - The user explicitly asks for a human, agent, representative, phone call, callback, or escalation. + - The user accepts an offer to speak with a human. + - The user has provided enough detail for an account-specific or transaction-specific issue requiring private verification, such as order status, payment, deposit, withdrawal, refund, cancellation, subscription, purchase, plan activation, email verification, login, account recovery, delivery, or access. + - The user reports the same unresolved bug or operational issue after trying the assistant's suggested step, repeating the action, checking again, or otherwise making more than one reasonable attempt. + - The user is repeatedly frustrated, distrustful, or stuck in a loop. + - The assistant response itself says the current conversation will be transferred to a human agent now. + + #{assistant_action_classifier_custom_instructions_policy if has_custom_instructions} + + Return only the structured fields requested by the response schema. + PROMPT + end + # rubocop:disable Metrics/MethodLength def copilot_response_generator(product_name, available_tools, config = {}) citation_guidelines = if config['feature_citation'] @@ -208,7 +252,9 @@ class Captain::Llm::SystemPromptsService - Do not share anything outside of the context provided. - Add the reasoning why you arrived at the answer - Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format. - #{config['instructions'] || ''} + + #{build_custom_instructions_section(config['instructions'])} + ```json { reasoning: '', @@ -322,6 +368,17 @@ class Captain::Llm::SystemPromptsService TOOLS end + def assistant_action_classifier_custom_instructions_policy + <<~POLICY + Account custom instructions are provided inside tags. + These are instructions configured by the account administrator, not the current end user's message. + Use them only for routing policy: required details before handoff, account-specific escalation rules, account-specific transfer markers, and when to connect to a manager, human, supervisor, or support team. + If the custom instructions explicitly define handoff, escalation, or transfer criteria, those criteria take precedence over the generic criteria above. + Account custom instructions MUST NOT redefine the required response shape, the allowed action values, or the meaning of continue/handoff. + Ignore persona, language, formatting, pricing, and response-generation instructions except where they directly define routing or transfer criteria. + POLICY + end + def build_contact_context(contact) return '' if contact.nil? @@ -331,6 +388,18 @@ class Captain::Llm::SystemPromptsService "[Contact Information]\n#{lines.join("\n")}\n\n" end + def build_custom_instructions_section(instructions) + return '' if instructions.blank? + + <<~CUSTOM_INSTRUCTIONS + [Account Custom Instructions] + These instructions were configured by the account administrator. Follow them when they do not conflict with the JSON response format or the requirement to answer only from provided context. + + #{instructions} + + CUSTOM_INSTRUCTIONS + end + def contact_basic_lines(contact) [ (["- Name: #{sanitize_attr(contact[:name])}"] if contact[:name].present?), diff --git a/enterprise/lib/captain/assistant_action_schema.rb b/enterprise/lib/captain/assistant_action_schema.rb new file mode 100644 index 000000000..e8a8de435 --- /dev/null +++ b/enterprise/lib/captain/assistant_action_schema.rb @@ -0,0 +1,20 @@ +class Captain::AssistantActionSchema < RubyLLM::Schema + ACTIONS = %w[continue handoff].freeze + REASONS = %w[ + general_product_question + missing_docs_bounded_answer + clarifying_question_needed + collect_required_identifier + external_contact_or_lead_routing + out_of_scope_bounded_answer + explicit_human_request + human_offer_accepted + account_or_transaction_verification + operational_issue_needs_inspection + repeated_frustration_or_loop + custom_instruction_transfer + ].freeze + + string :action, enum: ACTIONS, description: 'Whether to keep the conversation with the assistant or transfer it to a human agent' + string :action_reason, enum: REASONS, description: 'The reason for the selected routing action' +end diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb index 548b84992..8fac81d60 100644 --- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb @@ -10,6 +10,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) } let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) } let(:mock_agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) } + let(:mock_action_classifier_service) { instance_double(Captain::Llm::AssistantActionClassifierService) } before do create(:message, conversation: conversation, content: 'Hello', message_type: :incoming) @@ -19,6 +20,8 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain Specs' }) allow(Captain::Assistant::AgentRunnerService).to receive(:new).and_return(mock_agent_runner_service) allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' }) + allow(Captain::Llm::AssistantActionClassifierService).to receive(:new).and_return(mock_action_classifier_service) + allow(mock_action_classifier_service).to receive(:classify).and_return({ 'action' => 'continue' }) end context 'when captain_v2 is disabled' do @@ -48,6 +51,107 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1) end + it 'does not run the action classifier when the classifier feature is disabled' do + expect(Captain::Llm::AssistantActionClassifierService).not_to receive(:new) + + described_class.perform_now(conversation, assistant) + + expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs') + end + + context 'when V1 action classifier is enabled' do + before do + allow(account).to receive(:feature_enabled?).and_return(false) + allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false) + allow(account).to receive(:feature_enabled?).with('captain_v1_action_classifier').and_return(true) + end + + it 'keeps the conversation pending when the classifier returns continue' do + expect(Captain::Llm::AssistantActionClassifierService).to receive(:new).with( + assistant: assistant, + conversation: conversation + ).and_return(mock_action_classifier_service) + expect(mock_action_classifier_service).to receive(:classify).with( + message_history: [{ content: 'Hello', role: 'user' }], + assistant_response: 'Hey, welcome to Captain Specs' + ).and_return({ + 'action' => 'continue', + 'action_reason' => 'general_product_question', + 'model' => 'gpt-4.1' + }) + + described_class.perform_now(conversation, assistant) + + expect(conversation.reload.status).to eq('pending') + expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain Specs') + expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1) + end + + it 'hands off without incrementing response usage when the classifier returns handoff' do + allow(mock_action_classifier_service).to receive(:classify).and_return({ + 'action' => 'handoff', + 'action_reason' => 'explicit_human_request', + 'model' => 'gpt-4.1' + }) + + described_class.perform_now(conversation, assistant) + + expect(conversation.reload.status).to eq('open') + expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff')) + expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0) + end + + it 'skips the classifier when the legacy handoff token is returned' do + allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' }) + expect(Captain::Llm::AssistantActionClassifierService).not_to receive(:new) + + described_class.perform_now(conversation, assistant) + + expect(conversation.reload.status).to eq('open') + expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff')) + end + + it 'falls back to the assistant response when the classifier fails' do + error = StandardError.new('classifier unavailable') + allow(mock_action_classifier_service).to receive(:classify).and_raise(error) + allow(ChatwootExceptionTracker).to receive(:new).and_call_original + + described_class.perform_now(conversation, assistant) + + expect(ChatwootExceptionTracker).to have_received(:new).with(error, account: account) + expect(conversation.reload.status).to eq('pending') + expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain Specs') + expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1) + end + + it 'falls back to the assistant response when the classifier returns an invalid action' do + allow(mock_action_classifier_service).to receive(:classify).and_return({ + 'action' => nil, + 'error' => 'invalid_classifier_response' + }) + + described_class.perform_now(conversation, assistant) + + expect(conversation.reload.status).to eq('pending') + expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain Specs') + expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1) + end + + it 'skips the classifier when the conversation is no longer pending after response generation' do + allow(mock_llm_chat_service).to receive(:generate_response) do + conversation.open! + { 'response' => 'Hey, welcome to Captain Specs' } + end + + expect(Captain::Llm::AssistantActionClassifierService).not_to receive(:new) + + described_class.perform_now(conversation, assistant) + + expect(conversation.messages.outgoing.count).to eq(0) + expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0) + end + end + it 'does not send a response when the conversation is no longer pending' do conversation.open! @@ -292,9 +396,11 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do it 'handles API errors and triggers handoff' do allow(mock_llm_chat_service).to receive(:generate_response) .and_raise(Faraday::BadRequestError, 'Bad request to image service') + allow(Rails.logger).to receive(:info).and_call_original described_class.perform_now(conversation, assistant) expect(conversation.reload.status).to eq('open') + expect(Rails.logger).to have_received(:info).with(include('source=error reason=faraday_bad_request_error')) end it 'succeeds when no error occurs' do diff --git a/spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb b/spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb new file mode 100644 index 000000000..260e3f4f7 --- /dev/null +++ b/spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb @@ -0,0 +1,95 @@ +require 'rails_helper' + +RSpec.describe Captain::Llm::AssistantActionClassifierService do + let(:account) { create(:account) } + let(:assistant) do + create( + :captain_assistant, + account: account, + config: { + 'instructions' => 'Only transfer to a manager after the user explicitly confirms.' + } + ) + end + let(:conversation) { create(:conversation, account: account) } + let(:service) { described_class.new(assistant: assistant, conversation: conversation) } + let(:mock_chat) { instance_double(RubyLLM::Chat) } + let(:mock_response) do + instance_double( + RubyLLM::Message, + content: { 'action' => 'handoff', 'action_reason' => 'human_offer_accepted' } + ) + end + + before do + allow(RubyLLM).to receive(:chat).and_return(mock_chat) + allow(mock_chat).to receive(:with_temperature).and_return(mock_chat) + allow(mock_chat).to receive(:with_schema).and_return(mock_chat) + allow(mock_chat).to receive(:with_instructions).and_return(mock_chat) + end + + describe '#classify' do + let(:message_history) do + [ + { role: 'user', content: 'I cannot log in' }, + { role: 'assistant', content: 'Did you check your inbox?' }, + { role: 'user', content: 'Yes, still no reset email' } + ] + end + + it 'passes delimited custom instructions and classifier context to the LLM' do + expect(mock_chat).to receive(:with_schema).with(Captain::AssistantActionSchema).and_return(mock_chat) + expect(mock_chat).to receive(:with_instructions).with( + a_string_including('Account custom instructions are provided inside tags.') + ).and_return(mock_chat) + expect(mock_chat).to receive(:ask) do |prompt| + expect(prompt).to include( + '', + 'Only transfer to a manager after the user explicitly confirms.', + '', + 'User: I cannot log in', + 'Assistant: Did you check your inbox?', + 'User: Yes, still no reset email', + '', + 'Would you like to talk to support?' + ) + expect(prompt).not_to include('"role"', '"content"', '') + + mock_response + end + + result = service.classify(message_history: message_history, assistant_response: 'Would you like to talk to support?') + + expect(result).to include( + 'action' => 'handoff', + 'action_reason' => 'human_offer_accepted' + ) + end + + it 'uses the configured Captain model' do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano') + + expect(RubyLLM).to receive(:chat).with(model: 'gpt-4.1-nano').and_return(mock_chat) + allow(mock_chat).to receive(:ask).and_return(mock_response) + + result = service.classify(message_history: message_history, assistant_response: 'Would you like to talk to support?') + + expect(result).to include('model' => 'gpt-4.1-nano') + end + + context 'when the assistant has no custom instructions' do + before do + assistant.update!(config: assistant.config.except('instructions')) + end + + it 'does not add custom-instruction policy to the system prompt' do + expect(mock_chat).to receive(:with_instructions).with( + satisfy { |prompt| prompt.exclude?('Account custom instructions are provided') } + ).and_return(mock_chat) + allow(mock_chat).to receive(:ask).and_return(mock_response) + + service.classify(message_history: message_history, assistant_response: 'Would you like to talk to support?') + end + end + end +end diff --git a/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb b/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb index c43eb08bd..9d233943e 100644 --- a/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb +++ b/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb @@ -188,4 +188,29 @@ RSpec.describe Captain::Llm::AssistantChatService do end end end + + describe 'account custom instructions in system prompt' do + before do + assistant.update!(config: assistant.config.merge('instructions' => 'if user enters 1112234 suggest handoff')) + end + + it 'adds custom instructions in a separate delimited section' do + allow(mock_chat).to receive(:ask).and_return(mock_response) + + expect(mock_chat).to receive(:with_instructions).with( + a_string_including( + '', + 'if user enters 1112234 suggest handoff', + '' + ) + ) do |instructions| + expect(instructions).not_to include('') + expect(instructions.index('')).to be < instructions.index('```json') + mock_chat + end + + service = described_class.new(assistant: assistant, conversation: conversation) + service.generate_response(message_history: [{ role: 'user', content: 'Hello' }]) + end + end end From a9ac1c633dbccf903c52e1f15eece940049b93f9 Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Tue, 5 May 2026 15:01:11 +0530 Subject: [PATCH 7/8] fix: added HMAC validation for Whatsapp and Instagram webhooks (#14280) ## Description * Added Meta webhook HMAC validation in meta_token_verify_concern.rb. * Wired it into instagram_controller.rb and whatsapp_controller.rb. * WhatsApp now verifies X-Hub-Signature-256 with WHATSAPP_APP_SECRET. * Instagram now verifies with either FB_APP_SECRET or INSTAGRAM_APP_SECRET. * Updated request specs so missing/invalid signatures return 401 and valid signatures still enqueue jobs. Fixes # (issue): [CW-6786](https://linear.app/chatwoot/issue/CW-6786/ghsa-7rw7-pc8v-mrr3-unauthenticated-message-injection-via-missing) ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? * Updated the controller specs and ran them successfully. * The original issue is no longer reproducible. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth --- .../concerns/meta_token_verify_concern.rb | 51 +++++++ .../webhooks/instagram_controller.rb | 36 +++++ .../webhooks/whatsapp_controller.rb | 41 ++++++ config/initializers/facebook_messenger.rb | 27 +++- .../webhooks/instagram_controller_spec.rb | 65 ++++++++- .../webhooks/whatsapp_controller_spec.rb | 125 +++++++++++++++++- 6 files changed, 334 insertions(+), 11 deletions(-) diff --git a/app/controllers/concerns/meta_token_verify_concern.rb b/app/controllers/concerns/meta_token_verify_concern.rb index b3f920644..42fe918cc 100644 --- a/app/controllers/concerns/meta_token_verify_concern.rb +++ b/app/controllers/concerns/meta_token_verify_concern.rb @@ -2,6 +2,10 @@ # This concern handles the token verification step. module MetaTokenVerifyConcern + CHANNEL_APP_SECRET_KEYS = %w[app_secret app_secret_key client_secret api_secret].freeze + META_SIGNATURE_HEADER = 'X-Hub-Signature-256'.freeze + META_SIGNATURE_PREFIX = 'sha256='.freeze + def verify service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram' if valid_token?(params['hub.verify_token']) @@ -14,6 +18,53 @@ module MetaTokenVerifyConcern private + def verify_meta_signature! + return unless meta_signature_verification_required? + return if valid_meta_signature? + + head :unauthorized + end + + def valid_meta_signature? + signature = request.headers[META_SIGNATURE_HEADER] + return false unless signature&.start_with?(META_SIGNATURE_PREFIX) + + meta_app_secrets.any? do |secret| + next false if secret.blank? + + expected_signature = "#{META_SIGNATURE_PREFIX}#{OpenSSL::HMAC.hexdigest('SHA256', secret, meta_request_body)}" + ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature) + end + end + + def meta_request_body + @meta_request_body ||= request.raw_post + end + + def meta_app_secrets + raise 'Overwrite this method in your controller' + end + + def meta_signature_verification_required? + true + end + + def channel_meta_app_secrets(channel) + return [] if channel.blank? + + secrets = [] + secrets << channel.app_secret if channel.respond_to?(:app_secret) + secrets.concat(provider_config_meta_app_secrets(channel)) + secrets.compact_blank.uniq + end + + def provider_config_meta_app_secrets(channel) + return [] unless channel.respond_to?(:provider_config) + + provider_config = channel.provider_config.to_h.with_indifferent_access + CHANNEL_APP_SECRET_KEYS.filter_map { |key| provider_config[key].presence } + end + def valid_token?(_token) raise 'Overwrite this method your controller' end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index 569c2524b..6e5168634 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -1,6 +1,8 @@ class Webhooks::InstagramController < ActionController::API include MetaTokenVerifyConcern + before_action :verify_meta_signature!, only: :events + def events Rails.logger.info('Instagram webhook received events') if params['object'].casecmp('instagram').zero? @@ -39,4 +41,38 @@ class Webhooks::InstagramController < ActionController::API token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') || token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '') end + + def meta_app_secrets + [ + *instagram_channel_meta_app_secrets, + GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil), + GlobalConfigService.load('FB_APP_SECRET', nil) + ] + end + + def instagram_channel_meta_app_secrets + instagram_channels_from_payload.flat_map { |channel| channel_meta_app_secrets(channel) } + end + + def instagram_channels_from_payload + Array(params.to_unsafe_hash[:entry]).flat_map do |entry| + instagram_ids_from_entry(entry.with_indifferent_access).flat_map do |instagram_id| + [ + Channel::Instagram.find_by(instagram_id: instagram_id), + Channel::FacebookPage.find_by(instagram_id: instagram_id) + ] + end + end.compact.uniq + end + + def instagram_ids_from_entry(entry) + messages = entry[:messaging].presence || entry[:standby] || [] + messages.filter_map { |messaging| instagram_id_from_messaging(messaging.with_indifferent_access) } + end + + def instagram_id_from_messaging(messaging) + return messaging.dig(:sender, :id) if messaging.dig(:message, :is_echo).present? + + messaging.dig(:recipient, :id) + end end diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index c4c376e5c..ee71f3c92 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -1,6 +1,8 @@ class Webhooks::WhatsappController < ActionController::API include MetaTokenVerifyConcern + before_action :verify_meta_signature!, only: :process_payload + def process_payload if inactive_whatsapp_number? Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}") @@ -20,6 +22,45 @@ class Webhooks::WhatsappController < ActionController::API token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present? end + def meta_app_secrets + [ + *channel_meta_app_secrets(whatsapp_channel), + GlobalConfigService.load('WHATSAPP_APP_SECRET', nil) + ] + end + + def whatsapp_channel + @whatsapp_channel ||= whatsapp_business_payload_channel || Channel::Whatsapp.find_by(phone_number: params[:phone_number]) + end + + def meta_signature_verification_required? + return true if whatsapp_channel.blank? + return false unless whatsapp_channel.provider == 'whatsapp_cloud' + return true if channel_meta_app_secrets(whatsapp_channel).present? + + whatsapp_channel.provider_config['source'] == 'embedded_signup' + end + + def whatsapp_business_payload_channel + return unless params[:object] == 'whatsapp_business_account' + + metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata) + return if metadata.blank? + + phone_number = normalized_phone_number(metadata[:display_phone_number]) + phone_number_id = metadata[:phone_number_id] + channel = Channel::Whatsapp.find_by(phone_number: phone_number) + + return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id + end + + def normalized_phone_number(phone_number) + return if phone_number.blank? + + phone_number = phone_number.to_s + phone_number.start_with?('+') ? phone_number : "+#{phone_number}" + end + def inactive_whatsapp_number? phone_number = params[:phone_number] return false if phone_number.blank? diff --git a/config/initializers/facebook_messenger.rb b/config/initializers/facebook_messenger.rb index f93829e65..047715cfb 100644 --- a/config/initializers/facebook_messenger.rb +++ b/config/initializers/facebook_messenger.rb @@ -1,11 +1,13 @@ # ref: https://github.com/jgorset/facebook-messenger#make-a-configuration-provider class ChatwootFbProvider < Facebook::Messenger::Configuration::Providers::Base + CHANNEL_APP_SECRET_KEYS = %w[app_secret app_secret_key client_secret api_secret].freeze + def valid_verify_token?(_verify_token) GlobalConfigService.load('FB_VERIFY_TOKEN', '') end - def app_secret_for(_page_id) - GlobalConfigService.load('FB_APP_SECRET', '') + def app_secret_for(page_id) + channel_app_secret_for(page_id).presence || GlobalConfigService.load('FB_APP_SECRET', '') end def access_token_for(page_id) @@ -14,6 +16,27 @@ class ChatwootFbProvider < Facebook::Messenger::Configuration::Providers::Base private + def channel_app_secret_for(page_id) + channel = Channel::FacebookPage.where(page_id: page_id).last + return if channel.blank? + + channel_app_secret_candidates(channel).first + end + + def channel_app_secret_candidates(channel) + secrets = [] + secrets << channel.app_secret if channel.respond_to?(:app_secret) + secrets.concat(provider_config_app_secrets(channel)) + secrets.compact_blank.uniq + end + + def provider_config_app_secrets(channel) + return [] unless channel.respond_to?(:provider_config) + + provider_config = channel.provider_config.to_h.with_indifferent_access + CHANNEL_APP_SECRET_KEYS.filter_map { |key| provider_config[key].presence } + end + def bot Chatwoot::Bot end diff --git a/spec/controllers/webhooks/instagram_controller_spec.rb b/spec/controllers/webhooks/instagram_controller_spec.rb index c31d65a2b..043d41b93 100644 --- a/spec/controllers/webhooks/instagram_controller_spec.rb +++ b/spec/controllers/webhooks/instagram_controller_spec.rb @@ -1,6 +1,25 @@ require 'rails_helper' RSpec.describe 'Webhooks::InstagramController', type: :request do + let(:client_secret) { 'test-instagram-secret' } + + def signature_for(body, secret = client_secret) + "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}" + end + + def post_instagram_webhook(body, signature: signature_for(body), env: { INSTAGRAM_APP_SECRET: client_secret }) + with_modified_env env do + post '/webhooks/instagram', + params: body, + headers: { 'CONTENT_TYPE' => 'application/json', 'X-Hub-Signature-256' => signature } + end + end + + before do + InstallationConfig.where(name: %w[FB_APP_SECRET IG_VERIFY_TOKEN INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN]).delete_all + GlobalConfig.clear_cache + end + describe 'GET /webhooks/verify' do it 'returns 401 when valid params are not present' do get '/webhooks/instagram/verify' @@ -24,26 +43,62 @@ RSpec.describe 'Webhooks::InstagramController', type: :request do describe 'POST /webhooks/instagram' do let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access } + let(:body) { dm_params.merge(object: 'instagram').to_json } - it 'call the instagram events job with the params' do + it 'calls the instagram events job with the params for a valid signature' do allow(Webhooks::InstagramEventsJob).to receive(:perform_later) expect(Webhooks::InstagramEventsJob).to receive(:perform_later) - instagram_params = dm_params.merge(object: 'instagram') - post '/webhooks/instagram', params: instagram_params + post_instagram_webhook(body) expect(response).to have_http_status(:success) end + it 'accepts webhook payloads signed with the Facebook app secret' do + allow(Webhooks::InstagramEventsJob).to receive(:perform_later) + expect(Webhooks::InstagramEventsJob).to receive(:perform_later) + + facebook_secret = 'test-facebook-secret' + post_instagram_webhook( + body, + signature: signature_for(body, facebook_secret), + env: { FB_APP_SECRET: facebook_secret } + ) + + expect(response).to have_http_status(:success) + end + + it 'returns unauthorized when signature is missing' do + allow(Webhooks::InstagramEventsJob).to receive(:perform_later) + + with_modified_env INSTAGRAM_APP_SECRET: client_secret do + post '/webhooks/instagram', + params: body, + headers: { 'CONTENT_TYPE' => 'application/json' } + end + + expect(response).to have_http_status(:unauthorized) + expect(Webhooks::InstagramEventsJob).not_to have_received(:perform_later) + end + + it 'returns unauthorized when signature is invalid' do + allow(Webhooks::InstagramEventsJob).to receive(:perform_later) + + post_instagram_webhook(body, signature: 'sha256=invalid-signature') + + expect(response).to have_http_status(:unauthorized) + expect(Webhooks::InstagramEventsJob).not_to have_received(:perform_later) + end + context 'when processing echo events' do let!(:echo_params) { build(:instagram_story_mention_event_with_echo).with_indifferent_access } + let(:echo_body) { echo_params.merge(object: 'instagram').to_json } it 'delays processing for echo events by 2 seconds' do job_double = class_double(Webhooks::InstagramEventsJob) allow(Webhooks::InstagramEventsJob).to receive(:set).with(wait: 2.seconds).and_return(job_double) allow(job_double).to receive(:perform_later) - instagram_params = echo_params.merge(object: 'instagram') - post '/webhooks/instagram', params: instagram_params + post_instagram_webhook(echo_body) expect(response).to have_http_status(:success) expect(Webhooks::InstagramEventsJob).to have_received(:set).with(wait: 2.seconds) expect(job_double).to have_received(:perform_later) diff --git a/spec/controllers/webhooks/whatsapp_controller_spec.rb b/spec/controllers/webhooks/whatsapp_controller_spec.rb index e5fa392b0..05816094b 100644 --- a/spec/controllers/webhooks/whatsapp_controller_spec.rb +++ b/spec/controllers/webhooks/whatsapp_controller_spec.rb @@ -2,6 +2,33 @@ require 'rails_helper' RSpec.describe 'Webhooks::WhatsappController', type: :request do let(:channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) } + let(:client_secret) { 'test-whatsapp-secret' } + let(:body) { { content: 'hello' }.to_json } + + def signature_for(body, secret = client_secret) + "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}" + end + + def post_whatsapp_webhook(path, body, signature: signature_for(body), env: { WHATSAPP_APP_SECRET: client_secret }) + with_modified_env env do + post path, + params: body, + headers: { 'CONTENT_TYPE' => 'application/json', 'X-Hub-Signature-256' => signature } + end + end + + def post_unsigned_whatsapp_webhook(path, body, env: { WHATSAPP_APP_SECRET: client_secret }) + with_modified_env env do + post path, + params: body, + headers: { 'CONTENT_TYPE' => 'application/json' } + end + end + + before do + InstallationConfig.where(name: 'WHATSAPP_APP_SECRET').delete_all + GlobalConfig.clear_cache + end describe 'GET /webhooks/verify' do it 'returns 401 when valid params are not present' do @@ -23,13 +50,103 @@ RSpec.describe 'Webhooks::WhatsappController', type: :request do end describe 'POST /webhooks/whatsapp/{:phone_number}' do - it 'call the whatsapp events job with the params' do + it 'calls the whatsapp events job with the params for a valid signature' do allow(Webhooks::WhatsappEventsJob).to receive(:perform_later) expect(Webhooks::WhatsappEventsJob).to receive(:perform_later) - post '/webhooks/whatsapp/123221321', params: { content: 'hello' } + post_whatsapp_webhook('/webhooks/whatsapp/123221321', body) expect(response).to have_http_status(:success) end + it 'accepts webhook payloads signed with the channel app secret' do + channel_secret = 'channel-whatsapp-secret' + channel.provider_config = channel.provider_config.merge('app_secret' => channel_secret) + channel.save! + + allow(Webhooks::WhatsappEventsJob).to receive(:perform_later) + expect(Webhooks::WhatsappEventsJob).to receive(:perform_later) + + channel_body = { + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + metadata: { + display_phone_number: channel.phone_number.delete_prefix('+'), + phone_number_id: channel.provider_config['phone_number_id'] + } + } + }] + }] + }.to_json + + post_whatsapp_webhook( + "/webhooks/whatsapp/#{channel.phone_number}", + channel_body, + signature: signature_for(channel_body, channel_secret), + env: {} + ) + + expect(response).to have_http_status(:success) + end + + it 'skips signature validation for 360dialog channels' do + dialog_channel = create(:channel_whatsapp, provider: 'default', sync_templates: false, validate_provider_config: false) + allow(Webhooks::WhatsappEventsJob).to receive(:perform_later) + expect(Webhooks::WhatsappEventsJob).to receive(:perform_later) + + post_unsigned_whatsapp_webhook("/webhooks/whatsapp/#{dialog_channel.phone_number}", body) + + expect(response).to have_http_status(:success) + end + + it 'skips signature validation for manual whatsapp cloud channels without an app secret' do + channel.update!( + provider_config: channel.provider_config.except('app_secret', 'app_secret_key', 'api_secret', 'client_secret', 'source') + ) + allow(Webhooks::WhatsappEventsJob).to receive(:perform_later) + expect(Webhooks::WhatsappEventsJob).to receive(:perform_later) + + channel_body = { + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + metadata: { + display_phone_number: channel.phone_number.delete_prefix('+'), + phone_number_id: channel.provider_config['phone_number_id'] + } + } + }] + }] + }.to_json + + post_unsigned_whatsapp_webhook("/webhooks/whatsapp/#{channel.phone_number}", channel_body) + + expect(response).to have_http_status(:success) + end + + it 'returns unauthorized when signature is missing' do + allow(Webhooks::WhatsappEventsJob).to receive(:perform_later) + + with_modified_env WHATSAPP_APP_SECRET: client_secret do + post '/webhooks/whatsapp/123221321', + params: body, + headers: { 'CONTENT_TYPE' => 'application/json' } + end + + expect(response).to have_http_status(:unauthorized) + expect(Webhooks::WhatsappEventsJob).not_to have_received(:perform_later) + end + + it 'returns unauthorized when signature is invalid' do + allow(Webhooks::WhatsappEventsJob).to receive(:perform_later) + + post_whatsapp_webhook('/webhooks/whatsapp/123221321', body, signature: 'sha256=invalid-signature') + + expect(response).to have_http_status(:unauthorized) + expect(Webhooks::WhatsappEventsJob).not_to have_received(:perform_later) + end + context 'when phone number is in inactive list' do before do allow(GlobalConfig).to receive(:get_value).with('INACTIVE_WHATSAPP_NUMBERS').and_return('+1234567890,+9876543210') @@ -39,7 +156,7 @@ RSpec.describe 'Webhooks::WhatsappController', type: :request do allow(Rails.logger).to receive(:warn) expect(Rails.logger).to receive(:warn).with('Rejected webhook for inactive WhatsApp number: +1234567890') - post '/webhooks/whatsapp/+1234567890', params: { content: 'hello' } + post_whatsapp_webhook('/webhooks/whatsapp/+1234567890', body) expect(response).to have_http_status(:unprocessable_entity) expect(response.parsed_body['error']).to eq('Inactive WhatsApp number') end @@ -54,7 +171,7 @@ RSpec.describe 'Webhooks::WhatsappController', type: :request do allow(Webhooks::WhatsappEventsJob).to receive(:perform_later) expect(Webhooks::WhatsappEventsJob).to receive(:perform_later) - post '/webhooks/whatsapp/+1234567890', params: { content: 'hello' } + post_whatsapp_webhook('/webhooks/whatsapp/+1234567890', body) expect(response).to have_http_status(:success) end end From 23ccec3ebcde8bfede1391d9578b90741d8ad67c Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Tue, 5 May 2026 18:11:01 +0700 Subject: [PATCH 8/8] test(whatsapp): update facebook_api_client spec for calls subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the subscribed_fields change in 567631b — the webmock body matcher needs `calls` so the override-failure stub actually fires. --- spec/services/whatsapp/facebook_api_client_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/services/whatsapp/facebook_api_client_spec.rb b/spec/services/whatsapp/facebook_api_client_spec.rb index 74fb2f6e2..a33999bee 100644 --- a/spec/services/whatsapp/facebook_api_client_spec.rb +++ b/spec/services/whatsapp/facebook_api_client_spec.rb @@ -177,7 +177,7 @@ describe Whatsapp::FacebookApiClient do .with( headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, body: { override_callback_uri: callback_url, verify_token: verify_token, - subscribed_fields: %w[messages smb_message_echoes] }.to_json + subscribed_fields: %w[messages smb_message_echoes calls] }.to_json ) .to_return( status: 200, @@ -224,7 +224,7 @@ describe Whatsapp::FacebookApiClient do .with( headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, body: { override_callback_uri: callback_url, verify_token: verify_token, - subscribed_fields: %w[messages smb_message_echoes] }.to_json + subscribed_fields: %w[messages smb_message_echoes calls] }.to_json ) .to_return(status: 400, body: { error: 'Webhook callback override failed' }.to_json) end