diff --git a/.github/workflows/ghsa-linear-sync.yml b/.github/workflows/ghsa-linear-sync.yml new file mode 100644 index 000000000..961114bc9 --- /dev/null +++ b/.github/workflows/ghsa-linear-sync.yml @@ -0,0 +1,97 @@ +name: Sync GHSA advisories to Linear + +on: + schedule: + - cron: '0 4 * * *' # daily at 09:30 IST + workflow_dispatch: {} + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + security-events: read + steps: + - name: Fetch triage advisories + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api --paginate \ + -H "Accept: application/vnd.github+json" \ + "/repos/${{ github.repository }}/security-advisories?state=triage&per_page=100" \ + | jq -cs 'add | [.[] | { + ghsa_id, cve_id, summary, severity, state, html_url, + description, created_at, + cvss_score: .cvss.score, + reporter: ([.credits[]?.user.login] | first // "unknown") + }]' > advisories.json + echo "Fetched $(jq 'length' advisories.json) triage advisories" + + - name: Create Linear issues for new advisories + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} + LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }} + LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }} + run: | + created_count=0 + skipped_count=0 + failed_count=0 + while read -r advisory; do + ghsa_id=$(printf '%s' "$advisory" | jq -r '.ghsa_id') + summary=$(printf '%s' "$advisory" | jq -r '.summary') + severity=$(printf '%s' "$advisory" | jq -r '.severity // "unknown"') + cve_id=$(printf '%s' "$advisory" | jq -r '.cve_id // "n/a"') + cvss=$(printf '%s' "$advisory" | jq -r '.cvss_score // "n/a"') + reporter=$(printf '%s' "$advisory" | jq -r '.reporter') + html_url=$(printf '%s' "$advisory" | jq -r '.html_url') + created_date=$(printf '%s' "$advisory" | jq -r '.created_at' | cut -dT -f1) + description=$(printf '%s' "$advisory" | jq -r '.description // "No description provided."') + + existing=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n --arg q "$ghsa_id" '{query: "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) { nodes { id } } }", variables: {q: $q}}')" \ + | jq '.data.issues.nodes | length') + + if [ "${existing:-0}" -gt 0 ] 2>/dev/null; then + skipped_count=$((skipped_count+1)) + continue + fi + + priority=3 + case "$severity" in + critical) priority=1 ;; + high) priority=2 ;; + medium) priority=3 ;; + low) priority=4 ;; + esac + + title="[$ghsa_id] $summary" + body=$(printf '**GHSA:** %s\n**CVE:** %s\n**Severity:** %s (CVSS %s)\n**Reporter:** %s\n**Reported:** %s\n**Advisory:** %s\n\n---\n\n%s' \ + "$ghsa_id" "$cve_id" "$severity" "$cvss" "$reporter" "$created_date" "$html_url" "$description") + + success=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n \ + --arg title "$title" \ + --arg body "$body" \ + --arg teamId "$LINEAR_TEAM_ID" \ + --arg projectId "$LINEAR_PROJECT_ID" \ + --arg labelId "$LINEAR_LABEL_ID" \ + --argjson priority "$priority" \ + '{ + query: "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success } }", + variables: {input: {title: $title, description: $body, teamId: $teamId, projectId: $projectId, labelIds: [$labelId], priority: $priority}} + }')" | jq -r '.data.issueCreate.success // false') + + if [ "$success" = "true" ]; then + created_count=$((created_count+1)) + else + failed_count=$((failed_count+1)) + fi + done < <(jq -c '.[]' advisories.json) + echo "Created $created_count, skipped $skipped_count, failed $failed_count" + if [ "$failed_count" -gt 0 ]; then + exit 1 + fi diff --git a/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/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/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/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/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/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/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/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/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/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', () => { 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 { 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/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/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/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb index 0e574cb03..c502b717a 100644 --- a/enterprise/app/services/messages/audio_transcription_service.rb +++ b/enterprise/app/services/messages/audio_transcription_service.rb @@ -2,6 +2,10 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService include Integrations::LlmInstrumentation WHISPER_MODEL = 'whisper-1'.freeze + # Whisper's hard limit is 25 MB *decimal* (25_000_000), not binary (25.megabytes + # = 26_214_400) — using the binary form leaks the 25.0–26.2 MB range to the API + # as 413s. Long audio (~70+ min Opus) keeps the attachment but skips transcription. + WHISPER_BYTE_LIMIT = 25_000_000 attr_reader :attachment, :message, :account @@ -15,6 +19,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService def perform return { error: 'Transcription limit exceeded' } unless can_transcribe? return { error: 'Message not found' } if message.blank? + return { error: 'Audio too large for Whisper' } if audio_too_large? transcriptions = transcribe_audio Rails.logger.info "Audio transcription successful: #{transcriptions}" @@ -33,6 +38,13 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService account.usage_limits[:captain][:responses][:current_available].positive? end + def audio_too_large? + blob = attachment.file&.blob + return false unless blob + + blob.byte_size > WHISPER_BYTE_LIMIT + end + def fetch_audio_file blob = attachment.file.blob temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions') @@ -63,11 +75,14 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService transcribed_text = nil File.open(temp_file_path, 'rb') do |file| + # temperature: 0.0 minimises Whisper's hallucinations on silence / + # near-silent audio; non-zero values trigger spiraling repeats like + # "Oh, dear. Oh, dear. Oh, dear." — well-documented Whisper behaviour. response = @client.audio.transcribe( parameters: { model: WHISPER_MODEL, file: file, - temperature: 0.4 + temperature: 0.0 } ) transcribed_text = response['text'] diff --git a/enterprise/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/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/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 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 diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb index 7ece2540a..212c0bf01 100644 --- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb +++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb @@ -63,6 +63,23 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do expect(result).to eq({ success: true, transcriptions: 'Existing transcription' }) end end + + context 'when the audio exceeds Whisper byte limit' do + before do + attachment.file.attach( + io: File.open(Rails.public_path.join('audio/widget/ding.mp3')), + filename: 'large.mp3', + content_type: 'audio/mpeg' + ) + allow(service).to receive(:can_transcribe?).and_return(true) + allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::WHISPER_BYTE_LIMIT + 1) + end + + it 'returns an error without calling Whisper' do + expect(service).not_to receive(:transcribe_audio) + expect(service.perform).to eq({ error: 'Audio too large for Whisper' }) + end + end end describe '#fetch_audio_file' do diff --git a/spec/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 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({