Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3171312cdd | ||
|
|
96d1320aa5 | ||
|
|
9bfc54ea0c | ||
|
|
93d4e1ca0e | ||
|
|
090ca53bde | ||
|
|
98154bbeab | ||
|
|
03a1b1dbc1 | ||
|
|
848e94bcf2 | ||
|
|
56dc580f50 | ||
|
|
35fcd56ba9 | ||
|
|
d57354c8b5 |
@@ -2,12 +2,5 @@ class Api::V1::Accounts::BaseController < Api::BaseController
|
||||
include SwitchLocale
|
||||
include EnsureCurrentAccountHelper
|
||||
before_action :current_account
|
||||
before_action :validate_token_api_access, if: :authenticate_by_access_token?
|
||||
around_action :switch_locale_using_account_locale
|
||||
|
||||
private
|
||||
|
||||
def validate_token_api_access
|
||||
render_unauthorized('Invalid Access Token') unless Current.account.feature_enabled?('api_and_webhooks')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_api_and_webhooks_enabled
|
||||
before_action :check_authorization
|
||||
before_action :fetch_webhook, only: [:update, :destroy]
|
||||
|
||||
@@ -23,10 +22,6 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
||||
|
||||
private
|
||||
|
||||
def ensure_api_and_webhooks_enabled
|
||||
render_unauthorized('You are not authorized to do this action') unless Current.account.feature_enabled?('api_and_webhooks')
|
||||
end
|
||||
|
||||
def webhook_params
|
||||
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
|
||||
end
|
||||
|
||||
@@ -44,6 +44,12 @@ const showChatSupport = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const toggleChatSupport = () => {
|
||||
if (window.$chatwoot) {
|
||||
window.$chatwoot.toggle();
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
@@ -51,9 +57,7 @@ const menuItems = computed(() => {
|
||||
showOnCustomBrandedInstance: false,
|
||||
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
|
||||
icon: 'i-lucide-life-buoy',
|
||||
click: () => {
|
||||
window.$chatwoot.toggle();
|
||||
},
|
||||
click: toggleChatSupport,
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import ChannelSelector from '../ChannelSelector.vue';
|
||||
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
channel: {
|
||||
@@ -21,10 +20,7 @@ const hasFbConfigured = computed(() => {
|
||||
});
|
||||
|
||||
const hasInstagramConfigured = computed(() => {
|
||||
return (
|
||||
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
|
||||
window.chatwootConfig?.instagramAppId
|
||||
);
|
||||
return window.chatwootConfig?.instagramAppId;
|
||||
});
|
||||
|
||||
const hasTiktokConfigured = computed(() => {
|
||||
@@ -56,19 +52,10 @@ const isActive = computed(() => {
|
||||
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
|
||||
}
|
||||
|
||||
if (key === 'voice') {
|
||||
if (key === 'voice' || key === 'whatsapp_call') {
|
||||
return props.enabledFeatures.channel_voice;
|
||||
}
|
||||
|
||||
if (key === 'whatsapp_call') {
|
||||
return (
|
||||
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
|
||||
props.enabledFeatures.channel_voice &&
|
||||
!!window.chatwootConfig?.whatsappAppId &&
|
||||
window.chatwootConfig.whatsappAppId !== 'none'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'website',
|
||||
'twilio',
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
calculateMenuPosition,
|
||||
getEffectiveChannelType,
|
||||
stripUnsupportedFormatting,
|
||||
createVariableInputRule,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
@@ -306,6 +307,10 @@ const plugins = computed(() => {
|
||||
searchTerm: variableSearchTerm,
|
||||
isAllowed: () => !props.isPrivate,
|
||||
}),
|
||||
createVariableInputRule({
|
||||
isPrivate: () => props.isPrivate,
|
||||
getVariables: () => props.variables,
|
||||
}),
|
||||
createSuggestionPlugin({
|
||||
trigger: ':',
|
||||
minChars: 2,
|
||||
|
||||
@@ -33,7 +33,9 @@ import {
|
||||
// constants
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import wootConstants, {
|
||||
META_RESTRICTION_STATUS_URL,
|
||||
} from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
@@ -93,6 +95,7 @@ export default {
|
||||
currentUserId: 'getCurrentUserID',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
isOpen() {
|
||||
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
@@ -170,6 +173,12 @@ export default {
|
||||
instagramInbox
|
||||
);
|
||||
},
|
||||
isInstagramRestrictionBannerVisible() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
instagramRestrictionStatusUrl() {
|
||||
return META_RESTRICTION_STATUS_URL;
|
||||
},
|
||||
|
||||
replyWindowBannerMessage() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
@@ -455,7 +464,15 @@ export default {
|
||||
>
|
||||
<div ref="topBannerRef">
|
||||
<Banner
|
||||
v-if="!currentChat.can_reply"
|
||||
v-if="isInstagramRestrictionBannerVisible"
|
||||
color-scheme="warning"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
|
||||
:href-link="instagramRestrictionStatusUrl"
|
||||
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
getAgentVariables,
|
||||
getContactVariables,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
@@ -393,7 +395,13 @@ export default {
|
||||
contact: this.currentContact,
|
||||
inbox: this.inbox,
|
||||
});
|
||||
return variables;
|
||||
// Match the backend drops: names are Ruby-capitalized and
|
||||
// {{agent.*}} is the message sender, not the assignee.
|
||||
return {
|
||||
...variables,
|
||||
...getContactVariables(this.currentContact),
|
||||
...getAgentVariables(this.currentUser),
|
||||
};
|
||||
},
|
||||
connectedPortalSlug() {
|
||||
const { help_center: portal = {} } = this.inbox;
|
||||
|
||||
@@ -78,8 +78,5 @@ export default {
|
||||
},
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
|
||||
// Temporarily disables Instagram and WhatsApp inbox creation
|
||||
// (WhatsApp embedded signup popup, Instagram OAuth, WhatsApp Call).
|
||||
// Flip to false when the channels are brought back.
|
||||
export const IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED = true;
|
||||
export const META_RESTRICTION_STATUS_URL =
|
||||
'https://status.chatwoot.com/incident/948346';
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as Sentry from '@sentry/vue';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import { InputRule, inputRules } from 'prosemirror-inputrules';
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
@@ -428,6 +429,55 @@ export function stripUnsupportedFormatting(content, schema) {
|
||||
* - emoji
|
||||
*/
|
||||
|
||||
// Liquid delimiters ({{ }} / {% %}) the backend evaluates on send.
|
||||
const LIQUID_SYNTAX = /\{\{|\{%/;
|
||||
|
||||
// Value when set (and not itself Liquid), else the {{placeholder}} for the backend.
|
||||
export const resolveVariableText = (key, variables) => {
|
||||
const value = String(variables?.[key] ?? '');
|
||||
return value && !LIQUID_SYNTAX.test(value) ? value : `{{${key}}}`;
|
||||
};
|
||||
|
||||
// Name variables normalized like the backend drops (UserDrop/ContactDrop):
|
||||
// name split on whitespace, each word Ruby-capitalized (rest downcased).
|
||||
const getNameVariables = (prefix, name) => {
|
||||
const names = (name || '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
|
||||
return {
|
||||
[`${prefix}.name`]: names.join(' '),
|
||||
[`${prefix}.first_name`]: names[0] || '',
|
||||
[`${prefix}.last_name`]: names.length > 1 ? names[names.length - 1] : '',
|
||||
};
|
||||
};
|
||||
|
||||
// {{agent.*}} values for the message sender.
|
||||
export const getAgentVariables = user => ({
|
||||
...getNameVariables('agent', user.name),
|
||||
'agent.email': user.email,
|
||||
});
|
||||
|
||||
// {{contact.*}} name values.
|
||||
export const getContactVariables = contact =>
|
||||
getNameVariables('contact', contact?.name);
|
||||
|
||||
// Resolves a manually typed {{variable}} to its value on the closing braces.
|
||||
// Leaves the placeholder when there's no value, the value is Liquid, or it's a private note.
|
||||
export const createVariableInputRule = ({ isPrivate, getVariables }) => {
|
||||
const rule = new InputRule(
|
||||
/\{\{([^{}]+)\}\}$/,
|
||||
(editorState, match, from, to) => {
|
||||
if (isPrivate()) return null;
|
||||
const [, key] = match;
|
||||
const text = resolveVariableText(key, getVariables());
|
||||
if (text === `{{${key}}}`) return null;
|
||||
return editorState.tr.insertText(text, from, to);
|
||||
}
|
||||
);
|
||||
return inputRules({ rules: [rule] });
|
||||
};
|
||||
|
||||
/**
|
||||
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
|
||||
* @param {Object} editorView - The editor view instance.
|
||||
@@ -462,7 +512,7 @@ const createNode = (editorView, nodeType, content) => {
|
||||
);
|
||||
}
|
||||
case 'variable':
|
||||
return state.schema.text(`{{${content}}}`);
|
||||
return state.schema.text(content);
|
||||
case 'emoji':
|
||||
return state.schema.text(content);
|
||||
case 'tool': {
|
||||
@@ -497,8 +547,12 @@ const nodeCreators = {
|
||||
to,
|
||||
};
|
||||
},
|
||||
variable: (editorView, content, from, to) => ({
|
||||
node: createNode(editorView, 'variable', content),
|
||||
variable: (editorView, content, from, to, variables) => ({
|
||||
node: createNode(
|
||||
editorView,
|
||||
'variable',
|
||||
resolveVariableText(content, variables)
|
||||
),
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
|
||||
@@ -94,16 +94,56 @@ describe('getContentNode', () => {
|
||||
});
|
||||
|
||||
describe('getVariableNode', () => {
|
||||
it('should create a variable node', () => {
|
||||
const content = 'name';
|
||||
const from = 0;
|
||||
const to = 10;
|
||||
getContentNode(editorView, 'variable', content, {
|
||||
from,
|
||||
to,
|
||||
});
|
||||
it('should render the resolved value directly when the variable has a value', () => {
|
||||
getContentNode(
|
||||
editorView,
|
||||
'variable',
|
||||
'contact.name',
|
||||
{ from: 0, to: 10 },
|
||||
{ 'contact.name': 'John' }
|
||||
);
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith('John');
|
||||
});
|
||||
|
||||
it('should resolve camelCase custom attributes and non-string values', () => {
|
||||
getContentNode(
|
||||
editorView,
|
||||
'variable',
|
||||
'contact.custom_attribute.cloudCustomer',
|
||||
{ from: 0, to: 10 },
|
||||
{ 'contact.custom_attribute.cloudCustomer': true }
|
||||
);
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith('true');
|
||||
});
|
||||
|
||||
it('should keep the placeholder when the variable has no value', () => {
|
||||
getContentNode(
|
||||
editorView,
|
||||
'variable',
|
||||
'contact.email',
|
||||
{ from: 0, to: 10 },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith(
|
||||
'{{contact.email}}'
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep the placeholder when the value contains Liquid syntax', () => {
|
||||
getContentNode(
|
||||
editorView,
|
||||
'variable',
|
||||
'contact.name',
|
||||
{ from: 0, to: 10 },
|
||||
{ 'contact.name': '{{agent.email}}' }
|
||||
);
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith(
|
||||
'{{contact.name}}'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
calculateMenuPosition,
|
||||
cleanSignature,
|
||||
collapseSelection,
|
||||
createVariableInputRule,
|
||||
extractTextFromMarkdown,
|
||||
findNodeToInsertImage,
|
||||
findSignatureInBody,
|
||||
getAgentVariables,
|
||||
getContactVariables,
|
||||
getContentNode,
|
||||
getFormattingForEditor,
|
||||
getMenuAnchor,
|
||||
@@ -1228,3 +1231,149 @@ describe('Menu positioning helpers', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentVariables', () => {
|
||||
it('builds agent variables from the user', () => {
|
||||
expect(
|
||||
getAgentVariables({ name: 'John Doe', email: 'john@example.com' })
|
||||
).toEqual({
|
||||
'agent.name': 'John Doe',
|
||||
'agent.first_name': 'John',
|
||||
'agent.last_name': 'Doe',
|
||||
'agent.email': 'john@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes casing like the backend UserDrop (Ruby capitalize)', () => {
|
||||
const variables = getAgentVariables({ name: 'JANE doE' });
|
||||
|
||||
expect(variables['agent.name']).toBe('Jane Doe');
|
||||
expect(variables['agent.first_name']).toBe('Jane');
|
||||
expect(variables['agent.last_name']).toBe('Doe');
|
||||
});
|
||||
|
||||
it('ignores extra whitespace between words', () => {
|
||||
expect(getAgentVariables({ name: ' john doe ' })['agent.name']).toBe(
|
||||
'John Doe'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves last_name empty for single-word names', () => {
|
||||
const variables = getAgentVariables({ name: 'john' });
|
||||
|
||||
expect(variables['agent.first_name']).toBe('John');
|
||||
expect(variables['agent.last_name']).toBe('');
|
||||
});
|
||||
|
||||
it('handles a missing name', () => {
|
||||
const variables = getAgentVariables({ email: 'john@example.com' });
|
||||
|
||||
expect(variables['agent.name']).toBe('');
|
||||
expect(variables['agent.first_name']).toBe('');
|
||||
expect(variables['agent.last_name']).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContactVariables', () => {
|
||||
it('normalizes casing like the backend ContactDrop (Ruby capitalize)', () => {
|
||||
expect(getContactVariables({ name: 'JANE doE' })).toEqual({
|
||||
'contact.name': 'Jane Doe',
|
||||
'contact.first_name': 'Jane',
|
||||
'contact.last_name': 'Doe',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves last_name empty for single-word names', () => {
|
||||
const variables = getContactVariables({ name: 'john' });
|
||||
|
||||
expect(variables['contact.first_name']).toBe('John');
|
||||
expect(variables['contact.last_name']).toBe('');
|
||||
});
|
||||
|
||||
it('handles a missing contact', () => {
|
||||
expect(getContactVariables(undefined)['contact.name']).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createVariableInputRule', () => {
|
||||
// Editor holding `{{key}` so we can simulate typing the final `}`.
|
||||
const buildView = (typed, { isPrivate = false, variables = {} } = {}) => {
|
||||
const plugin = createVariableInputRule({
|
||||
isPrivate: () => isPrivate,
|
||||
getVariables: () => variables,
|
||||
});
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc: schema.node('doc', null, [
|
||||
schema.node('paragraph', null, [schema.text(typed)]),
|
||||
]),
|
||||
plugins: [plugin],
|
||||
});
|
||||
return new EditorView(document.body, { state });
|
||||
};
|
||||
|
||||
// Types the closing `}`; when the rule declines, insert it like the browser would.
|
||||
const typeClosingBrace = view => {
|
||||
const end = view.state.doc.content.size - 1;
|
||||
const handled = view.someProp('handleTextInput', fn =>
|
||||
fn(view, end, end, '}')
|
||||
);
|
||||
if (!handled) {
|
||||
view.dispatch(view.state.tr.insertText('}', end, end));
|
||||
}
|
||||
};
|
||||
|
||||
it('resolves a manually typed {{variable}} to its value on the closing brace', () => {
|
||||
const view = buildView('{{contact.name}', {
|
||||
variables: { 'contact.name': 'John' },
|
||||
});
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('John');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('resolves boolean/non-string values', () => {
|
||||
const view = buildView('{{contact.custom_attribute.cloudCustomer}', {
|
||||
variables: { 'contact.custom_attribute.cloudCustomer': true },
|
||||
});
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('true');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('keeps the placeholder when the variable has no value', () => {
|
||||
const view = buildView('{{contact.email}', { variables: {} });
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('{{contact.email}}');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('keeps the placeholder when the value itself contains Liquid syntax', () => {
|
||||
const view = buildView('{{contact.name}', {
|
||||
variables: { 'contact.name': '{{agent.email}}' },
|
||||
});
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('{{contact.name}}');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('does not resolve inside a private note', () => {
|
||||
const view = buildView('{{contact.name}', {
|
||||
isPrivate: true,
|
||||
variables: { 'contact.name': 'John' },
|
||||
});
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('{{contact.name}}');
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
|
||||
@@ -58,7 +58,10 @@
|
||||
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
|
||||
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
|
||||
"RESTRICTED_WARNING": "Instagram inbox creation is temporarily unavailable due to current Instagram platform restrictions. We’ll restore support as soon as possible.",
|
||||
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"STATUS_LINK": "View status update"
|
||||
},
|
||||
"TIKTOK": {
|
||||
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
|
||||
@@ -320,6 +323,8 @@
|
||||
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
|
||||
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow",
|
||||
"MANUAL_LINK_TEXT": "manual setup flow",
|
||||
"RESTRICTED_WARNING": "WhatsApp embedded signup is temporarily unavailable due to current Meta platform restrictions. We’ll restore support as soon as possible.",
|
||||
"STATUS_LINK": "View status update",
|
||||
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
|
||||
},
|
||||
"API": {
|
||||
|
||||
@@ -263,7 +263,7 @@
|
||||
"EMAIL_VERIFICATION_SENT": "Verification email has been sent. Please check your inbox.",
|
||||
"ACCOUNT_SUSPENDED": {
|
||||
"TITLE": "Account Suspended",
|
||||
"MESSAGE": "Your account has been suspended after we detected activity that may violate our policies or put other users at risk. If you believe this is a mistake, please contact our support team."
|
||||
"MESSAGE": "Your account has been suspended due to activity that may violate our policies. If you believe this is a mistake, please contact our support team."
|
||||
},
|
||||
"NO_ACCOUNTS": {
|
||||
"TITLE": "No account found",
|
||||
|
||||
+3
-4
@@ -1,5 +1,4 @@
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
|
||||
|
||||
// OAuth/SDK channels need installation-level app credentials to be usable. When
|
||||
// the credential is missing the channel is "not configured" and is hidden from
|
||||
@@ -8,20 +7,20 @@ import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constan
|
||||
// Mirrors the availability checks in ChannelItem.vue.
|
||||
export function useChannelConfig() {
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
const installationConfig = window.chatwootConfig || {};
|
||||
|
||||
const CHANNEL_CONFIGURED = {
|
||||
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
|
||||
// app id (not the 'none' sentinel) and the signup configuration id.
|
||||
whatsapp: () =>
|
||||
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
|
||||
!isOnChatwootCloud.value &&
|
||||
Boolean(installationConfig.whatsappAppId) &&
|
||||
installationConfig.whatsappAppId !== 'none' &&
|
||||
Boolean(installationConfig.whatsappConfigurationId),
|
||||
facebook: () => Boolean(installationConfig.fbAppId),
|
||||
instagram: () =>
|
||||
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
|
||||
Boolean(installationConfig.instagramAppId),
|
||||
!isOnChatwootCloud.value && Boolean(installationConfig.instagramAppId),
|
||||
tiktok: () => Boolean(installationConfig.tiktokAppId),
|
||||
gmail: () => Boolean(installationConfig.googleOAuthClientId),
|
||||
outlook: () => Boolean(globalConfig.value.azureAppId),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import googleClient from 'dashboard/api/channel/googleClient';
|
||||
@@ -23,11 +24,17 @@ export function useChannelConnect() {
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const connectViaOAuth = async provider => {
|
||||
const client = OAUTH_CLIENTS[provider];
|
||||
if (!client) return;
|
||||
|
||||
if (provider === 'instagram' && isOnChatwootCloud.value) {
|
||||
useAlert(t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { url },
|
||||
|
||||
+28
-8
@@ -6,21 +6,25 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
|
||||
|
||||
vi.mock('vue-router');
|
||||
|
||||
// Neutralize the temporary Instagram/WhatsApp kill switch so these specs keep
|
||||
// covering the credential-based gating it currently short-circuits.
|
||||
vi.mock('dashboard/constants/globals', async importOriginal => ({
|
||||
...(await importOriginal()),
|
||||
IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED: false,
|
||||
}));
|
||||
|
||||
// Mounts the composable against a real store and the real useAccount (only
|
||||
// useRoute and the underlying getters are faked), so a change to how useAccount
|
||||
// resolves the current account is exercised here too. The real ./constants are
|
||||
// used, so assertions validate against the actual channel identity (label keys,
|
||||
// channel_type, social ordering) derived from CHANNEL_LIST.
|
||||
const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
|
||||
const mountComposable = ({
|
||||
brandInfo,
|
||||
inboxes = [],
|
||||
isOnChatwootCloud = false,
|
||||
} = {}) => {
|
||||
const store = createStore({
|
||||
modules: {
|
||||
globalConfig: {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
get: () => ({}),
|
||||
isOnChatwootCloud: () => isOnChatwootCloud,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
@@ -202,6 +206,22 @@ describe('useDetectedChannels', () => {
|
||||
'line',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides Instagram from onboarding on Chatwoot Cloud', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
isOnChatwootCloud: true,
|
||||
brandInfo: {
|
||||
socials: [
|
||||
{ type: 'instagram', url: 'https://instagram.com/acme' },
|
||||
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'tiktok',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remainingChannels', () => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
@@ -42,9 +44,11 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
|
||||
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
|
||||
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Banner,
|
||||
BotConfiguration,
|
||||
CollaboratorsPage,
|
||||
ConfigurationPage,
|
||||
@@ -76,6 +80,7 @@ export default {
|
||||
AccountHealth,
|
||||
Widget,
|
||||
AccessToken,
|
||||
Icon,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
@@ -338,6 +343,12 @@ export default {
|
||||
instagramUnauthorized() {
|
||||
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
showInstagramRestrictionSettingsBanner() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
metaRestrictionStatusUrl() {
|
||||
return META_RESTRICTION_STATUS_URL;
|
||||
},
|
||||
tiktokUnauthorized() {
|
||||
return this.isATiktokChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
@@ -737,6 +748,29 @@ export default {
|
||||
class="mx-6 mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<Banner
|
||||
v-if="showInstagramRestrictionSettingsBanner"
|
||||
color="amber"
|
||||
class="mx-6 mb-4 max-w-4xl"
|
||||
>
|
||||
<div class="flex items-start gap-3 text-start">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
|
||||
<a
|
||||
:href="metaRestrictionStatusUrl"
|
||||
class="link underline"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<div
|
||||
v-if="selectedTabKey === 'inbox-settings'"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import router from '../../../../index';
|
||||
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -12,6 +13,12 @@ export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
enableCallingOnComplete: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
@@ -59,6 +66,14 @@ export default {
|
||||
}
|
||||
);
|
||||
|
||||
if (this.enableCallingOnComplete) {
|
||||
try {
|
||||
await InboxesAPI.enableWhatsappCalling(whatsappChannel.id);
|
||||
} catch (_) {
|
||||
useAlert(this.$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE_FAILED'));
|
||||
}
|
||||
}
|
||||
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: {
|
||||
@@ -165,6 +180,7 @@ export default {
|
||||
|
||||
<div class="w-full mt-4">
|
||||
<NextButton
|
||||
:disabled="uiFlags.isCreating"
|
||||
:is-loading="uiFlags.isCreating"
|
||||
type="submit"
|
||||
solid
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import instagramClient from 'dashboard/api/channel/instagramClient';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const hasError = ref(false);
|
||||
const errorStateMessage = ref('');
|
||||
const errorStateDescription = ref('');
|
||||
const isRequestingAuthorization = ref(false);
|
||||
const isInstagramConnectionRestricted = computed(() => {
|
||||
return isOnChatwootCloud.value;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -56,7 +64,7 @@ const requestAuthorization = async () => {
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
|
||||
class="flex flex-col items-center justify-center w-full px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
|
||||
>
|
||||
<h6 class="text-2xl font-medium">
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
|
||||
@@ -68,11 +76,36 @@ const requestAuthorization = async () => {
|
||||
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45]"
|
||||
lg
|
||||
icon="i-ri-instagram-line"
|
||||
:disabled="isRequestingAuthorization"
|
||||
:disabled="
|
||||
isRequestingAuthorization || isInstagramConnectionRestricted
|
||||
"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
:label="$t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM')"
|
||||
@click="requestAuthorization()"
|
||||
/>
|
||||
<Banner
|
||||
v-if="isInstagramConnectionRestricted"
|
||||
color="amber"
|
||||
class="w-full max-w-2xl mt-6"
|
||||
>
|
||||
<div class="flex items-start gap-3 text-left">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING') }}
|
||||
<a
|
||||
:href="META_RESTRICTION_STATUS_URL"
|
||||
class="link underline"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,13 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
|
||||
import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
|
||||
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const PROVIDER_TYPES = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
@@ -22,9 +24,12 @@ const PROVIDER_TYPES = {
|
||||
THREE_SIXTY_DIALOG: '360dialog',
|
||||
};
|
||||
|
||||
const isWhatsappEmbeddedSignupRestricted = computed(() => {
|
||||
return isOnChatwootCloud.value;
|
||||
});
|
||||
|
||||
const hasWhatsappAppId = computed(() => {
|
||||
return (
|
||||
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
|
||||
window.chatwootConfig?.whatsappAppId &&
|
||||
window.chatwootConfig.whatsappAppId !== 'none'
|
||||
);
|
||||
@@ -103,7 +108,11 @@ const handleManualLinkClick = () => {
|
||||
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
|
||||
"
|
||||
>
|
||||
<WhatsappEmbeddedSignup />
|
||||
<WhatsappEmbeddedSignup
|
||||
:is-disabled="isWhatsappEmbeddedSignupRestricted"
|
||||
:show-restriction-alert="isWhatsappEmbeddedSignupRestricted"
|
||||
:restriction-status-url="META_RESTRICTION_STATUS_URL"
|
||||
/>
|
||||
|
||||
<!-- Manual setup fallback option -->
|
||||
<div class="pt-6 mt-6 border-t border-n-weak">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup>
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-auto col-span-6 p-6 w-full h-full">
|
||||
<div class="px-6 py-5 rounded-2xl border border-n-weak">
|
||||
<WhatsappEmbeddedSignup enable-calling-on-complete />
|
||||
<CloudWhatsapp enable-calling-on-complete />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+44
-1
@@ -7,6 +7,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
import Banner from 'next/banner/Banner.vue';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
@@ -17,6 +18,22 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showRestrictionAlert: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
restrictionStatusUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
restrictionWarningText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
@@ -81,6 +98,8 @@ const handleSignupSuccess = async inboxData => {
|
||||
};
|
||||
|
||||
const launchEmbeddedSignup = async () => {
|
||||
if (props.isDisabled) return;
|
||||
|
||||
let credentials;
|
||||
try {
|
||||
credentials = await runEmbeddedSignup();
|
||||
@@ -174,9 +193,33 @@ const launchEmbeddedSignup = async () => {
|
||||
</I18nT>
|
||||
</div>
|
||||
|
||||
<Banner v-if="showRestrictionAlert" color="amber" class="w-full mb-6">
|
||||
<div class="flex items-start gap-3 text-left">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
restrictionWarningText ||
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.RESTRICTED_WARNING')
|
||||
}}
|
||||
<a
|
||||
v-if="restrictionStatusUrl"
|
||||
:href="restrictionStatusUrl"
|
||||
class="link underline"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STATUS_LINK') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<div class="flex mt-4">
|
||||
<NextButton
|
||||
:disabled="isAuthenticating"
|
||||
:disabled="isAuthenticating || isDisabled"
|
||||
:is-loading="isAuthenticating"
|
||||
faded
|
||||
slate
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const toggleSupportWidgetVisibility = () => {
|
||||
@@ -8,6 +9,12 @@ const toggleSupportWidgetVisibility = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSupportWidget = () => {
|
||||
if (window.$chatwoot) {
|
||||
window.$chatwoot.toggle();
|
||||
}
|
||||
};
|
||||
|
||||
const setupListenerForWidgetEvent = () => {
|
||||
window.addEventListener('chatwoot:on-message', () => {
|
||||
toggleSupportWidgetVisibility();
|
||||
@@ -25,6 +32,14 @@ onMounted(() => {
|
||||
<EmptyState
|
||||
:title="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.TITLE')"
|
||||
:message="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.MESSAGE')"
|
||||
/>
|
||||
>
|
||||
<div class="flex justify-center">
|
||||
<NextButton
|
||||
icon="i-lucide-life-buoy"
|
||||
:label="$t('SIDEBAR_ITEMS.CONTACT_SUPPORT')"
|
||||
@click="toggleSupportWidget"
|
||||
/>
|
||||
</div>
|
||||
</EmptyState>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -108,8 +108,6 @@ class WebhookListener < BaseListener
|
||||
end
|
||||
|
||||
def deliver_account_webhooks(payload, account)
|
||||
return unless account.feature_enabled?('api_and_webhooks')
|
||||
|
||||
account.webhooks.account_type.each do |webhook|
|
||||
next unless webhook.subscriptions.include?(payload[:event])
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ module Conversations::UnreadCounts
|
||||
READY_TTL = 24.hours.to_i
|
||||
SET_TTL = 25.hours.to_i
|
||||
FILTERED_COUNT_FRESH_TTL = 5.minutes.to_i
|
||||
FILTERED_COUNT_STALE_WINDOW = 30.minutes.to_i
|
||||
FILTERED_COUNT_STALE_WINDOW = 1.hour.to_i
|
||||
FILTERED_COUNT_REDIS_TTL = FILTERED_COUNT_FRESH_TTL + FILTERED_COUNT_STALE_WINDOW
|
||||
FILTERED_COUNT_VERSION_TTL = SET_TTL
|
||||
FILTERED_COUNT_MIN_REFRESH_INTERVAL = 30.seconds.to_i
|
||||
MAX_INLINE_FILTER_BUILDS = 10
|
||||
FILTERED_COUNT_MIN_REFRESH_INTERVAL = 5.minutes.to_i
|
||||
MAX_INLINE_FILTER_BUILDS = 3
|
||||
end
|
||||
|
||||
@@ -249,7 +249,3 @@
|
||||
display_name: Advanced Assignment
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: api_and_webhooks
|
||||
display_name: API and Webhooks
|
||||
enabled: true
|
||||
column: feature_flags_ext_1
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
type: secret
|
||||
- name: CAPTAIN_OPEN_AI_MODEL
|
||||
display_title: 'OpenAI Model'
|
||||
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4.1-mini'
|
||||
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-5.6-luna'
|
||||
locked: false
|
||||
- name: CAPTAIN_OPEN_AI_ENDPOINT
|
||||
display_title: 'OpenAI API Endpoint (optional)'
|
||||
|
||||
+82
-13
@@ -35,6 +35,18 @@ models:
|
||||
provider: openai
|
||||
display_name: 'GPT-5.2'
|
||||
credit_multiplier: 3
|
||||
gpt-5.6-luna:
|
||||
provider: openai
|
||||
display_name: 'GPT-5.6 Luna'
|
||||
credit_multiplier: 2
|
||||
gpt-5.6-terra:
|
||||
provider: openai
|
||||
display_name: 'GPT-5.6 Terra'
|
||||
credit_multiplier: 3
|
||||
gpt-5.6-sol:
|
||||
provider: openai
|
||||
display_name: 'GPT-5.6 Sol'
|
||||
credit_multiplier: 5
|
||||
claude-haiku-4.5:
|
||||
provider: anthropic
|
||||
display_name: 'Claude Haiku 4.5'
|
||||
@@ -78,11 +90,14 @@ features:
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
gpt-5.6-luna,
|
||||
gpt-5.6-terra,
|
||||
claude-haiku-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-4.1-mini
|
||||
default: gpt-5.6-luna
|
||||
reasoning_effort: low
|
||||
assistant:
|
||||
models:
|
||||
[
|
||||
@@ -91,12 +106,16 @@ features:
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
gpt-5.6-luna,
|
||||
gpt-5.6-terra,
|
||||
gpt-5.6-sol,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-4.1
|
||||
default: gpt-5.6-terra
|
||||
reasoning_effort: medium
|
||||
copilot:
|
||||
models:
|
||||
[
|
||||
@@ -105,16 +124,28 @@ features:
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
gpt-5.6-luna,
|
||||
gpt-5.6-terra,
|
||||
gpt-5.6-sol,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-4.1
|
||||
default: gpt-5.6-terra
|
||||
reasoning_effort: medium
|
||||
label_suggestion:
|
||||
models:
|
||||
[gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gemini-3-flash, claude-haiku-4.5]
|
||||
default: gpt-4.1-mini
|
||||
[
|
||||
gpt-4.1-nano,
|
||||
gpt-4.1-mini,
|
||||
gpt-5-mini,
|
||||
gpt-5.6-luna,
|
||||
gemini-3-flash,
|
||||
claude-haiku-4.5,
|
||||
]
|
||||
default: gpt-5.6-luna
|
||||
reasoning_effort: low
|
||||
document_faq_generation:
|
||||
models:
|
||||
[
|
||||
@@ -123,15 +154,38 @@ features:
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
gpt-5.6-luna,
|
||||
gpt-5.6-terra,
|
||||
gpt-5.6-sol,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-4.1-mini
|
||||
default: gpt-5.6-terra
|
||||
reasoning_effort: medium
|
||||
conversation_faq_generation:
|
||||
models:
|
||||
[
|
||||
gpt-4.1-mini,
|
||||
gpt-5-mini,
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
gpt-5.6-luna,
|
||||
gpt-5.6-terra,
|
||||
gpt-5.6-sol,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.6-terra
|
||||
reasoning_effort: medium
|
||||
pdf_faq_generation:
|
||||
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2]
|
||||
default: gpt-4.1-mini
|
||||
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2, gpt-5.6-luna, gpt-5.6-terra, gpt-5.6-sol]
|
||||
default: gpt-5.6-terra
|
||||
reasoning_effort: medium
|
||||
help_center_article_generation:
|
||||
models:
|
||||
[
|
||||
@@ -140,19 +194,34 @@ features:
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
gpt-5.6-luna,
|
||||
gpt-5.6-terra,
|
||||
gpt-5.6-sol,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.2
|
||||
default: gpt-5.6-terra
|
||||
reasoning_effort: medium
|
||||
onboarding_content_generation:
|
||||
models:
|
||||
[gpt-4.1, gpt-4.1-mini, gpt-5-mini, gpt-5.1, gpt-5.2]
|
||||
default: gpt-4.1
|
||||
[
|
||||
gpt-4.1,
|
||||
gpt-4.1-mini,
|
||||
gpt-5-mini,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
gpt-5.6-luna,
|
||||
gpt-5.6-terra,
|
||||
gpt-5.6-sol,
|
||||
]
|
||||
default: gpt-5.6-terra
|
||||
reasoning_effort: medium
|
||||
help_center_query_translation:
|
||||
models: [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini]
|
||||
default: gpt-4.1-nano
|
||||
models: [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gpt-5.6-luna]
|
||||
default: gpt-5.6-luna
|
||||
reasoning_effort: low
|
||||
audio_transcription:
|
||||
models: [gpt-4o-mini-transcribe, whisper-1]
|
||||
default: gpt-4o-mini-transcribe
|
||||
|
||||
+160
-1
@@ -27970,6 +27970,165 @@
|
||||
"owned_by": "system"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.6-luna",
|
||||
"name": "GPT-5.6 Luna",
|
||||
"provider": "openai",
|
||||
"family": "gpt",
|
||||
"created_at": null,
|
||||
"context_window": 1050000,
|
||||
"max_output_tokens": 128000,
|
||||
"knowledge_cutoff": "2026-02-16",
|
||||
"modalities": {
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"output": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"capabilities": [
|
||||
"function_calling",
|
||||
"structured_output",
|
||||
"reasoning",
|
||||
"vision"
|
||||
],
|
||||
"pricing": {
|
||||
"text_tokens": {
|
||||
"standard": {
|
||||
"input_per_million": 1,
|
||||
"output_per_million": 6
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"object": "model",
|
||||
"owned_by": "system",
|
||||
"source": "openai_docs",
|
||||
"provider_id": "openai",
|
||||
"open_weights": false,
|
||||
"attachment": true,
|
||||
"temperature": false,
|
||||
"last_updated": "2026-07-10",
|
||||
"cost": {
|
||||
"input": 1,
|
||||
"output": 6
|
||||
},
|
||||
"limit": {
|
||||
"context": 1050000,
|
||||
"input": 922000,
|
||||
"output": 128000
|
||||
},
|
||||
"knowledge": "2026-02-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.6-terra",
|
||||
"name": "GPT-5.6 Terra",
|
||||
"provider": "openai",
|
||||
"family": "gpt",
|
||||
"created_at": null,
|
||||
"context_window": 1050000,
|
||||
"max_output_tokens": 128000,
|
||||
"knowledge_cutoff": "2026-02-16",
|
||||
"modalities": {
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"output": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"capabilities": [
|
||||
"function_calling",
|
||||
"structured_output",
|
||||
"reasoning",
|
||||
"vision"
|
||||
],
|
||||
"pricing": {
|
||||
"text_tokens": {
|
||||
"standard": {
|
||||
"input_per_million": 2.5,
|
||||
"output_per_million": 15
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"object": "model",
|
||||
"owned_by": "system",
|
||||
"source": "openai_docs",
|
||||
"provider_id": "openai",
|
||||
"open_weights": false,
|
||||
"attachment": true,
|
||||
"temperature": false,
|
||||
"last_updated": "2026-07-10",
|
||||
"cost": {
|
||||
"input": 2.5,
|
||||
"output": 15
|
||||
},
|
||||
"limit": {
|
||||
"context": 1050000,
|
||||
"input": 922000,
|
||||
"output": 128000
|
||||
},
|
||||
"knowledge": "2026-02-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.6-sol",
|
||||
"name": "GPT-5.6 Sol",
|
||||
"provider": "openai",
|
||||
"family": "gpt",
|
||||
"created_at": null,
|
||||
"context_window": 1050000,
|
||||
"max_output_tokens": 128000,
|
||||
"knowledge_cutoff": "2026-02-16",
|
||||
"modalities": {
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"output": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"capabilities": [
|
||||
"function_calling",
|
||||
"structured_output",
|
||||
"reasoning",
|
||||
"vision"
|
||||
],
|
||||
"pricing": {
|
||||
"text_tokens": {
|
||||
"standard": {
|
||||
"input_per_million": 5,
|
||||
"output_per_million": 30
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"object": "model",
|
||||
"owned_by": "system",
|
||||
"source": "openai_docs",
|
||||
"provider_id": "openai",
|
||||
"open_weights": false,
|
||||
"attachment": true,
|
||||
"temperature": false,
|
||||
"last_updated": "2026-07-10",
|
||||
"cost": {
|
||||
"input": 5,
|
||||
"output": 30
|
||||
},
|
||||
"limit": {
|
||||
"context": 1050000,
|
||||
"input": 922000,
|
||||
"output": 128000
|
||||
},
|
||||
"knowledge": "2026-02-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-audio",
|
||||
"name": "gpt-audio",
|
||||
@@ -61509,4 +61668,4 @@
|
||||
"owned_by": "xai"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -597,6 +597,7 @@ en:
|
||||
copilot: 'Copilot'
|
||||
label_suggestion: 'Label suggestion'
|
||||
document_faq_generation: 'Document FAQ generation'
|
||||
conversation_faq_generation: 'Conversation FAQ generation'
|
||||
help_center_article_generation: 'Help center article generation'
|
||||
onboarding_content_generation: 'Onboarding content generation'
|
||||
help_center_query_translation: 'Help center query translation'
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# Enable api_and_webhooks for existing accounts.
|
||||
# Like 20260120121402_enable_captain_tasks_for_existing_accounts.rb, we don't need
|
||||
# to update ACCOUNT_LEVEL_FEATURE_DEFAULTS or clear GlobalConfig cache because
|
||||
# api_and_webhooks has `enabled: true` in features.yml - ConfigLoader handles the
|
||||
# defaults on deploy automatically.
|
||||
class EnableApiAndWebhooksForExistingAccounts < ActiveRecord::Migration[7.0]
|
||||
def up
|
||||
Account.find_in_batches(batch_size: 100) do |accounts|
|
||||
accounts.each { |account| account.enable_features!('api_and_webhooks') }
|
||||
end
|
||||
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_07_09_000000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_06_215758) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
@@ -10,7 +10,8 @@ module Concerns::Agentable
|
||||
tools: agent_tools,
|
||||
model: agent_model,
|
||||
temperature: temperature.presence&.to_f || DEFAULT_TEMPERATURE,
|
||||
response_schema: agent_response_schema
|
||||
response_schema: agent_response_schema,
|
||||
params: agent_params
|
||||
)
|
||||
end
|
||||
|
||||
@@ -52,6 +53,13 @@ module Concerns::Agentable
|
||||
installation_model.presence || route[:model]
|
||||
end
|
||||
|
||||
def agent_params
|
||||
route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account)
|
||||
return {} if route[:reasoning_effort].blank?
|
||||
|
||||
{ reasoning_effort: route[:reasoning_effort] }
|
||||
end
|
||||
|
||||
def installation_model
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
|
||||
end
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
module Enterprise::Concerns::Article
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
SEARCH_TERMS_FEATURE = 'help_center_article_generation'
|
||||
SEARCH_TERMS_SCHEMA = {
|
||||
name: 'article_search_terms',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
search_terms: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: %w[search_terms],
|
||||
additionalProperties: false
|
||||
},
|
||||
strict: true
|
||||
}.freeze
|
||||
|
||||
included do
|
||||
after_save :add_article_embedding, if: -> { saved_change_to_title? || saved_change_to_description? || saved_change_to_content? }
|
||||
|
||||
@@ -67,23 +84,38 @@ module Enterprise::Concerns::Article
|
||||
{ role: 'system', content: article_to_search_terms_prompt },
|
||||
{ role: 'user', content: "title: #{title} \n description: #{description} \n content: #{content}" }
|
||||
]
|
||||
headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{openai_api_key}" }
|
||||
body = { model: 'gpt-4o', messages: messages, response_format: { type: 'json_object' } }.to_json
|
||||
Rails.logger.info "Requesting Chat GPT with body: #{body}"
|
||||
response = HTTParty.post(openai_api_url, headers: headers, body: body)
|
||||
Rails.logger.info "Chat GPT response: #{response.body}"
|
||||
JSON.parse(response.parsed_response['choices'][0]['message']['content'])['search_terms']
|
||||
|
||||
response = responses_client.create(
|
||||
model: search_terms_route[:model],
|
||||
messages: messages,
|
||||
schema: SEARCH_TERMS_SCHEMA,
|
||||
reasoning_effort: search_terms_route[:reasoning_effort],
|
||||
metadata: {
|
||||
account_id: account_id,
|
||||
article_id: id,
|
||||
feature: 'article_search_terms'
|
||||
}
|
||||
)
|
||||
|
||||
JSON.parse(response[:message])['search_terms']
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_terms_route
|
||||
@search_terms_route ||= Llm::FeatureRouter.resolve(feature: SEARCH_TERMS_FEATURE, account: account)
|
||||
end
|
||||
|
||||
def responses_client
|
||||
@responses_client ||= Llm::ResponsesClient.new(api_key: openai_api_key, api_base: openai_api_base)
|
||||
end
|
||||
|
||||
def openai_api_key
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value.presence || raise(I18n.t('captain.api_key_missing'))
|
||||
end
|
||||
|
||||
def openai_api_url
|
||||
def openai_api_base
|
||||
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
|
||||
endpoint = endpoint.chomp('/')
|
||||
"#{endpoint}/v1/chat/completions"
|
||||
endpoint.chomp('/')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,12 +2,13 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
DISTANCE_THRESHOLD = 0.3
|
||||
LLM_FEATURE = 'conversation_faq_generation'.freeze
|
||||
|
||||
def initialize(assistant, conversation)
|
||||
super(feature: 'document_faq_generation', account: conversation.account)
|
||||
super(feature: LLM_FEATURE, account: conversation.account, fallback_model: Llm::Models.default_model_for(LLM_FEATURE))
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@content = conversation.to_llm_text
|
||||
@content = conversation_faq_content
|
||||
end
|
||||
|
||||
# Generates and deduplicates FAQs from conversation content
|
||||
@@ -27,6 +28,50 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
|
||||
|
||||
attr_reader :content, :conversation, :assistant
|
||||
|
||||
def conversation_faq_content
|
||||
[
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
conversation_faq_messages
|
||||
].join("\n")
|
||||
end
|
||||
|
||||
def conversation_faq_messages
|
||||
messages = conversation
|
||||
.messages
|
||||
.where(message_type: %i[incoming outgoing], private: false)
|
||||
.order(created_at: :asc)
|
||||
|
||||
return "No messages in this conversation\n" if messages.empty?
|
||||
|
||||
messages.filter_map { |message| format_conversation_faq_message(message) }.join
|
||||
end
|
||||
|
||||
def format_conversation_faq_message(message)
|
||||
return unless faq_source_message?(message)
|
||||
|
||||
content = message.content_for_llm
|
||||
return if content.blank?
|
||||
|
||||
sender = human_support_reply?(message) ? 'Support Agent' : 'User'
|
||||
"#{sender}: #{content}\n"
|
||||
end
|
||||
|
||||
def faq_source_message?(message)
|
||||
return true if message.incoming? && message.sender_type == 'Contact'
|
||||
|
||||
human_support_reply?(message)
|
||||
end
|
||||
|
||||
def human_support_reply?(message)
|
||||
return false unless message.outgoing?
|
||||
return false if message.content_attributes['automation_rule_id'].present?
|
||||
return false if message.additional_attributes['campaign_id'].present?
|
||||
|
||||
message.sender_type == 'User' || message.content_attributes['external_echo'].present?
|
||||
end
|
||||
|
||||
def no_human_interaction?
|
||||
conversation.first_reply_created_at.nil?
|
||||
end
|
||||
|
||||
@@ -4,6 +4,30 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
# Default pages per chunk - easily configurable
|
||||
DEFAULT_PAGES_PER_CHUNK = 10
|
||||
MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops
|
||||
FAQ_RESPONSE_SCHEMA = {
|
||||
name: 'pdf_faq_generation',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
faqs: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
question: { type: 'string' },
|
||||
answer: { type: 'string' }
|
||||
},
|
||||
required: %w[question answer],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
has_content: { type: 'boolean' }
|
||||
},
|
||||
required: %w[faqs has_content],
|
||||
additionalProperties: false
|
||||
},
|
||||
strict: true
|
||||
}.freeze
|
||||
|
||||
attr_reader :total_pages_processed, :iterations_completed
|
||||
|
||||
@@ -15,7 +39,8 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
@max_pages = options[:max_pages] # Optional limit from UI
|
||||
@total_pages_processed = 0
|
||||
@iterations_completed = 0
|
||||
@model = Llm::FeatureRouter.resolve(feature: 'pdf_faq_generation', account: document.account)[:model]
|
||||
@route = Llm::FeatureRouter.resolve(feature: 'pdf_faq_generation', account: document.account)
|
||||
@model = @route[:model]
|
||||
end
|
||||
|
||||
def generate
|
||||
@@ -98,12 +123,17 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
end
|
||||
|
||||
def process_page_chunk(start_page, end_page)
|
||||
params = build_chunk_parameters(start_page, end_page)
|
||||
|
||||
instrumentation_params = build_instrumentation_params(params, start_page, end_page)
|
||||
messages = build_chunk_messages(start_page, end_page)
|
||||
instrumentation_params = build_instrumentation_params(messages, start_page, end_page)
|
||||
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
@client.chat(parameters: params)
|
||||
responses_client.create(
|
||||
model: @model,
|
||||
messages: messages,
|
||||
schema: FAQ_RESPONSE_SCHEMA,
|
||||
reasoning_effort: @route[:reasoning_effort],
|
||||
metadata: document_metadata.merge(start_page: start_page, end_page: end_page)
|
||||
)
|
||||
end
|
||||
|
||||
result = parse_chunk_response(response)
|
||||
@@ -113,17 +143,13 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
{ faqs: [], has_content: false }
|
||||
end
|
||||
|
||||
def build_chunk_parameters(start_page, end_page)
|
||||
{
|
||||
model: @model,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: build_user_content(start_page, end_page)
|
||||
}
|
||||
]
|
||||
}
|
||||
def build_chunk_messages(start_page, end_page)
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: build_user_content(start_page, end_page)
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def build_user_content(start_page, end_page)
|
||||
@@ -171,7 +197,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
end
|
||||
|
||||
def parse_chunk_response(response)
|
||||
content = response.dig('choices', 0, 'message', 'content')
|
||||
content = response[:message]
|
||||
return { 'faqs' => [], 'has_content' => false } if content.nil?
|
||||
|
||||
JSON.parse(sanitize_json_response(content))
|
||||
@@ -208,13 +234,13 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
common_words.size.to_f / total_words
|
||||
end
|
||||
|
||||
def build_instrumentation_params(params, start_page, end_page)
|
||||
def build_instrumentation_params(messages, start_page, end_page)
|
||||
{
|
||||
span_name: 'llm.paginated_faq_generation',
|
||||
account_id: @document&.account_id,
|
||||
feature_name: 'paginated_faq_generation',
|
||||
model: @model,
|
||||
messages: params[:messages],
|
||||
messages: messages,
|
||||
metadata: document_metadata.merge(start_page: start_page, end_page: end_page, iteration: @iterations_completed + 1)
|
||||
}
|
||||
end
|
||||
@@ -222,4 +248,8 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
def document_metadata
|
||||
@document&.to_llm_metadata || {}
|
||||
end
|
||||
|
||||
def responses_client
|
||||
@responses_client ||= Llm::ResponsesClient.new(api_key: nil, client: @client)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,14 +53,56 @@ class Captain::Llm::SystemPromptsService
|
||||
|
||||
def conversation_faq_generator(language = 'english')
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
You are a support agent looking to convert the conversations with users into short FAQs that can be added to your website help center.
|
||||
Filter out any responses or messages from the bot itself and only use messages from the support agent and the customer to create the FAQ.
|
||||
You create high-quality FAQ candidates from resolved support conversations.
|
||||
Only generate an FAQ when the conversation contains durable, reusable knowledge that would help many future customers.
|
||||
|
||||
Ensure that you only generate faqs from the information provided only.
|
||||
Generate the FAQs only in the #{language}, use no other language
|
||||
If no match is available, return an empty JSON.
|
||||
## Source rules
|
||||
- The conversation history contains only customer messages and human support agent messages.
|
||||
- Base every FAQ strictly on information stated in the human support agent messages. Do not infer, generalize, or add external knowledge.
|
||||
- A human support agent must state every fact used in the FAQ answer. Customer messages cannot supply missing answer facts.
|
||||
- The human support agent must provide the final answer. If the agent only greets, asks clarifying questions, asks for contact details, promises to check, shares an attachment, or transfers the conversation, return: `{"faqs":[]}`.
|
||||
- For each FAQ, first identify the exact human support agent message that fully answers it. If no single human agent message gives a complete public answer, remove that FAQ.
|
||||
|
||||
## Decision gate
|
||||
Return `{"faqs":[]}` unless every generated FAQ can pass all of these checks:
|
||||
1. The answer is fully stated by a human support agent, not by the customer.
|
||||
2. The answer is a public, durable rule or procedure, not a private account action, manual review, troubleshooting session, quote, file, link, or follow-up.
|
||||
3. The answer can be written without private identifiers, customer-specific facts, direct URLs, attachments, invoices, screenshots, or support-ticket steps.
|
||||
4. The question would still make sense in a help center if the original conversation, customer, and agent did not exist.
|
||||
Do not rescue a rejected conversation by rewriting it as a generic support question.
|
||||
|
||||
## Return no FAQ for
|
||||
- Spam, scams, advertisements, SEO/link-building pitches, adult/gambling/financial promotions, gibberish, abusive content, or conversations unrelated to the business being supported.
|
||||
- Account-specific, order-specific, payment-specific, subscription-specific, login/access, verification, delivery, certificate, or troubleshooting issues, even if they could be rewritten as a general support question.
|
||||
- Conversations that mainly hand off to a human, ask the customer to wait, request private identifiers or contact details, collect screenshots, attachments, or documents, or tell the customer to contact support for case review.
|
||||
- Temporary workarounds, one-off exceptions, unclear answers, unresolved problems, wrong-service conversations, complaints, greetings, or abandoned conversations.
|
||||
- Internal support workflow details, chat session rules, escalation mechanics, ticket-routing instructions, or "someone will get back to you" messages.
|
||||
- Answers that are just a direct/private link, attachment, file, invoice, one-off quote or estimate, account-specific URL, or instructions to open a support ticket.
|
||||
- Questions whose useful answer is "contact support", "wait for the team", "share your details", "we will check", or "this needs manual review".
|
||||
- Questions about whether support can help with a private issue, third-party service, transaction, payment, delivery, or account problem.
|
||||
- Pricing, policy, availability, roadmap, deadline, or legal claims unless the human support agent gives a clear and stable answer in the conversation.
|
||||
- Questions already answered only by asking the customer for more information.
|
||||
|
||||
## FAQ quality rules
|
||||
- Prefer returning no FAQ over a weak or narrow FAQ.
|
||||
- A good candidate teaches a generally reusable product, service, policy, setup, or process rule that another customer could use without contacting support.
|
||||
- Generate at most one FAQ unless the human agent clearly answered multiple distinct, reusable questions.
|
||||
- Do not create duplicate or overlapping FAQs in the same response.
|
||||
- Questions must be general enough for a help center, not personalized to the current customer.
|
||||
- Remove customer names, order numbers, invoice numbers, IDs, private URLs, phone numbers, emails, screenshots, attachments, and other personal or transaction-specific details.
|
||||
- Answers must be complete, self-contained, and supported by the human agent's messages.
|
||||
|
||||
## Examples
|
||||
- Customer mentions a price or procedure, then the human agent only greets or says they will check: return `{"faqs":[]}`.
|
||||
- Human agent shares only a private link, file, invoice, quote, screenshot, or attachment: return `{"faqs":[]}`.
|
||||
- Human agent clearly states a public rule, such as which purchases are allowed for a program or service: generate one general FAQ.
|
||||
|
||||
Generate the FAQs only in the #{language}, use no other language.
|
||||
If no suitable reusable FAQ is available, return: `{"faqs":[]}`.
|
||||
|
||||
Return only valid JSON in this exact structure:
|
||||
```json
|
||||
{ faqs: [ { question: '', answer: ''} ]
|
||||
{ "faqs": [ { "question": "", "answer": "" } ] }
|
||||
```
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
@@ -37,10 +37,8 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
def perform
|
||||
account.disable_features(*PREMIUM_PLAN_FEATURES)
|
||||
account.disable_features('captain_integration_v2') if default_plan?
|
||||
account.disable_features('api_and_webhooks')
|
||||
account.enable_features(*current_plan_features)
|
||||
account.enable_features('captain_integration_v2') if captain_v2_default_eligible?
|
||||
account.enable_features('api_and_webhooks') if api_and_webhooks_eligible?
|
||||
account.enable_features(*manually_managed_features)
|
||||
account.save!
|
||||
end
|
||||
@@ -58,10 +56,6 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
end
|
||||
end
|
||||
|
||||
def api_and_webhooks_eligible?
|
||||
!default_plan? && account.custom_attributes['subscription_status'] == 'active'
|
||||
end
|
||||
|
||||
def default_plan?
|
||||
default_plan_name = cloud_plans.first&.dig('name')
|
||||
return false if default_plan_name.blank?
|
||||
|
||||
@@ -54,7 +54,7 @@ class Internal::Accounts::InternalAttributesService
|
||||
def valid_feature_list
|
||||
Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES +
|
||||
Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES +
|
||||
%w[inbound_emails api_and_webhooks]
|
||||
%w[inbound_emails]
|
||||
end
|
||||
|
||||
# Account notes functionality removed for now
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
require 'ruby_llm'
|
||||
|
||||
module Llm::Config
|
||||
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
|
||||
DEFAULT_MODEL = 'gpt-5.6-luna'.freeze
|
||||
|
||||
class << self
|
||||
def initialized?
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module Llm::FeatureRouter
|
||||
class UnknownFeatureError < StandardError; end
|
||||
|
||||
CAPTAIN_V2_ASSISTANT_MODEL = 'gpt-5.2'.freeze
|
||||
CAPTAIN_V2_ASSISTANT_MODEL = 'gpt-5.6-terra'.freeze
|
||||
|
||||
class << self
|
||||
def resolve(feature:, account: nil)
|
||||
@@ -17,6 +17,7 @@ module Llm::FeatureRouter
|
||||
feature: feature_key,
|
||||
provider: Llm::Models.provider_for(model),
|
||||
model: model,
|
||||
reasoning_effort: Llm::Models.reasoning_effort_for(feature_key),
|
||||
source: source
|
||||
}
|
||||
end
|
||||
|
||||
+6
-1
@@ -15,6 +15,10 @@ module Llm::Models
|
||||
features.dig(feature.to_s, 'default')
|
||||
end
|
||||
|
||||
def reasoning_effort_for(feature)
|
||||
features.dig(feature.to_s, 'reasoning_effort')
|
||||
end
|
||||
|
||||
def models_for(feature)
|
||||
features.dig(feature.to_s, 'models') || []
|
||||
end
|
||||
@@ -46,7 +50,8 @@ module Llm::Models
|
||||
credit_multiplier: model['credit_multiplier']
|
||||
}
|
||||
end,
|
||||
default: feature['default']
|
||||
default: feature['default'],
|
||||
reasoning_effort: feature['reasoning_effort']
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
require 'openai'
|
||||
require 'ruby_llm/tool'
|
||||
|
||||
class Llm::ResponsesClient
|
||||
DEFAULT_STORE = false
|
||||
DEFAULT_TEXT_FORMAT = { type: 'text' }.freeze
|
||||
SYSTEM_ROLES = %w[system developer].freeze
|
||||
|
||||
def initialize(api_key:, api_base: nil, client: nil)
|
||||
@client = client || OpenAI::Client.new(access_token: api_key, uri_base: normalized_api_base(api_base))
|
||||
end
|
||||
|
||||
def create(model:, messages:, reasoning_effort: nil, schema: nil, tools: [], metadata: {}, store: DEFAULT_STORE, **options)
|
||||
payload = build_payload(
|
||||
model: model,
|
||||
messages: messages,
|
||||
reasoning_effort: reasoning_effort,
|
||||
schema: schema,
|
||||
tools: tools,
|
||||
metadata: metadata,
|
||||
store: store,
|
||||
options: options
|
||||
)
|
||||
return payload if payload[:error]
|
||||
|
||||
build_response(@client.json_post(path: '/responses', parameters: payload), request_messages: messages)
|
||||
end
|
||||
|
||||
def build_payload(model:, messages:, reasoning_effort: nil, schema: nil, tools: [], metadata: {}, store: DEFAULT_STORE, options: {})
|
||||
input_messages = conversation_messages(messages)
|
||||
return no_conversation_payload(messages) if input_messages.empty?
|
||||
|
||||
payload = {
|
||||
model: model,
|
||||
input: input_messages,
|
||||
store: store,
|
||||
text: { format: text_format(schema) }
|
||||
}
|
||||
|
||||
instructions = instructions_from(messages)
|
||||
payload[:instructions] = instructions if instructions.present?
|
||||
payload[:reasoning] = { effort: reasoning_effort } if reasoning_effort.present?
|
||||
payload[:tools] = normalize_tools(tools) if tools.present?
|
||||
payload[:metadata] = metadata if metadata.present?
|
||||
|
||||
payload.merge!(options.compact)
|
||||
payload
|
||||
end
|
||||
|
||||
def self.extract_output_text(response)
|
||||
Array(response['output']).filter_map do |item|
|
||||
next unless item['type'] == 'message'
|
||||
|
||||
Array(item['content']).filter_map do |content|
|
||||
content['text'] if content['type'] == 'output_text'
|
||||
end.join
|
||||
end.join
|
||||
end
|
||||
|
||||
def self.extract_function_calls(response)
|
||||
Array(response['output']).filter_map do |item|
|
||||
next unless item['type'] == 'function_call'
|
||||
|
||||
{
|
||||
'id' => item['id'],
|
||||
'call_id' => item['call_id'],
|
||||
'name' => item['name'],
|
||||
'arguments' => parse_arguments(item['arguments']),
|
||||
'status' => item['status']
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def self.usage_from(response)
|
||||
usage = response['usage'] || {}
|
||||
{
|
||||
'prompt_tokens' => usage['input_tokens'],
|
||||
'completion_tokens' => usage['output_tokens'],
|
||||
'total_tokens' => usage['total_tokens'],
|
||||
'reasoning_tokens' => usage.dig('output_tokens_details', 'reasoning_tokens')
|
||||
}.compact
|
||||
end
|
||||
|
||||
def self.parse_arguments(arguments)
|
||||
return arguments unless arguments.is_a?(String)
|
||||
|
||||
JSON.parse(arguments)
|
||||
rescue JSON::ParserError
|
||||
arguments
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_response(response, request_messages:)
|
||||
{
|
||||
message: self.class.extract_output_text(response),
|
||||
usage: self.class.usage_from(response),
|
||||
response_id: response['id'],
|
||||
model: response['model'],
|
||||
status: response['status'],
|
||||
function_calls: self.class.extract_function_calls(response),
|
||||
raw_response: response,
|
||||
request_messages: request_messages
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_messages(messages)
|
||||
messages.reject { |message| SYSTEM_ROLES.include?(message[:role].to_s) }.map do |message|
|
||||
{
|
||||
role: message[:role].to_s,
|
||||
content: format_content(message[:content])
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def instructions_from(messages)
|
||||
messages.filter_map do |message|
|
||||
message[:content] if SYSTEM_ROLES.include?(message[:role].to_s)
|
||||
end.join("\n\n")
|
||||
end
|
||||
|
||||
def format_content(content)
|
||||
return content unless content.is_a?(Array)
|
||||
|
||||
content.map do |part|
|
||||
normalized_part = part.deep_symbolize_keys
|
||||
case normalized_part[:type]
|
||||
when 'text'
|
||||
{ type: 'input_text', text: normalized_part[:text] }
|
||||
when 'image_url'
|
||||
{ type: 'input_image', image_url: normalized_part.dig(:image_url, :url) || normalized_part[:image_url] }
|
||||
when 'file'
|
||||
input_file_part(normalized_part)
|
||||
else
|
||||
normalized_part
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def input_file_part(part)
|
||||
file = part[:file] || {}
|
||||
{
|
||||
type: 'input_file',
|
||||
file_id: file[:file_id] || part[:file_id],
|
||||
file_url: file[:file_url] || part[:file_url],
|
||||
detail: part[:detail]
|
||||
}.compact
|
||||
end
|
||||
|
||||
def text_format(schema)
|
||||
return DEFAULT_TEXT_FORMAT unless schema
|
||||
|
||||
schema_payload = normalize_schema_payload(schema)
|
||||
{
|
||||
type: 'json_schema',
|
||||
name: schema_payload[:name],
|
||||
schema: schema_payload[:schema],
|
||||
strict: schema_payload[:strict]
|
||||
}
|
||||
end
|
||||
|
||||
def normalize_schema_payload(schema)
|
||||
schema_instance = schema.is_a?(Class) ? schema.new : schema
|
||||
raw_schema = schema_instance.respond_to?(:to_json_schema) ? schema_instance.to_json_schema : schema_instance
|
||||
raw_schema = raw_schema.deep_symbolize_keys
|
||||
schema_def = (raw_schema[:schema] || raw_schema).deep_dup
|
||||
strict = raw_schema.key?(:strict) ? raw_schema[:strict] : schema_def.delete(:strict)
|
||||
|
||||
{
|
||||
name: sanitize_schema_name(raw_schema[:name] || 'response'),
|
||||
schema: schema_def,
|
||||
strict: strict.nil? || strict
|
||||
}
|
||||
end
|
||||
|
||||
def normalize_tools(tools)
|
||||
tools.map do |tool|
|
||||
if tool.is_a?(Hash)
|
||||
normalized_tool = tool.deep_symbolize_keys
|
||||
function = normalized_tool[:function]
|
||||
next normalized_tool unless function
|
||||
|
||||
next {
|
||||
type: 'function',
|
||||
name: function[:name],
|
||||
description: function[:description],
|
||||
parameters: function[:parameters],
|
||||
strict: function.fetch(:strict, true)
|
||||
}.compact
|
||||
end
|
||||
|
||||
tool_instance = tool.is_a?(Class) ? tool.new : tool
|
||||
{
|
||||
type: 'function',
|
||||
name: tool_instance.name,
|
||||
description: tool_instance.description,
|
||||
parameters: tool_instance.params_schema || RubyLLM::Tool::SchemaDefinition.from_parameters(tool_instance.parameters)&.json_schema,
|
||||
strict: true
|
||||
}.compact
|
||||
end
|
||||
end
|
||||
|
||||
def no_conversation_payload(messages)
|
||||
{
|
||||
error: 'No conversation messages provided',
|
||||
error_code: 400,
|
||||
request_messages: messages
|
||||
}
|
||||
end
|
||||
|
||||
def sanitize_schema_name(name)
|
||||
sanitized = name.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
|
||||
sanitized.presence || 'response'
|
||||
end
|
||||
|
||||
def normalized_api_base(api_base)
|
||||
endpoint = api_base.presence || 'https://api.openai.com'
|
||||
endpoint = endpoint.chomp('/')
|
||||
endpoint.delete_suffix('/v1')
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module LlmConstants
|
||||
DEFAULT_MODEL = 'gpt-4.1'
|
||||
DEFAULT_MODEL = 'gpt-5.6-luna'
|
||||
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Grandfather api_and_webhooks for existing accounts (cloud only).
|
||||
#
|
||||
# Adds api_and_webhooks to each account's manually_managed_features internal
|
||||
# attribute so per-account billing reconciles never strip the feature from
|
||||
# accounts that existed before the flag was introduced. Idempotent.
|
||||
#
|
||||
# Usage Examples:
|
||||
# # Grandfather a single account
|
||||
# ACCOUNT_ID=1 bundle exec rake api_and_webhooks:grandfather
|
||||
#
|
||||
# # Grandfather all accounts in the installation
|
||||
# bundle exec rake api_and_webhooks:grandfather
|
||||
# rubocop:disable Metrics/BlockLength
|
||||
namespace :api_and_webhooks do
|
||||
desc 'Grandfather api_and_webhooks via manually_managed_features for existing accounts'
|
||||
task grandfather: :environment do
|
||||
abort 'Aborted. This task requires the enterprise edition.' unless ChatwootApp.enterprise?
|
||||
|
||||
account_id = ENV.fetch('ACCOUNT_ID', nil)
|
||||
accounts = account_id.present? ? Account.where(id: account_id) : Account.all
|
||||
|
||||
if account_id.blank?
|
||||
print 'No ACCOUNT_ID specified. This will grandfather ALL accounts. Continue? [y/N] '
|
||||
abort 'Aborted.' unless $stdin.gets.chomp.casecmp('y').zero?
|
||||
end
|
||||
|
||||
if account_id.present? && accounts.empty?
|
||||
puts "Error: Account with ID #{account_id} not found"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
total = accounts.count
|
||||
puts "Grandfathering api_and_webhooks for #{total} account(s)..."
|
||||
|
||||
updated = 0
|
||||
skipped = 0
|
||||
errored = 0
|
||||
|
||||
accounts.find_each do |account|
|
||||
service = Internal::Accounts::InternalAttributesService.new(account)
|
||||
features = service.manually_managed_features
|
||||
|
||||
if features.include?('api_and_webhooks')
|
||||
skipped += 1
|
||||
next
|
||||
end
|
||||
|
||||
service.manually_managed_features = features + ['api_and_webhooks']
|
||||
account.enable_features!('api_and_webhooks')
|
||||
updated += 1
|
||||
rescue StandardError => e
|
||||
errored += 1
|
||||
puts " Account #{account.id} — error: #{e.message}"
|
||||
end
|
||||
|
||||
puts "Done! Updated: #{updated}, Skipped: #{skipped}, Errored: #{errored}, Total: #{total}"
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/BlockLength
|
||||
@@ -87,6 +87,7 @@
|
||||
"opus-recorder": "^8.0.5",
|
||||
"pinia": "^3.0.4",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "7.6.3",
|
||||
|
||||
Generated
+5
-2
@@ -183,6 +183,9 @@ importers:
|
||||
prosemirror-commands:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
prosemirror-inputrules:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
prosemirror-schema-list:
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1
|
||||
@@ -9037,7 +9040,7 @@ snapshots:
|
||||
prosemirror-state@1.4.3:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
prosemirror-transform: 1.10.0
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
prosemirror-tables@1.5.0:
|
||||
@@ -9065,7 +9068,7 @@ snapshots:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
prosemirror-state: 1.4.3
|
||||
prosemirror-transform: 1.10.0
|
||||
prosemirror-transform: 1.12.0
|
||||
|
||||
proto-list@1.2.4: {}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'API Base', type: :request do
|
||||
let!(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } }
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account: account) }
|
||||
|
||||
describe 'request with api_access_token for user' do
|
||||
@@ -23,32 +23,6 @@ RSpec.describe 'API Base', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account does not have the api_and_webhooks feature' do
|
||||
let!(:admin) { create(:user, :administrator, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
before do
|
||||
account.disable_features!('api_and_webhooks')
|
||||
end
|
||||
|
||||
it 'returns unauthorized for token authenticated requests' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
headers: { api_access_token: admin.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(response.parsed_body['error']).to eq('Invalid Access Token')
|
||||
end
|
||||
|
||||
it 'allows session authenticated requests' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an invalid api_access_token' do
|
||||
it 'returns unauthorized' do
|
||||
get '/api/v1/profile',
|
||||
@@ -120,19 +94,6 @@ RSpec.describe 'API Base', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account does not have the api_and_webhooks feature' do
|
||||
it 'returns unauthorized for accessible bot endpoints' do
|
||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
|
||||
headers: { api_access_token: agent_bot.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account is suspended' do
|
||||
it 'returns 401 unauthorized' do
|
||||
account.update!(status: :suspended)
|
||||
|
||||
@@ -44,10 +44,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
|
||||
expect(json_response).to have_key(:providers)
|
||||
expect(json_response).to have_key(:models)
|
||||
expect(json_response).to have_key(:features)
|
||||
expect(json_response[:models].keys).to include(:'gpt-5.6-luna', :'gpt-5.6-terra', :'gpt-5.6-sol')
|
||||
expect(json_response.dig(:features, :assistant, :models).pluck(:id)).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
|
||||
end
|
||||
|
||||
it 'returns effective model provider and source for each feature' do
|
||||
account.update!(captain_models: { 'editor' => 'gpt-4.1' })
|
||||
account.update!(captain_models: { 'editor' => 'gpt-5.6-terra' })
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
@@ -55,15 +57,17 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response.dig(:features, :editor)).to include(
|
||||
model: 'gpt-4.1',
|
||||
selected: 'gpt-4.1',
|
||||
model: 'gpt-5.6-terra',
|
||||
selected: 'gpt-5.6-terra',
|
||||
provider: 'openai',
|
||||
reasoning_effort: 'low',
|
||||
source: 'account_override'
|
||||
)
|
||||
expect(json_response.dig(:features, :label_suggestion)).to include(
|
||||
model: Llm::Models.default_model_for('label_suggestion'),
|
||||
selected: Llm::Models.default_model_for('label_suggestion'),
|
||||
provider: 'openai',
|
||||
reasoning_effort: 'low',
|
||||
source: 'default'
|
||||
)
|
||||
end
|
||||
@@ -81,7 +85,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns GPT-5.2 as the assistant default for V2 accounts' do
|
||||
it 'returns the Captain V2 assistant default for V2 accounts' do
|
||||
account.enable_features!('captain_integration_v2')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
@@ -198,6 +202,17 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
|
||||
expect(account.reload.captain_models['document_faq_generation']).to eq('gpt-5.2')
|
||||
end
|
||||
|
||||
it 'updates captain_models for conversation FAQ generation' do
|
||||
put "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { captain_models: { conversation_faq_generation: 'gpt-4.1-mini' } },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response.dig(:features, :conversation_faq_generation, :selected)).to eq('gpt-4.1-mini')
|
||||
expect(account.reload.captain_models['conversation_faq_generation']).to eq('gpt-4.1-mini')
|
||||
end
|
||||
|
||||
it 'updates captain_models for PDF FAQ generation' do
|
||||
put "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Conversation Assignment API', type: :request do
|
||||
let(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } }
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/conversations/<id>/assignments' do
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Conversation Messages API', type: :request do
|
||||
let!(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } }
|
||||
let!(:account) { create(:account) }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/conversations/<id>/messages' do
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Conversations API', type: :request do
|
||||
let(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } }
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/conversations' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Webhooks API', type: :request do
|
||||
let(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } }
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:webhook) { create(:webhook, account: account, inbox: inbox, url: 'https://hello.com', name: 'My Webhook') }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
@@ -26,16 +26,6 @@ RSpec.describe 'Webhooks API', type: :request do
|
||||
expect(response.parsed_body['payload']['webhooks'].count).to eql account.webhooks.count
|
||||
end
|
||||
end
|
||||
|
||||
context 'when api_and_webhooks feature is disabled' do
|
||||
it 'returns unauthorized even for an admin' do
|
||||
account.disable_features!('api_and_webhooks')
|
||||
get "/api/v1/accounts/#{account.id}/webhooks",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/<account_id>/webhooks' do
|
||||
|
||||
@@ -258,7 +258,7 @@ RSpec.describe Account, type: :model do
|
||||
|
||||
expect(account).to be_feature_enabled('captain_integration')
|
||||
expect(account).to be_feature_enabled('captain_integration_v2')
|
||||
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
|
||||
expect(account.captain_preferences[:models]['assistant']).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
|
||||
expect(account.captain_models).to be_nil
|
||||
end
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ RSpec.describe Concerns::Agentable do
|
||||
tools: [],
|
||||
model: Llm::Models.default_model_for('assistant'),
|
||||
temperature: 0.8,
|
||||
response_schema: Captain::ResponseSchema
|
||||
response_schema: Captain::ResponseSchema,
|
||||
params: { reasoning_effort: Llm::Models.reasoning_effort_for('assistant') }
|
||||
)
|
||||
|
||||
dummy_instance.agent
|
||||
@@ -183,7 +184,7 @@ RSpec.describe Concerns::Agentable do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
|
||||
account.enable_features!('captain_integration_v2')
|
||||
|
||||
expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2')
|
||||
expect(dummy_instance.send(:agent_model)).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
|
||||
expect(account.reload.captain_models).to be_nil
|
||||
end
|
||||
|
||||
@@ -194,6 +195,14 @@ RSpec.describe Concerns::Agentable do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_params' do
|
||||
it 'returns reasoning effort for the assistant feature' do
|
||||
expect(dummy_instance.send(:agent_params)).to eq(
|
||||
reasoning_effort: Llm::Models.reasoning_effort_for('assistant')
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_response_schema' do
|
||||
it 'returns Captain::ResponseSchema' do
|
||||
expect(dummy_instance.send(:agent_response_schema)).to eq(Captain::ResponseSchema)
|
||||
|
||||
@@ -33,23 +33,109 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
|
||||
end
|
||||
|
||||
it 'uses the document FAQ generation feature model' do
|
||||
it 'uses the conversation FAQ generation feature model' do
|
||||
expect(RubyLLM).to receive(:chat).with(
|
||||
model: Llm::Models.default_model_for('document_faq_generation')
|
||||
model: Llm::Models.default_model_for('conversation_faq_generation')
|
||||
).and_return(mock_chat)
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'uses the conversation FAQ default ahead of the legacy global installation model' do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-mini')
|
||||
|
||||
expect(RubyLLM).to receive(:chat).with(
|
||||
model: Llm::Models.default_model_for('conversation_faq_generation')
|
||||
).and_return(mock_chat)
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'keeps account conversation FAQ model overrides ahead of the feature default' do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1')
|
||||
conversation.account.update!(captain_models: { 'conversation_faq_generation' => 'gpt-4.1-mini' })
|
||||
|
||||
expect(RubyLLM).to receive(:chat).with(model: 'gpt-4.1-mini').and_return(mock_chat)
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'resolves the feature model from the conversation account' do
|
||||
expect(Llm::FeatureRouter).to receive(:resolve).with(
|
||||
feature: 'document_faq_generation',
|
||||
feature: 'conversation_faq_generation',
|
||||
account: conversation.account
|
||||
).and_call_original
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'sends only customer and human support agent messages to the LLM' do
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
sender: create(:contact, account: conversation.account), message_type: :incoming,
|
||||
content: 'Customer question')
|
||||
create(:message, :bot_message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
content: 'Bot answer that should not become knowledge')
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
sender: create(:user, account: conversation.account), message_type: :outgoing,
|
||||
content: 'Human answer')
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
sender: create(:user, account: conversation.account), message_type: :outgoing,
|
||||
private: true, content: 'Private note')
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
message_type: :activity, content: 'Activity message')
|
||||
|
||||
service.generate_and_deduplicate
|
||||
|
||||
expected_content = satisfy do |content|
|
||||
content.include?('User: Customer question') &&
|
||||
content.include?('Support Agent: Human answer') &&
|
||||
content.exclude?('Bot answer that should not become knowledge') &&
|
||||
content.exclude?('Private note') &&
|
||||
content.exclude?('Activity message')
|
||||
end
|
||||
expect(mock_chat).to have_received(:ask).with(expected_content)
|
||||
end
|
||||
|
||||
it 'keeps external echo outgoing replies from native channels in the LLM transcript' do
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
sender: create(:contact, account: conversation.account), message_type: :incoming,
|
||||
content: 'Customer asks in a native channel')
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
sender: nil, message_type: :outgoing, content: 'Human replied from the native app',
|
||||
content_attributes: { external_echo: true })
|
||||
|
||||
service.generate_and_deduplicate
|
||||
|
||||
expected_content = satisfy do |content|
|
||||
content.include?('User: Customer asks in a native channel') &&
|
||||
content.include?('Support Agent: Human replied from the native app')
|
||||
end
|
||||
expect(mock_chat).to have_received(:ask).with(expected_content)
|
||||
end
|
||||
|
||||
it 'uses the human-only conversation transcript for instrumentation' do
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
sender: create(:contact, account: conversation.account), message_type: :incoming,
|
||||
content: 'Customer asks something')
|
||||
create(:message, :bot_message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
content: 'Bot-only answer')
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
sender: create(:user, account: conversation.account), message_type: :outgoing,
|
||||
content: 'Agent gives a public answer')
|
||||
|
||||
expect(service).to receive(:instrument_llm_call) do |params, &block|
|
||||
user_message = params[:messages].find { |message| message[:role] == 'user' }[:content]
|
||||
|
||||
expect(user_message).to include('User: Customer asks something')
|
||||
expect(user_message).to include('Support Agent: Agent gives a public answer')
|
||||
expect(user_message).not_to include('Bot-only answer')
|
||||
|
||||
block.call
|
||||
end
|
||||
|
||||
service.generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'creates new FAQs for valid conversation content' do
|
||||
expect do
|
||||
service.generate_and_deduplicate
|
||||
|
||||
@@ -35,28 +35,38 @@ RSpec.describe Captain::Llm::PaginatedFaqGeneratorService do
|
||||
context 'when generating FAQs from PDF pages' do
|
||||
let(:faq_response) do
|
||||
{
|
||||
'choices' => [{
|
||||
'message' => {
|
||||
'content' => JSON.generate({
|
||||
'faqs' => [
|
||||
{ 'question' => 'What is this document about?', 'answer' => 'It explains key concepts.' }
|
||||
],
|
||||
'has_content' => true
|
||||
})
|
||||
}
|
||||
'id' => 'resp-123',
|
||||
'status' => 'completed',
|
||||
'model' => service.model,
|
||||
'output' => [{
|
||||
'type' => 'message',
|
||||
'content' => [{
|
||||
'type' => 'output_text',
|
||||
'text' => JSON.generate({
|
||||
'faqs' => [
|
||||
{ 'question' => 'What is this document about?', 'answer' => 'It explains key concepts.' }
|
||||
],
|
||||
'has_content' => true
|
||||
})
|
||||
}]
|
||||
}]
|
||||
}
|
||||
end
|
||||
|
||||
let(:empty_response) do
|
||||
{
|
||||
'choices' => [{
|
||||
'message' => {
|
||||
'content' => JSON.generate({
|
||||
'faqs' => [],
|
||||
'has_content' => false
|
||||
})
|
||||
}
|
||||
'id' => 'resp-456',
|
||||
'status' => 'completed',
|
||||
'model' => service.model,
|
||||
'output' => [{
|
||||
'type' => 'message',
|
||||
'content' => [{
|
||||
'type' => 'output_text',
|
||||
'text' => JSON.generate({
|
||||
'faqs' => [],
|
||||
'has_content' => false
|
||||
})
|
||||
}]
|
||||
}]
|
||||
}
|
||||
end
|
||||
@@ -66,16 +76,38 @@ RSpec.describe Captain::Llm::PaginatedFaqGeneratorService do
|
||||
end
|
||||
|
||||
it 'generates FAQs from paginated content' do
|
||||
allow(openai_client).to receive(:chat).and_return(faq_response, empty_response)
|
||||
allow(openai_client).to receive(:json_post).and_return(faq_response, empty_response)
|
||||
|
||||
faqs = service.generate
|
||||
|
||||
expect(faqs).to have_attributes(size: 1)
|
||||
expect(faqs.first['question']).to eq('What is this document about?')
|
||||
expect(openai_client).to have_received(:json_post).with(
|
||||
path: '/responses',
|
||||
parameters: hash_including(
|
||||
model: service.model,
|
||||
reasoning: { effort: Llm::Models.reasoning_effort_for('pdf_faq_generation') },
|
||||
text: {
|
||||
format: hash_including(
|
||||
type: 'json_schema',
|
||||
name: 'pdf_faq_generation',
|
||||
strict: true
|
||||
)
|
||||
},
|
||||
input: [
|
||||
hash_including(
|
||||
content: array_including(
|
||||
{ type: 'input_file', file_id: 'file-123' },
|
||||
hash_including(type: 'input_text')
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
).at_least(:once)
|
||||
end
|
||||
|
||||
it 'stops when no more content' do
|
||||
allow(openai_client).to receive(:chat).and_return(empty_response)
|
||||
allow(openai_client).to receive(:json_post).and_return(empty_response)
|
||||
|
||||
faqs = service.generate
|
||||
|
||||
@@ -83,7 +115,7 @@ RSpec.describe Captain::Llm::PaginatedFaqGeneratorService do
|
||||
end
|
||||
|
||||
it 'respects max iterations limit' do
|
||||
allow(openai_client).to receive(:chat).and_return(faq_response)
|
||||
allow(openai_client).to receive(:json_post).and_return(faq_response)
|
||||
|
||||
# Force max iterations
|
||||
service.instance_variable_set(:@iterations_completed, 19)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Enterprise::Billing::ReconcilePlanFeaturesService do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
before do
|
||||
create(:installation_config, {
|
||||
name: 'CHATWOOT_CLOUD_PLANS',
|
||||
value: [
|
||||
{ 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
|
||||
{ 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] }
|
||||
]
|
||||
})
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'with api_and_webhooks feature' do
|
||||
it 'enables the feature for a paid plan with an active subscription' do
|
||||
account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'active' })
|
||||
|
||||
described_class.new(account: account).perform
|
||||
|
||||
expect(account.reload).to be_feature_enabled('api_and_webhooks')
|
||||
end
|
||||
|
||||
it 'disables the feature for a paid plan on trial' do
|
||||
account.enable_features!('api_and_webhooks')
|
||||
account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'trialing' })
|
||||
|
||||
described_class.new(account: account).perform
|
||||
|
||||
expect(account.reload).not_to be_feature_enabled('api_and_webhooks')
|
||||
end
|
||||
|
||||
it 'disables the feature on the default plan' do
|
||||
account.enable_features!('api_and_webhooks')
|
||||
account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'active' })
|
||||
|
||||
described_class.new(account: account).perform
|
||||
|
||||
expect(account.reload).not_to be_feature_enabled('api_and_webhooks')
|
||||
end
|
||||
|
||||
it 'keeps the feature enabled when manually managed' do
|
||||
account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'trialing' })
|
||||
Internal::Accounts::InternalAttributesService.new(account).manually_managed_features = ['api_and_webhooks']
|
||||
|
||||
described_class.new(account: account).perform
|
||||
|
||||
expect(account.reload).to be_feature_enabled('api_and_webhooks')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -34,7 +34,7 @@ RSpec.describe Llm::BaseAiService do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
|
||||
account.enable_features!('captain_integration_v2')
|
||||
|
||||
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2')
|
||||
expect(described_class.new(feature: 'assistant', account: account).model).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
|
||||
expect(account.reload.captain_models).to be_nil
|
||||
end
|
||||
|
||||
|
||||
@@ -12,25 +12,27 @@ RSpec.describe Llm::FeatureRouter do
|
||||
expect(resolved).to eq(
|
||||
feature: 'editor',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1-mini',
|
||||
model: 'gpt-5.6-luna',
|
||||
reasoning_effort: 'low',
|
||||
source: :default
|
||||
)
|
||||
end
|
||||
|
||||
it 'uses a valid account model override' do
|
||||
account.update!(captain_models: { 'editor' => 'gpt-4.1' })
|
||||
account.update!(captain_models: { 'editor' => 'gpt-5.6-terra' })
|
||||
|
||||
resolved = described_class.resolve(feature: 'editor', account: account)
|
||||
|
||||
expect(resolved).to include(
|
||||
feature: 'editor',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1',
|
||||
model: 'gpt-5.6-terra',
|
||||
reasoning_effort: 'low',
|
||||
source: :account_override
|
||||
)
|
||||
end
|
||||
|
||||
it 'resolves GPT-5.2 as the assistant default when Captain V2 is enabled without storing an account override' do
|
||||
it 'resolves the Captain V2 assistant default without storing an account override' do
|
||||
account.enable_features!('captain_integration_v2')
|
||||
|
||||
resolved = described_class.resolve(feature: 'assistant', account: account)
|
||||
@@ -38,7 +40,8 @@ RSpec.describe Llm::FeatureRouter do
|
||||
expect(resolved).to include(
|
||||
feature: 'assistant',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.2',
|
||||
model: described_class::CAPTAIN_V2_ASSISTANT_MODEL,
|
||||
reasoning_effort: 'medium',
|
||||
source: :default
|
||||
)
|
||||
expect(account.reload.captain_models).to be_nil
|
||||
@@ -62,7 +65,8 @@ RSpec.describe Llm::FeatureRouter do
|
||||
resolved = described_class.resolve(feature: 'editor', account: account)
|
||||
|
||||
expect(resolved).to include(
|
||||
model: 'gpt-4.1-mini',
|
||||
model: 'gpt-5.6-luna',
|
||||
reasoning_effort: 'low',
|
||||
source: :default
|
||||
)
|
||||
end
|
||||
@@ -73,7 +77,8 @@ RSpec.describe Llm::FeatureRouter do
|
||||
resolved = described_class.resolve(feature: 'editor', account: account)
|
||||
|
||||
expect(resolved).to include(
|
||||
model: 'gpt-4.1-mini',
|
||||
model: 'gpt-5.6-luna',
|
||||
reasoning_effort: 'low',
|
||||
source: :default
|
||||
)
|
||||
end
|
||||
|
||||
@@ -25,9 +25,35 @@ RSpec.describe Llm::Models do
|
||||
expect(missing_models).to be_empty, "#{feature_key} references missing models: #{missing_models.join(', ')}"
|
||||
end
|
||||
end
|
||||
|
||||
it 'routes document and conversation FAQ generation independently' do
|
||||
expect(described_class.default_model_for('document_faq_generation')).to eq('gpt-5.6-terra')
|
||||
expect(described_class.default_model_for('conversation_faq_generation')).to eq('gpt-5.6-terra')
|
||||
end
|
||||
|
||||
it 'sets reasoning effort for every text generation feature' do
|
||||
features_without_reasoning = %w[audio_transcription help_center_search]
|
||||
|
||||
described_class.features.each_key do |feature_key|
|
||||
if features_without_reasoning.include?(feature_key)
|
||||
expect(described_class.reasoning_effort_for(feature_key)).to be_nil
|
||||
else
|
||||
expect(described_class.reasoning_effort_for(feature_key)).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'exposes GPT-5.6 models for Captain V2 workflows without changing the legacy PDF path' do
|
||||
expect(described_class.models_for('assistant')).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
|
||||
expect(described_class.models_for('pdf_faq_generation')).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.models' do
|
||||
it 'includes the GPT-5.6 model family' do
|
||||
expect(described_class.models.keys).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
|
||||
end
|
||||
|
||||
it 'references existing providers from every model' do
|
||||
missing_providers = described_class.models.filter_map do |model_name, config|
|
||||
provider = config['provider']
|
||||
@@ -44,7 +70,9 @@ RSpec.describe Llm::Models do
|
||||
it 'returns model metadata for a feature' do
|
||||
config = described_class.feature_config('editor')
|
||||
|
||||
expect(config[:default]).to eq('gpt-4.1-mini')
|
||||
expect(config[:default]).to eq('gpt-5.6-luna')
|
||||
expect(config[:reasoning_effort]).to eq('low')
|
||||
expect(config[:models].pluck(:id)).to include('gpt-5.6-luna', 'gpt-5.6-terra')
|
||||
expect(config[:models].first).to include(
|
||||
id: 'gpt-4.1-mini',
|
||||
display_name: 'GPT-4.1 Mini',
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Llm::ResponsesClient do
|
||||
let(:openai_client) { instance_double(OpenAI::Client) }
|
||||
let(:client) { described_class.new(api_key: 'test-key', api_base: 'https://api.openai.com/v1', client: openai_client) }
|
||||
|
||||
let(:response_body) do
|
||||
{
|
||||
'id' => 'resp_123',
|
||||
'status' => 'completed',
|
||||
'model' => 'gpt-5.6-terra',
|
||||
'output' => [
|
||||
{
|
||||
'type' => 'message',
|
||||
'content' => [
|
||||
{ 'type' => 'output_text', 'text' => 'Hello' }
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'function_call',
|
||||
'id' => 'fc_123',
|
||||
'call_id' => 'call_123',
|
||||
'name' => 'lookup_faq',
|
||||
'arguments' => '{"query":"billing"}',
|
||||
'status' => 'completed'
|
||||
}
|
||||
],
|
||||
'usage' => {
|
||||
'input_tokens' => 10,
|
||||
'output_tokens' => 5,
|
||||
'total_tokens' => 15,
|
||||
'output_tokens_details' => { 'reasoning_tokens' => 2 }
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(openai_client).to receive(:json_post).and_return(response_body)
|
||||
end
|
||||
|
||||
describe '#create' do
|
||||
it 'sends a Responses API payload with reasoning effort and store disabled' do
|
||||
messages = [
|
||||
{ role: 'system', content: 'Answer as a support assistant.' },
|
||||
{ role: 'user', content: 'Hi' }
|
||||
]
|
||||
|
||||
result = client.create(
|
||||
model: 'gpt-5.6-terra',
|
||||
messages: messages,
|
||||
reasoning_effort: 'medium',
|
||||
metadata: { feature: 'assistant' }
|
||||
)
|
||||
|
||||
expect(openai_client).to have_received(:json_post).with(
|
||||
path: '/responses',
|
||||
parameters: {
|
||||
model: 'gpt-5.6-terra',
|
||||
input: [{ role: 'user', content: 'Hi' }],
|
||||
store: false,
|
||||
text: { format: { type: 'text' } },
|
||||
instructions: 'Answer as a support assistant.',
|
||||
reasoning: { effort: 'medium' },
|
||||
metadata: { feature: 'assistant' }
|
||||
}
|
||||
)
|
||||
expect(result).to include(
|
||||
message: 'Hello',
|
||||
response_id: 'resp_123',
|
||||
model: 'gpt-5.6-terra',
|
||||
status: 'completed',
|
||||
request_messages: messages
|
||||
)
|
||||
expect(result[:usage]).to eq(
|
||||
'prompt_tokens' => 10,
|
||||
'completion_tokens' => 5,
|
||||
'total_tokens' => 15,
|
||||
'reasoning_tokens' => 2
|
||||
)
|
||||
expect(result[:function_calls]).to contain_exactly(
|
||||
'id' => 'fc_123',
|
||||
'call_id' => 'call_123',
|
||||
'name' => 'lookup_faq',
|
||||
'arguments' => { 'query' => 'billing' },
|
||||
'status' => 'completed'
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns a local error when no conversation messages are present' do
|
||||
result = client.create(
|
||||
model: 'gpt-5.6-terra',
|
||||
messages: [{ role: 'system', content: 'Only instructions' }],
|
||||
reasoning_effort: 'low'
|
||||
)
|
||||
|
||||
expect(openai_client).not_to have_received(:json_post)
|
||||
expect(result).to eq(
|
||||
error: 'No conversation messages provided',
|
||||
error_code: 400,
|
||||
request_messages: [{ role: 'system', content: 'Only instructions' }]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_payload' do
|
||||
class TestResponsesSchema
|
||||
def to_json_schema
|
||||
{
|
||||
name: 'captain.response',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
response: { type: 'string' }
|
||||
},
|
||||
required: ['response'],
|
||||
additionalProperties: false
|
||||
},
|
||||
strict: true
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses Responses text.format for structured outputs' do
|
||||
payload = client.build_payload(
|
||||
model: 'gpt-5.6-terra',
|
||||
messages: [{ role: 'user', content: 'Summarize' }],
|
||||
schema: TestResponsesSchema
|
||||
)
|
||||
|
||||
expect(payload[:text]).to eq(
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
name: 'captain_response',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
response: { type: 'string' }
|
||||
},
|
||||
required: ['response'],
|
||||
additionalProperties: false
|
||||
},
|
||||
strict: true
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'normalizes chat-style function tools to Responses function tools' do
|
||||
payload = client.build_payload(
|
||||
model: 'gpt-5.6-terra',
|
||||
messages: [{ role: 'user', content: 'Find docs' }],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_faq',
|
||||
description: 'Search FAQs',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' }
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
expect(payload[:tools]).to eq(
|
||||
[
|
||||
{
|
||||
type: 'function',
|
||||
name: 'lookup_faq',
|
||||
description: 'Search FAQs',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' }
|
||||
},
|
||||
required: ['query']
|
||||
},
|
||||
strict: true
|
||||
}
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
it 'normalizes multimodal chat content to Responses input blocks' do
|
||||
payload = client.build_payload(
|
||||
model: 'gpt-5.6-terra',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'What is in this image?' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } },
|
||||
{ type: 'file', file: { file_id: 'file-123' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
expect(payload[:input]).to eq(
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'What is in this image?' },
|
||||
{ type: 'input_image', image_url: 'https://example.com/image.png' },
|
||||
{ type: 'input_file', file_id: 'file-123' }
|
||||
]
|
||||
}
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
describe WebhookListener do
|
||||
let(:listener) { described_class.instance }
|
||||
let!(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } }
|
||||
let!(:account) { create(:account) }
|
||||
let(:report_identity) { Reports::UpdateAccountIdentity.new(account, Time.zone.now) }
|
||||
let!(:user) { create(:user, account: account) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
@@ -44,30 +44,6 @@ describe WebhookListener do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when api_and_webhooks feature is disabled' do
|
||||
before do
|
||||
account.disable_features!('api_and_webhooks')
|
||||
end
|
||||
|
||||
it 'does not trigger account webhooks' do
|
||||
create(:webhook, inbox: inbox, account: account)
|
||||
expect(WebhookJob).not_to receive(:perform_later)
|
||||
listener.message_created(message_created_event)
|
||||
end
|
||||
|
||||
it 'still triggers API inbox webhooks' do
|
||||
channel_api = create(:channel_api, account: account)
|
||||
api_conversation = create(:conversation, account: account, inbox: channel_api.inbox, assignee: user)
|
||||
api_message = create(:message, message_type: 'outgoing', account: account, inbox: channel_api.inbox, conversation: api_conversation)
|
||||
api_event = Events::Base.new(event_name, Time.zone.now, message: api_message)
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'),
|
||||
:api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.message_created(api_event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox is an API Channel' do
|
||||
it 'triggers webhook if webhook_url is present' do
|
||||
channel_api = create(:channel_api, account: account)
|
||||
|
||||
@@ -108,7 +108,7 @@ RSpec.describe Account do
|
||||
|
||||
it 'configures the account feature flag extension column' do
|
||||
expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1')
|
||||
expect(described_class.flag_mapping['feature_flags_ext_1']).to eq(feature_api_and_webhooks: 1)
|
||||
expect(described_class.flag_mapping['feature_flags_ext_1']).to eq({})
|
||||
end
|
||||
|
||||
it 'keeps existing feature flags on the original column' do
|
||||
@@ -393,10 +393,10 @@ RSpec.describe Account do
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns GPT-5.2 for assistant when Captain V2 is enabled' do
|
||||
it 'returns the Captain V2 default for assistant when Captain V2 is enabled' do
|
||||
account.enable_features!('captain_integration_v2')
|
||||
|
||||
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
|
||||
expect(account.captain_preferences[:models]['assistant']).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
|
||||
expect(account.reload.captain_models).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -208,6 +208,58 @@ RSpec.describe Article do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_article_search_terms' do
|
||||
let(:responses_client) { instance_double(Llm::ResponsesClient) }
|
||||
let(:article) do
|
||||
create(
|
||||
:article,
|
||||
account: account,
|
||||
category: category_1,
|
||||
content: 'How to configure billing invoices and payment reminders',
|
||||
description: 'Billing setup guide',
|
||||
portal: portal_1,
|
||||
author: user,
|
||||
title: 'Configure billing'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_OPEN_AI_API_KEY').tap do |config|
|
||||
config.value = 'sk-test'
|
||||
config.save!
|
||||
end
|
||||
InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT').tap do |config|
|
||||
config.value = 'https://api.openai.test/v1'
|
||||
config.save!
|
||||
end
|
||||
allow(Llm::ResponsesClient).to receive(:new).and_return(responses_client)
|
||||
allow(responses_client).to receive(:create).and_return(
|
||||
{ message: { search_terms: ['billing setup', 'invoice reminders'] }.to_json }
|
||||
)
|
||||
end
|
||||
|
||||
it 'uses Responses API with the help center article model route' do
|
||||
expect(article.generate_article_search_terms).to eq(['billing setup', 'invoice reminders'])
|
||||
|
||||
expect(Llm::ResponsesClient).to have_received(:new).with(
|
||||
api_key: 'sk-test',
|
||||
api_base: 'https://api.openai.test/v1'
|
||||
)
|
||||
expect(responses_client).to have_received(:create).with(
|
||||
hash_including(
|
||||
model: Llm::Models.default_model_for('help_center_article_generation'),
|
||||
reasoning_effort: Llm::Models.reasoning_effort_for('help_center_article_generation'),
|
||||
schema: Enterprise::Concerns::Article::SEARCH_TERMS_SCHEMA,
|
||||
metadata: hash_including(
|
||||
account_id: account.id,
|
||||
article_id: article.id,
|
||||
feature: 'article_search_terms'
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.update_positions' do
|
||||
let!(:article_a) { create(:article, portal: portal_1, category: category_1, author: user, position: 10) }
|
||||
let!(:article_b) { create(:article, portal: portal_1, category: category_1, author: user, position: 11) }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::BaseController', type: :request do
|
||||
let(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } }
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
@@ -96,7 +96,14 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountStore do
|
||||
|
||||
described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
|
||||
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 2.minutes)).to be_stale
|
||||
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 36.minutes)).to be_expired
|
||||
expect(
|
||||
described_class.built_in_filter_counts_state(
|
||||
account_id: account_id,
|
||||
user_id: user_id,
|
||||
now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_FRESH_TTL +
|
||||
Conversations::UnreadCounts::FILTERED_COUNT_STALE_WINDOW + 1.second
|
||||
)
|
||||
).to be_expired
|
||||
|
||||
Redis::Alfred.delete(described_class.built_in_filter_counts_key(account_id, user_id))
|
||||
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id)).to be_missing
|
||||
@@ -202,8 +209,18 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountStore do
|
||||
)
|
||||
|
||||
snapshot = described_class.built_in_filter_counts(account_id: account_id, user_id: user_id)
|
||||
expect(described_class.refresh_due?(snapshot, now: built_at + 10.seconds)).to be(false)
|
||||
expect(described_class.refresh_due?(snapshot, now: built_at + 31.seconds)).to be(true)
|
||||
expect(
|
||||
described_class.refresh_due?(
|
||||
snapshot,
|
||||
now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second
|
||||
)
|
||||
).to be(false)
|
||||
expect(
|
||||
described_class.refresh_due?(
|
||||
snapshot,
|
||||
now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
|
||||
)
|
||||
).to be(true)
|
||||
|
||||
expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(true)
|
||||
expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(false)
|
||||
|
||||
@@ -48,10 +48,22 @@ RSpec.describe Conversations::UnreadCounts::FilteredCounter do
|
||||
create(:mention, account: account, conversation: second_mention, user: agent)
|
||||
store.bump_conversation_version!(account.id)
|
||||
|
||||
expect(described_class.new(account: account, user: agent, now: now + 10.seconds).perform[:mentions_count]).to eq(1)
|
||||
expect(
|
||||
described_class.new(
|
||||
account: account,
|
||||
user: agent,
|
||||
now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second
|
||||
).perform[:mentions_count]
|
||||
).to eq(1)
|
||||
|
||||
Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id))
|
||||
expect(described_class.new(account: account, user: agent, now: now + 31.seconds).perform[:mentions_count]).to eq(2)
|
||||
expect(
|
||||
described_class.new(
|
||||
account: account,
|
||||
user: agent,
|
||||
now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
|
||||
).perform[:mentions_count]
|
||||
).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns stale built-in counts when a refresh build hits a database error' do
|
||||
@@ -62,7 +74,11 @@ RSpec.describe Conversations::UnreadCounts::FilteredCounter do
|
||||
|
||||
store.bump_conversation_version!(account.id)
|
||||
Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id))
|
||||
failing_counter = described_class.new(account: account, user: agent, now: now + 31.seconds)
|
||||
failing_counter = described_class.new(
|
||||
account: account,
|
||||
user: agent,
|
||||
now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
|
||||
)
|
||||
allow(failing_counter).to receive(:built_in_counts_from_database).and_raise(ActiveRecord::StatementInvalid.new('statement timeout'))
|
||||
|
||||
expect(failing_counter.perform[:mentions_count]).to eq(1)
|
||||
|
||||
Reference in New Issue
Block a user