Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -123,6 +123,13 @@ export function cleanSignature(signature) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strip `\<newline>` 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;
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -42,6 +42,7 @@ const createMarkdownInstance = (linkify = true) => {
|
||||
quotes: '\u201c\u201d\u2018\u2019',
|
||||
maxNesting: 20,
|
||||
})
|
||||
.disable(['lheading'])
|
||||
.use(mentionPlugin)
|
||||
.use(imgResizeManager)
|
||||
.use(mila, {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -33,6 +33,13 @@ describe('#MessageFormatter', () => {
|
||||
<h2>tool</h2>`
|
||||
);
|
||||
});
|
||||
|
||||
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('<h2>');
|
||||
expect(result).not.toMatch('<h1>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('content with image and has "cw_image_height" query at the end of URL', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
@@ -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
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
<account_custom_instructions>
|
||||
#{@assistant.config['instructions']}
|
||||
</account_custom_instructions>
|
||||
|
||||
<conversation_context>
|
||||
#{format_conversation_context(message_history)}
|
||||
</conversation_context>
|
||||
|
||||
<assistant_response_to_classify>
|
||||
#{assistant_response}
|
||||
</assistant_response_to_classify>
|
||||
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
|
||||
@@ -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 <account_custom_instructions> 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.
|
||||
<account_custom_instructions>
|
||||
#{instructions}
|
||||
</account_custom_instructions>
|
||||
CUSTOM_INSTRUCTIONS
|
||||
end
|
||||
|
||||
def contact_basic_lines(contact)
|
||||
[
|
||||
(["- Name: #{sanitize_attr(contact[:name])}"] if contact[:name].present?),
|
||||
|
||||
@@ -2,6 +2,10 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
WHISPER_MODEL = 'whisper-1'.freeze
|
||||
# Whisper's hard limit is 25 MB *decimal* (25_000_000), not binary (25.megabytes
|
||||
# = 26_214_400) — using the binary form leaks the 25.0–26.2 MB range to the API
|
||||
# as 413s. Long audio (~70+ min Opus) keeps the attachment but skips transcription.
|
||||
WHISPER_BYTE_LIMIT = 25_000_000
|
||||
|
||||
attr_reader :attachment, :message, :account
|
||||
|
||||
@@ -15,6 +19,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
def perform
|
||||
return { error: 'Transcription limit exceeded' } unless can_transcribe?
|
||||
return { error: 'Message not found' } if message.blank?
|
||||
return { error: 'Audio too large for Whisper' } if audio_too_large?
|
||||
|
||||
transcriptions = transcribe_audio
|
||||
Rails.logger.info "Audio transcription successful: #{transcriptions}"
|
||||
@@ -33,6 +38,13 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
account.usage_limits[:captain][:responses][:current_available].positive?
|
||||
end
|
||||
|
||||
def audio_too_large?
|
||||
blob = attachment.file&.blob
|
||||
return false unless blob
|
||||
|
||||
blob.byte_size > WHISPER_BYTE_LIMIT
|
||||
end
|
||||
|
||||
def fetch_audio_file
|
||||
blob = attachment.file.blob
|
||||
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
|
||||
@@ -63,11 +75,14 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
transcribed_text = nil
|
||||
|
||||
File.open(temp_file_path, 'rb') do |file|
|
||||
# temperature: 0.0 minimises Whisper's hallucinations on silence /
|
||||
# near-silent audio; non-zero values trigger spiraling repeats like
|
||||
# "Oh, dear. Oh, dear. Oh, dear." — well-documented Whisper behaviour.
|
||||
response = @client.audio.transcribe(
|
||||
parameters: {
|
||||
model: WHISPER_MODEL,
|
||||
file: file,
|
||||
temperature: 0.4
|
||||
temperature: 0.0
|
||||
}
|
||||
)
|
||||
transcribed_text = response['text']
|
||||
|
||||
@@ -0,0 +1,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
|
||||
+2
-1
@@ -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",
|
||||
|
||||
Generated
+65
-16
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <account_custom_instructions> tags.')
|
||||
).and_return(mock_chat)
|
||||
expect(mock_chat).to receive(:ask) do |prompt|
|
||||
expect(prompt).to include(
|
||||
'<account_custom_instructions>',
|
||||
'Only transfer to a manager after the user explicitly confirms.',
|
||||
'<conversation_context>',
|
||||
'User: I cannot log in',
|
||||
'Assistant: Did you check your inbox?',
|
||||
'User: Yes, still no reset email',
|
||||
'<assistant_response_to_classify>',
|
||||
'Would you like to talk to support?'
|
||||
)
|
||||
expect(prompt).not_to include('"role"', '"content"', '<current_user_message>')
|
||||
|
||||
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
|
||||
@@ -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(
|
||||
'<account_custom_instructions>',
|
||||
'if user enters 1112234 suggest handoff',
|
||||
'</account_custom_instructions>'
|
||||
)
|
||||
) do |instructions|
|
||||
expect(instructions).not_to include('<custom-instructions>')
|
||||
expect(instructions.index('<account_custom_instructions>')).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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-2
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user