Merge branch 'develop' into inline-edit
This commit is contained in:
@@ -10,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
private
|
||||
|
||||
def sign_in_user
|
||||
# Capture before skip_confirmation! sets confirmed_at, which would
|
||||
# make oauth_user_needs_password_reset? return false and skip the
|
||||
# password reset for persisted unconfirmed users.
|
||||
needs_password_reset = oauth_user_needs_password_reset?
|
||||
@resource.skip_confirmation! if confirmable_enabled?
|
||||
set_random_password_if_oauth_user if needs_password_reset
|
||||
|
||||
# once the resource is found and verified
|
||||
# we can just send them to the login page again with the SSO params
|
||||
@@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
end
|
||||
|
||||
def sign_in_user_on_mobile
|
||||
# See comment in sign_in_user for why this is captured before skip_confirmation!
|
||||
needs_password_reset = oauth_user_needs_password_reset?
|
||||
@resource.skip_confirmation! if confirmable_enabled?
|
||||
set_random_password_if_oauth_user if needs_password_reset
|
||||
|
||||
# once the resource is found and verified
|
||||
# we can just send them to the login page again with the SSO params
|
||||
@@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||
|
||||
create_account_for_user
|
||||
set_random_password_if_oauth_user
|
||||
token = @resource.send(:set_reset_password_token)
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
|
||||
@@ -81,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
|
||||
end
|
||||
|
||||
def oauth_user_needs_password_reset?
|
||||
@resource.present? && (@resource.new_record? || !@resource.confirmed?)
|
||||
end
|
||||
|
||||
def set_random_password_if_oauth_user
|
||||
# Password must satisfy secure_password requirements (uppercase, lowercase, number, special char)
|
||||
@resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted?
|
||||
end
|
||||
|
||||
def default_devise_mapping
|
||||
'user'
|
||||
end
|
||||
|
||||
@@ -98,7 +98,9 @@ export default {
|
||||
mql.onchange = e => setColorTheme(e.matches);
|
||||
},
|
||||
setLocale(locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
if (locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
}
|
||||
},
|
||||
async initializeAccount() {
|
||||
await this.$store.dispatch('accounts/get');
|
||||
|
||||
@@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient {
|
||||
delete(id) {
|
||||
return axios.delete(`${this.url}/${id}`);
|
||||
}
|
||||
|
||||
test(data = {}) {
|
||||
return axios.post(`${this.url}/test`, {
|
||||
custom_tool: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainCustomTools();
|
||||
|
||||
+3
-6
@@ -101,12 +101,9 @@ const authTypeLabel = computed(() => {
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<span
|
||||
v-if="description"
|
||||
class="text-sm truncate text-n-slate-11 flex-1"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full gap-4 min-w-0">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<span v-if="description" class="text-sm truncate text-n-slate-11">
|
||||
{{ description }}
|
||||
</span>
|
||||
<span
|
||||
|
||||
+80
-6
@@ -1,9 +1,10 @@
|
||||
<script setup>
|
||||
import { reactive, computed, useTemplateRef, watch } from 'vue';
|
||||
import { reactive, computed, ref, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { required, maxLength } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import CustomToolsAPI from 'dashboard/api/captain/customTools';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
@@ -72,8 +73,12 @@ const DEFAULT_PARAM = {
|
||||
required: false,
|
||||
};
|
||||
|
||||
// OpenAI enforces a 64-char limit on function names. The backend slug is
|
||||
// "custom_" (7 chars) + parameterized title, so cap the title conservatively.
|
||||
const MAX_TOOL_NAME_LENGTH = 55;
|
||||
|
||||
const validationRules = {
|
||||
title: { required },
|
||||
title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) },
|
||||
endpoint_url: { required },
|
||||
http_method: { required },
|
||||
auth_type: { required },
|
||||
@@ -103,9 +108,15 @@ const isLoading = computed(() =>
|
||||
);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
return v$.value[field].$error
|
||||
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
|
||||
: '';
|
||||
if (!v$.value[field].$error) return '';
|
||||
|
||||
const failedRule = v$.value[field].$errors[0]?.$validator;
|
||||
if (failedRule === 'maxLength') {
|
||||
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.MAX_LENGTH_ERROR`, {
|
||||
max: MAX_TOOL_NAME_LENGTH,
|
||||
});
|
||||
}
|
||||
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`);
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
@@ -140,6 +151,30 @@ const handleSubmit = async () => {
|
||||
|
||||
emit('submit', state);
|
||||
};
|
||||
|
||||
const isTesting = ref(false);
|
||||
const testResult = ref(null);
|
||||
const isTestDisabled = computed(
|
||||
() => state.endpoint_url.includes('{{') || !!state.request_template
|
||||
);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!state.endpoint_url) return;
|
||||
|
||||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
try {
|
||||
const { data } = await CustomToolsAPI.test(state);
|
||||
const isOk = data.status >= 200 && data.status < 300;
|
||||
testResult.value = { success: isOk, status: data.status };
|
||||
} catch (e) {
|
||||
const message =
|
||||
e.response?.data?.error || t('CAPTAIN.CUSTOM_TOOLS.TEST.ERROR');
|
||||
testResult.value = { success: false, message };
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -248,6 +283,45 @@ const handleSubmit = async () => {
|
||||
class="[&_textarea]:font-mono"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
icon="i-lucide-play"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.TEST.BUTTON')"
|
||||
:is-loading="isTesting"
|
||||
:disabled="isTesting || !state.endpoint_url || isTestDisabled"
|
||||
@click="handleTest"
|
||||
/>
|
||||
<p v-if="isTestDisabled" class="text-xs text-n-slate-11">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.TEST.DISABLED_HINT') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="testResult"
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs rounded-lg"
|
||||
:class="
|
||||
testResult.success
|
||||
? 'bg-n-teal-2 text-n-teal-11'
|
||||
: 'bg-n-ruby-2 text-n-ruby-11'
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
testResult.success ? 'i-lucide-check-circle' : 'i-lucide-x-circle'
|
||||
"
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
{{
|
||||
testResult.status
|
||||
? t('CAPTAIN.CUSTOM_TOOLS.TEST.SUCCESS', {
|
||||
status: testResult.status,
|
||||
})
|
||||
: testResult.message
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-between items-center w-full">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
+12
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -10,6 +13,15 @@ const onClick = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureSpotlight
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
|
||||
learn-more-url="https://chwt.app/hc/captain-tools"
|
||||
class="mb-8"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
||||
|
||||
@@ -63,6 +63,16 @@ const hasAdvancedAssignment = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasCustomTools = computed(() => {
|
||||
return (
|
||||
isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS
|
||||
) ||
|
||||
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.CAPTAIN_V2)
|
||||
);
|
||||
});
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -364,14 +374,18 @@ const menuItems = computed(() => {
|
||||
navigationPath: 'captain_assistants_inboxes_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
},
|
||||
...(hasCustomTools.value
|
||||
? [
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Settings',
|
||||
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
|
||||
|
||||
@@ -37,6 +37,7 @@ export const FEATURE_FLAGS = {
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
CAPTAIN_TASKS: 'captain_tasks',
|
||||
SAML: 'saml',
|
||||
|
||||
@@ -807,6 +807,7 @@
|
||||
"CUSTOM_TOOLS": {
|
||||
"HEADER": "Tools",
|
||||
"ADD_NEW": "Create a new tool",
|
||||
"SOFT_LIMIT_WARNING": "Having more than 10 tools may reduce the assistant's reliability in selecting the right tool. Consider removing unused tools for better results.",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No custom tools available",
|
||||
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
|
||||
@@ -837,11 +838,18 @@
|
||||
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete custom tool"
|
||||
},
|
||||
"TEST": {
|
||||
"BUTTON": "Test connection",
|
||||
"SUCCESS": "Endpoint returned HTTP {status}",
|
||||
"ERROR": "Connection failed",
|
||||
"DISABLED_HINT": "Testing is only available for endpoints without templates or request bodies."
|
||||
},
|
||||
"FORM": {
|
||||
"TITLE": {
|
||||
"LABEL": "Tool Name",
|
||||
"PLACEHOLDER": "Order Lookup",
|
||||
"ERROR": "Tool name is required"
|
||||
"ERROR": "Tool name is required",
|
||||
"MAX_LENGTH_ERROR": "Tool name must be {max} characters or fewer"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Description",
|
||||
|
||||
@@ -46,7 +46,7 @@ const assistantRoutes = [
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
|
||||
component: CustomToolsIndex,
|
||||
name: 'captain_tools_index',
|
||||
meta: metaV2,
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'),
|
||||
|
||||
@@ -2,21 +2,29 @@
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
|
||||
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
|
||||
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { isFeatureFlagEnabled } = usePolicy();
|
||||
|
||||
const SOFT_LIMIT = 10;
|
||||
const isV2 = computed(() => isFeatureFlagEnabled(FEATURE_FLAGS.CAPTAIN_V2));
|
||||
|
||||
const uiFlags = useMapGetter('captainCustomTools/getUIFlags');
|
||||
const customTools = useMapGetter('captainCustomTools/getRecords');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const customToolsMeta = useMapGetter('captainCustomTools/getMeta');
|
||||
|
||||
const showSoftLimitWarning = computed(
|
||||
() => !isV2.value && customToolsMeta.value.totalCount > SOFT_LIMIT
|
||||
);
|
||||
|
||||
const createDialogRef = ref(null);
|
||||
const deleteDialogRef = ref(null);
|
||||
const selectedTool = ref(null);
|
||||
@@ -86,21 +94,23 @@ onMounted(() => {
|
||||
:show-pagination-footer="!isFetching && !!customTools.length"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!customTools.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN_V2"
|
||||
:show-know-more="false"
|
||||
@update:current-page="onPageChange"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<CustomToolsPageEmptyState @click="openCreateDialog" />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-if="showSoftLimitWarning"
|
||||
class="flex items-center gap-2 px-4 py-3 text-sm rounded-lg bg-n-amber-2 text-n-amber-11"
|
||||
>
|
||||
<span class="i-lucide-triangle-alert size-4 shrink-0" />
|
||||
{{ $t('CAPTAIN.CUSTOM_TOOLS.SOFT_LIMIT_WARNING') }}
|
||||
</div>
|
||||
<CustomToolCard
|
||||
v-for="tool in customTools"
|
||||
:id="tool.id"
|
||||
|
||||
@@ -103,7 +103,10 @@ export default {
|
||||
const { name, locale, id, domain, support_email, features } =
|
||||
this.getAccount(this.accountId);
|
||||
|
||||
this.$root.$i18n.locale = this.uiSettings?.locale || locale;
|
||||
const effectiveLocale = this.uiSettings?.locale || locale;
|
||||
if (effectiveLocale) {
|
||||
this.$root.$i18n.locale = effectiveLocale;
|
||||
}
|
||||
this.name = name;
|
||||
this.locale = locale;
|
||||
this.id = id;
|
||||
@@ -129,11 +132,9 @@ export default {
|
||||
support_email: this.supportEmail,
|
||||
});
|
||||
// If user locale is set, update the locale with user locale
|
||||
if (this.uiSettings?.locale) {
|
||||
this.$root.$i18n.locale = this.uiSettings?.locale;
|
||||
} else {
|
||||
// If user locale is not set, update the locale with account locale
|
||||
this.$root.$i18n.locale = this.locale;
|
||||
const updatedLocale = this.uiSettings?.locale || this.locale;
|
||||
if (updatedLocale) {
|
||||
this.$root.$i18n.locale = updatedLocale;
|
||||
}
|
||||
this.getAccount(this.id).locale = this.locale;
|
||||
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
|
||||
|
||||
@@ -35,7 +35,9 @@ export default {
|
||||
};
|
||||
},
|
||||
setLocale(locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
if (locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply antialiased h-full;
|
||||
@apply antialiased h-full bg-n-slate-2 dark:bg-n-solid-1;
|
||||
}
|
||||
|
||||
.is-mobile {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed } from 'vue';
|
||||
import { computed, watchEffect } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const isDarkModeAuto = mode => mode === 'auto';
|
||||
@@ -23,6 +23,10 @@ export function useDarkMode() {
|
||||
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', prefersDarkMode.value);
|
||||
});
|
||||
|
||||
return {
|
||||
darkMode,
|
||||
prefersDarkMode,
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white h-full">
|
||||
<div class="bg-n-solid-1 h-full">
|
||||
<IframeLoader :url="$route.query.link" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,10 +44,15 @@ class Line::SendOnLineService < Base::SendOnChannelService
|
||||
# Support only image and video for now, https://developers.line.biz/en/reference/messaging-api/#image-message
|
||||
next unless attachment.file_type == 'image' || attachment.file_type == 'video'
|
||||
|
||||
# Use file_url (permanent redirect-based URL) instead of download_url (signed URL that expires in 5 minutes).
|
||||
# LINE mobile app lazy-loads images and may fetch them well after the message is sent.
|
||||
original_url = attachment.file_url
|
||||
preview_url = attachment.thumb_url.presence || original_url
|
||||
|
||||
{
|
||||
type: attachment.file_type,
|
||||
originalContentUrl: attachment.download_url,
|
||||
previewImageUrl: attachment.download_url
|
||||
originalContentUrl: original_url,
|
||||
previewImageUrl: preview_url
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,7 +79,7 @@ class Notification::PushNotificationService
|
||||
subscription.destroy!
|
||||
when WebPush::TooManyRequests
|
||||
Rails.logger.warn "WebPush rate limited for #{user.email} on account #{notification.account.id}: #{error.message}"
|
||||
when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout
|
||||
when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, Socket::ResolutionError
|
||||
Rails.logger.error "WebPush operation error: #{error.message}"
|
||||
else
|
||||
ChatwootExceptionTracker.new(error, account: notification.account).capture_exception
|
||||
|
||||
@@ -58,9 +58,9 @@ By default, it renders:
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-white dark:bg-slate-900">
|
||||
<div id="portal" class="antialiased">
|
||||
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
|
||||
<main class="flex flex-col min-h-screen main-content" role="main">
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<%= render "public/api/v1/portals/header", portal: @portal %>
|
||||
<% end %>
|
||||
|
||||
@@ -7,7 +7,7 @@ if ENV['SENTRY_DSN'].present?
|
||||
# We recommend adjusting the value in production:
|
||||
config.traces_sample_rate = 0.1 if ENV['ENABLE_SENTRY_TRANSACTIONS']
|
||||
|
||||
config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException']
|
||||
config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException', 'MutexApplicationJob::LockAcquisitionError']
|
||||
|
||||
# to track post data in sentry
|
||||
config.send_default_pii = true unless ENV['DISABLE_SENTRY_PII']
|
||||
|
||||
@@ -387,6 +387,7 @@ en:
|
||||
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
|
||||
custom_tool:
|
||||
slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
|
||||
limit_exceeded: 'You can create a maximum of %{limit} custom tools per account'
|
||||
public_portal:
|
||||
search:
|
||||
search_placeholder: Search for article by title or body...
|
||||
|
||||
+3
-1
@@ -72,7 +72,9 @@ Rails.application.routes.draw do
|
||||
resources :copilot_threads, only: [:index, :create] do
|
||||
resources :copilot_messages, only: [:index, :create]
|
||||
end
|
||||
resources :custom_tools
|
||||
resources :custom_tools do
|
||||
post :test, on: :collection
|
||||
end
|
||||
resources :documents, only: [:index, :show, :create, :destroy]
|
||||
resource :tasks, only: [], controller: 'tasks' do
|
||||
post :rewrite
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :ensure_custom_tools_enabled
|
||||
before_action -> { check_authorization(Captain::CustomTool) }
|
||||
before_action :set_custom_tool, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
@custom_tools = account_custom_tools.enabled
|
||||
@custom_tools = account_custom_tools
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@custom_tool = account_custom_tools.create!(custom_tool_params)
|
||||
rescue Captain::CustomTool::LimitExceededError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -22,8 +25,22 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def test
|
||||
tool = account_custom_tools.new(custom_tool_params)
|
||||
result = execute_test_request(tool)
|
||||
render json: { status: result.code.to_i, body: result.body.to_s.truncate(500) }
|
||||
rescue StandardError => e
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_custom_tools_enabled
|
||||
return if Current.account.feature_enabled?('custom_tools') || Current.account.feature_enabled?('captain_integration_v2')
|
||||
|
||||
render json: { error: 'Custom tools are not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def set_custom_tool
|
||||
@custom_tool = account_custom_tools.find(params[:id])
|
||||
end
|
||||
@@ -32,6 +49,11 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
|
||||
@account_custom_tools ||= Current.account.captain_custom_tools
|
||||
end
|
||||
|
||||
def execute_test_request(tool)
|
||||
http_tool = Captain::Tools::HttpTool.new(nil, tool)
|
||||
http_tool.send(:execute_http_request, tool.endpoint_url, nil, nil)
|
||||
end
|
||||
|
||||
def custom_tool_params
|
||||
params.require(:custom_tool).permit(
|
||||
:title,
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE
|
||||
#
|
||||
class Captain::CustomTool < ApplicationRecord
|
||||
class LimitExceededError < StandardError; end
|
||||
|
||||
MAX_PER_ACCOUNT = 15
|
||||
|
||||
include Concerns::Toolable
|
||||
include Concerns::SafeEndpointValidatable
|
||||
|
||||
@@ -31,6 +35,10 @@ class Captain::CustomTool < ApplicationRecord
|
||||
|
||||
NAME_PREFIX = 'custom'.freeze
|
||||
NAME_SEPARATOR = '_'.freeze
|
||||
# OpenAI enforces a 64-char limit on function names. The slug is used
|
||||
# verbatim as the tool name in LLM requests, so it must fit within this limit.
|
||||
MAX_SLUG_LENGTH = 64
|
||||
COLLISION_SUFFIX_LENGTH = 7 # "_" + 6 random alphanumeric chars
|
||||
PARAM_SCHEMA_VALIDATION = {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
@@ -52,8 +60,9 @@ class Captain::CustomTool < ApplicationRecord
|
||||
enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth
|
||||
|
||||
before_validation :generate_slug
|
||||
before_create :ensure_within_limit
|
||||
|
||||
validates :slug, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :slug, presence: true, uniqueness: { scope: :account_id }, length: { maximum: MAX_SLUG_LENGTH }
|
||||
validates :title, presence: true
|
||||
validates :endpoint_url, presence: true
|
||||
validates_with JsonSchemaValidator,
|
||||
@@ -73,21 +82,29 @@ class Captain::CustomTool < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def ensure_within_limit
|
||||
# Lock the account row to serialize concurrent creates and prevent exceeding the cap
|
||||
Account.lock.find(account_id)
|
||||
return if account.captain_custom_tools.count < MAX_PER_ACCOUNT
|
||||
|
||||
raise LimitExceededError, I18n.t('captain.custom_tool.limit_exceeded', limit: MAX_PER_ACCOUNT)
|
||||
end
|
||||
|
||||
def generate_slug
|
||||
return if slug.present?
|
||||
return if title.blank?
|
||||
|
||||
paramterized_title = title.parameterize(separator: NAME_SEPARATOR)
|
||||
|
||||
base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}"
|
||||
parameterized_title = title.parameterize(separator: NAME_SEPARATOR)
|
||||
base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{parameterized_title}".truncate(MAX_SLUG_LENGTH, omission: '')
|
||||
self.slug = find_unique_slug(base_slug)
|
||||
end
|
||||
|
||||
def find_unique_slug(base_slug)
|
||||
return base_slug unless slug_exists?(base_slug)
|
||||
|
||||
truncated = base_slug.truncate(MAX_SLUG_LENGTH - COLLISION_SUFFIX_LENGTH, omission: '')
|
||||
5.times do
|
||||
slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
|
||||
slug_candidate = "#{truncated}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
|
||||
return slug_candidate unless slug_exists?(slug_candidate)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
module Concerns::Toolable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def tool(assistant)
|
||||
# Isolated namespace for user-defined custom tool classes.
|
||||
# Keeps them separate from built-in classes in Captain::Tools (e.g., HttpTool, CustomHttpTool).
|
||||
module CustomTools; end
|
||||
|
||||
def tool(assistant, base_class: Captain::Tools::HttpTool, **)
|
||||
custom_tool_record = self
|
||||
# Convert slug to valid Ruby constant name (replace hyphens with underscores, then camelize)
|
||||
class_name = custom_tool_record.slug.underscore.camelize
|
||||
|
||||
# Always create a fresh class to reflect current metadata
|
||||
tool_class = Class.new(Captain::Tools::HttpTool) do
|
||||
tool_slug = custom_tool_record.slug
|
||||
tool_class = Class.new(base_class) do
|
||||
description custom_tool_record.description
|
||||
|
||||
# Override name to use the slug directly, avoiding the namespace prefix
|
||||
# that RubyLLM's default normalization would produce (e.g., "captain--tools--custom_dog_facts").
|
||||
define_method(:name) { tool_slug }
|
||||
|
||||
custom_tool_record.param_schema.each do |param_def|
|
||||
param param_def['name'].to_sym,
|
||||
type: param_def['type'],
|
||||
@@ -18,17 +26,14 @@ module Concerns::Toolable
|
||||
end
|
||||
end
|
||||
|
||||
# Register the dynamically created class as a constant in the Captain::Tools namespace.
|
||||
# This is required because RubyLLM's Tool base class derives the tool name from the class name
|
||||
# (via Class#name). Anonymous classes created with Class.new have no name and return empty strings,
|
||||
# which causes "Invalid 'tools[].function.name': empty string" errors from the LLM API.
|
||||
# By setting it as a constant, the class gets a proper name (e.g., "Captain::Tools::CatFactLookup")
|
||||
# which RubyLLM extracts and normalizes to "cat-fact-lookup" for the LLM API.
|
||||
# We refresh the constant on each call to ensure tool metadata changes are reflected.
|
||||
Captain::Tools.send(:remove_const, class_name) if Captain::Tools.const_defined?(class_name, false)
|
||||
Captain::Tools.const_set(class_name, tool_class)
|
||||
# Register as a constant so the class gets a proper name (Class#name).
|
||||
# Anonymous classes return nil for #name, which causes "Invalid 'tools[].function.name':
|
||||
# empty string" errors from the LLM API. We use CustomTools as the namespace to avoid
|
||||
# collisions with real classes in Captain::Tools.
|
||||
CustomTools.send(:remove_const, class_name) if CustomTools.const_defined?(class_name, false)
|
||||
CustomTools.const_set(class_name, tool_class)
|
||||
|
||||
tool_class.new(assistant, self)
|
||||
tool_class.new(assistant, self, **)
|
||||
end
|
||||
|
||||
def build_request_url(params)
|
||||
|
||||
@@ -11,6 +11,10 @@ class Captain::CustomToolPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def test?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def update?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@@ -30,7 +30,12 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
private
|
||||
|
||||
def build_tools
|
||||
[Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
|
||||
tools = [Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
|
||||
return tools unless custom_tools_enabled?
|
||||
|
||||
tools + @assistant.account.captain_custom_tools.enabled.map do |ct|
|
||||
ct.tool(@assistant, base_class: Captain::Tools::CustomHttpTool, conversation: @conversation)
|
||||
end
|
||||
end
|
||||
|
||||
def system_message
|
||||
@@ -38,11 +43,24 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
role: 'system',
|
||||
content: Captain::Llm::SystemPromptsService.assistant_response_generator(
|
||||
@assistant.name, @assistant.config['product_name'], @assistant.config,
|
||||
contact: contact_attributes
|
||||
contact: contact_attributes,
|
||||
custom_tools: custom_tools_metadata
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
def custom_tools_metadata
|
||||
return [] unless custom_tools_enabled?
|
||||
|
||||
@assistant.account.captain_custom_tools.enabled.map do |ct|
|
||||
{ name: ct.slug, description: ct.description }
|
||||
end
|
||||
end
|
||||
|
||||
def custom_tools_enabled?
|
||||
@assistant.account.feature_enabled?('custom_tools')
|
||||
end
|
||||
|
||||
def contact_attributes
|
||||
return nil unless @conversation&.contact
|
||||
return nil unless @assistant&.feature_contact_attributes
|
||||
|
||||
@@ -152,7 +152,7 @@ class Captain::Llm::SystemPromptsService
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def assistant_response_generator(assistant_name, product_name, config = {}, contact: nil)
|
||||
def assistant_response_generator(assistant_name, product_name, config = {}, contact: nil, custom_tools: [])
|
||||
assistant_citation_guidelines = if config['feature_citation']
|
||||
<<~CITATION_TEXT
|
||||
- Always include citations for any information provided, referencing the specific source (document only - skip if it was derived from a conversation).
|
||||
@@ -187,7 +187,7 @@ class Captain::Llm::SystemPromptsService
|
||||
#{assistant_citation_guidelines}
|
||||
|
||||
#{build_contact_context(contact)}[Task]
|
||||
Start by introducing yourself. Then, ask the user to share their question. When they answer, call the search_documentation function. Give a helpful response based on the steps written below.
|
||||
Start by introducing yourself. Then, ask the user to share their question. When they answer, use the most appropriate tool to find information. Give a helpful response based on the steps written below.
|
||||
|
||||
- Provide the user with the steps required to complete the action one by one.
|
||||
- Do not return list numbers in the steps, just the plain text is enough.
|
||||
@@ -203,6 +203,8 @@ class Captain::Llm::SystemPromptsService
|
||||
```
|
||||
- If the answer is not provided in context sections, Respond to the customer and ask whether they want to talk to another support agent . If they ask to Chat with another agent, return `conversation_handoff' as the response in JSON response
|
||||
#{'- You MUST provide numbered citations at the appropriate places in the text.' if config['feature_citation']}
|
||||
|
||||
#{build_tools_section(custom_tools)}
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
@@ -291,6 +293,15 @@ class Captain::Llm::SystemPromptsService
|
||||
|
||||
private
|
||||
|
||||
def build_tools_section(custom_tools)
|
||||
tools_list = custom_tools.map { |t| "- #{t[:name]}: #{t[:description]}" }.join("\n")
|
||||
<<~TOOLS.strip
|
||||
[Available Tools]
|
||||
- search_documentation: Search and retrieve documentation from knowledge base
|
||||
#{tools_list}
|
||||
TOOLS
|
||||
end
|
||||
|
||||
def build_contact_context(contact)
|
||||
return '' if contact.nil?
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# V1-compatible wrapper for custom HTTP tools.
|
||||
#
|
||||
# V2's HttpTool inherits from Agents::Tool which overrides execute(tool_context, **params),
|
||||
# making it incompatible with V1's RubyLLM pipeline that calls execute(**keyword_args).
|
||||
#
|
||||
# This class bridges the gap: it inherits from BaseTool (RubyLLM::Tool) for V1 compatibility
|
||||
# and delegates the actual HTTP execution to HttpTool#perform.
|
||||
class Captain::Tools::CustomHttpTool < Captain::Tools::BaseTool
|
||||
# BaseTool prepends Instrumentation, but our execute() shadows it in the MRO.
|
||||
# Re-prepend so Langfuse captures tool call input/output/timing.
|
||||
prepend Captain::Tools::Instrumentation
|
||||
|
||||
attr_reader :custom_tool
|
||||
|
||||
def initialize(assistant, custom_tool, conversation: nil)
|
||||
@custom_tool = custom_tool
|
||||
@conversation = conversation
|
||||
super(assistant)
|
||||
end
|
||||
|
||||
def active?
|
||||
@custom_tool.enabled?
|
||||
end
|
||||
|
||||
def execute(**params)
|
||||
http_tool = Captain::Tools::HttpTool.new(assistant, @custom_tool)
|
||||
http_tool.perform(build_tool_context, **params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_tool_context
|
||||
state = { account_id: assistant.account_id, assistant_id: assistant.id }
|
||||
add_conversation_state(state) if @conversation
|
||||
OpenStruct.new(state: state)
|
||||
end
|
||||
|
||||
def add_conversation_state(state)
|
||||
state[:conversation] = { id: @conversation.id, display_id: @conversation.display_id }
|
||||
state[:contact] = slice_record_attrs(@conversation.contact, :id, :email, :phone_number)
|
||||
state[:contact_inbox] = slice_record_attrs(@conversation.contact_inbox, :id, :hmac_verified)
|
||||
end
|
||||
|
||||
def slice_record_attrs(record, *keys)
|
||||
record&.attributes&.symbolize_keys&.slice(*keys)
|
||||
end
|
||||
end
|
||||
@@ -17,7 +17,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
linear_integration
|
||||
].freeze
|
||||
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||
PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ json.http_method custom_tool.http_method
|
||||
json.request_template custom_tool.request_template
|
||||
json.response_template custom_tool.response_template
|
||||
json.auth_type custom_tool.auth_type
|
||||
json.auth_config custom_tool.auth_config
|
||||
json.auth_config custom_tool.auth_config if Current.user&.administrator?
|
||||
json.param_schema custom_tool.param_schema
|
||||
json.enabled custom_tool.enabled
|
||||
json.account_id custom_tool.account_id
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
"@amplitude/analytics-browser": "^2.11.10",
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.8",
|
||||
"@chatwoot/prosemirror-schema": "1.3.9",
|
||||
"@chatwoot/utils": "^0.0.52",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
|
||||
Generated
+5
-5
@@ -26,8 +26,8 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.3.8
|
||||
version: 1.3.8
|
||||
specifier: 1.3.9
|
||||
version: 1.3.9
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.52
|
||||
version: 0.0.52
|
||||
@@ -454,8 +454,8 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.8':
|
||||
resolution: {integrity: sha512-Vr8eUdydmVr7iRnNky4jXKX3XD4z5HAS4bV7zJXxA4av4ig5qjTldDOg7c/C8rqYNKGR5UEOEu9CQfGcjfKVXg==}
|
||||
'@chatwoot/prosemirror-schema@1.3.9':
|
||||
resolution: {integrity: sha512-nbzvW4Rfe7EC+tHF/wWJK5pIxRzfQj/DDAtZI7pwM9uJfv9yQz6bAUCA7kz7Vq1NF29XOisZaT5W0005ygk1pg==}
|
||||
|
||||
'@chatwoot/utils@0.0.52':
|
||||
resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
|
||||
@@ -4966,7 +4966,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.8':
|
||||
'@chatwoot/prosemirror-schema@1.3.9':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.6.0
|
||||
|
||||
@@ -164,5 +164,21 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
it 'resets password for an unconfirmed persisted user on OAuth login' do
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
user = create(:user, email: 'unconfirmed-oauth@example.com', skip_confirmation: false)
|
||||
original_password_digest = user.encrypted_password
|
||||
set_omniauth_config('unconfirmed-oauth@example.com')
|
||||
|
||||
get '/omniauth/google_oauth2/callback'
|
||||
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
|
||||
follow_redirect!
|
||||
|
||||
user.reload
|
||||
expect(user).to be_confirmed
|
||||
expect(user.encrypted_password).not_to eq(original_password_digest)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before { account.enable_features!('custom_tools') }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
@@ -40,7 +42,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do
|
||||
expect(json_response[:payload].length).to eq(5)
|
||||
end
|
||||
|
||||
it 'returns only enabled custom tools' do
|
||||
it 'returns all custom tools including disabled' do
|
||||
create(:captain_custom_tool, account: account, enabled: true)
|
||||
create(:captain_custom_tool, account: account, enabled: false)
|
||||
get "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||
@@ -48,8 +50,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:payload].length).to eq(1)
|
||||
expect(json_response[:payload].first[:enabled]).to be(true)
|
||||
expect(json_response[:payload].length).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -161,7 +161,9 @@ describe Line::SendOnLineService do
|
||||
it 'sends the message with text and attachments' do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
expected_url_regex = %r{rails/active_storage/disk/[a-zA-Z0-9=_\-+]+/avatar\.png}
|
||||
attachment.save!
|
||||
expected_original_url_regex = %r{rails/active_storage/blobs/redirect/[a-zA-Z0-9=_\-+]+/avatar\.png}
|
||||
expected_preview_url_regex = %r{rails/active_storage/representations/redirect/[a-zA-Z0-9=_\-+]+/[a-zA-Z0-9=_\-+]+/avatar\.png}
|
||||
|
||||
expect(line_client).to receive(:push_message).with(
|
||||
message.conversation.contact_inbox.source_id,
|
||||
@@ -169,8 +171,8 @@ describe Line::SendOnLineService do
|
||||
{ type: 'text', text: message.content },
|
||||
{
|
||||
type: 'image',
|
||||
originalContentUrl: match(expected_url_regex),
|
||||
previewImageUrl: match(expected_url_regex)
|
||||
originalContentUrl: match(expected_original_url_regex),
|
||||
previewImageUrl: match(expected_preview_url_regex)
|
||||
}
|
||||
]
|
||||
)
|
||||
@@ -181,16 +183,18 @@ describe Line::SendOnLineService do
|
||||
it 'sends the message with attachments only' do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
attachment.save!
|
||||
message.update!(content: nil)
|
||||
expected_url_regex = %r{rails/active_storage/disk/[a-zA-Z0-9=_\-+]+/avatar\.png}
|
||||
expected_original_url_regex = %r{rails/active_storage/blobs/redirect/[a-zA-Z0-9=_\-+]+/avatar\.png}
|
||||
expected_preview_url_regex = %r{rails/active_storage/representations/redirect/[a-zA-Z0-9=_\-+]+/[a-zA-Z0-9=_\-+]+/avatar\.png}
|
||||
|
||||
expect(line_client).to receive(:push_message).with(
|
||||
message.conversation.contact_inbox.source_id,
|
||||
[
|
||||
{
|
||||
type: 'image',
|
||||
originalContentUrl: match(expected_url_regex),
|
||||
previewImageUrl: match(expected_url_regex)
|
||||
originalContentUrl: match(expected_original_url_regex),
|
||||
previewImageUrl: match(expected_preview_url_regex)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user