Merge branch 'develop' into feat/voice-as-twilio-capability

This commit is contained in:
Muhsin Keloth
2026-04-01 12:41:07 +04:00
committed by GitHub
66 changed files with 1341 additions and 335 deletions
+5
View File
@@ -2,3 +2,8 @@
ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
# storage setup uses local/cloud storage with optional direct uploads to the
# storage provider rather than Rails proxy mode. Revisit if we enable
# rails_storage_proxy or other app-served Active Storage proxy routes.
- CVE-2026-33658
@@ -1,6 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :ensure_portal_feature_enabled
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show]
layout 'portal'
@@ -1,6 +1,7 @@
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :ensure_portal_feature_enabled
before_action :set_category, only: [:show]
layout 'portal'
@@ -1,7 +1,8 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show]
before_action :portal
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
before_action :ensure_portal_feature_enabled
layout 'portal'
def show
@@ -24,6 +25,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
def redirect_to_portal_with_locale
return if params[:locale].present?
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
end
+7
View File
@@ -18,4 +18,11 @@ class PublicController < ActionController::Base
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
}, status: :unauthorized and return
end
def ensure_portal_feature_enabled
return unless ChatwootApp.chatwoot_cloud?
return if @portal.account.feature_enabled?('help_center')
render 'public/api/v1/portals/not_active', status: :payment_required
end
end
+3 -1
View File
@@ -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');
@@ -106,6 +106,10 @@ select {
&[disabled] {
@apply field-disabled;
}
option:not(:disabled) {
@apply bg-n-solid-2 text-n-slate-12;
}
}
// Textarea
@@ -12,6 +12,7 @@ import {
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
id: {
@@ -34,14 +35,34 @@ const props = defineProps({
type: Number,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
showSelectionControl: {
type: Boolean,
default: false,
},
showMenu: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['action']);
const emit = defineEmits(['action', 'select', 'hover']);
const { checkPermissions } = usePolicy();
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const menuItems = computed(() => {
const allOptions = [
@@ -79,12 +100,23 @@ const handleAction = ({ action, value }) => {
</script>
<template>
<CardLayout>
<CardLayout
:selectable="selectable"
class="relative"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div
v-show="showSelectionControl"
class="absolute top-7 ltr:left-3 rtl:right-3"
>
<Checkbox v-model="modelValue" />
</div>
<div class="flex gap-1 justify-between w-full">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ name }}
</span>
<div class="flex gap-2 items-center">
<div v-if="showMenu" class="flex gap-2 items-center">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="flex relative items-center group"
@@ -21,16 +21,22 @@ const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n();
const store = useStore();
const bulkDeleteDialogRef = ref(null);
const i18nKey = computed(() => props.type.toUpperCase());
const i18nKey = computed(() => {
const i18nTypeMap = {
AssistantResponse: 'RESPONSES',
AssistantDocument: 'DOCUMENTS',
};
return i18nTypeMap[props.type];
});
const handleBulkDelete = async ids => {
if (!ids) return;
try {
await store.dispatch(
'captainBulkActions/handleBulkDelete',
Array.from(props.bulkIds)
);
await store.dispatch('captainBulkActions/handleBulkDelete', {
ids: Array.from(props.bulkIds),
type: props.type,
});
emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
@@ -253,6 +253,9 @@ export default {
if (this.isAnInstagramChannel) {
return MESSAGE_MAX_LENGTH.INSTAGRAM;
}
if (this.isATelegramChannel) {
return MESSAGE_MAX_LENGTH.TELEGRAM;
}
if (this.isATiktokChannel) {
return MESSAGE_MAX_LENGTH.TIKTOK;
}
@@ -545,7 +548,10 @@ export default {
},
setCopilotAcceptedMessage(message, replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
this.copilotAcceptedMessages[key] = trimContent(message || '');
this.copilotAcceptedMessages[key] = trimContent(
message || '',
this.maxLength
);
},
clearCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
@@ -603,7 +609,7 @@ export default {
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
const key = this.getDraftKey(conversationId, replyType);
const draftToSave = trimContent(this.message || '');
const draftToSave = trimContent(this.message || '', this.maxLength);
this.$store.dispatch('draftMessages/set', {
key,
@@ -738,6 +738,17 @@
"DOCUMENTS": {
"HEADER": "Documents",
"ADD_NEW": "Create a new document",
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "Delete",
"BULK_DELETE": {
"TITLE": "Delete documents?",
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "Documents deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
},
"RELATED_RESPONSES": {
"TITLE": "Related FAQs",
"DESCRIPTION": "These FAQs are generated directly from the document."
@@ -4,9 +4,13 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import { usePolicy } from 'dashboard/composables/usePolicy';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
@@ -14,9 +18,12 @@ import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponen
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
import { useI18n } from 'vue-i18n';
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const { checkPermissions } = usePolicy();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
@@ -25,9 +32,13 @@ const isFetching = computed(() => uiFlags.value.fetchingList);
const documentsMeta = useMapGetter('captainDocuments/getMeta');
const selectedAssistantId = computed(() => Number(route.params.assistantId));
const canManageDocuments = computed(() => checkPermissions(['administrator']));
const selectedDocument = ref(null);
const deleteDocumentDialog = ref(null);
const bulkDeleteDialog = ref(null);
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const handleDelete = () => {
deleteDocumentDialog.value.dialogRef.open();
@@ -78,7 +89,14 @@ const fetchDocuments = (page = 1) => {
store.dispatch('captainDocuments/get', filterParams);
};
const onPageChange = page => fetchDocuments(page);
const onPageChange = page => {
const hadSelection = bulkSelectedIds.value.size > 0;
fetchDocuments(page);
if (hadSelection) {
bulkSelectedIds.value = new Set();
}
};
const onDeleteSuccess = () => {
if (documents.value?.length === 0 && documentsMeta.value?.page > 1) {
@@ -86,6 +104,58 @@ const onDeleteSuccess = () => {
}
};
const buildSelectedCountLabel = computed(() => {
const count = documents.value?.length || 0;
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.DOCUMENTS.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const hasBulkSelection = computed(() => bulkSelectedIds.value.size > 0);
const shouldShowSelectionControl = docId => {
return (
canManageDocuments.value &&
(hoveredCard.value === docId || hasBulkSelection.value)
);
};
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const handleCardSelect = id => {
if (!canManageDocuments.value) return;
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
bulkSelectedIds.value = selected;
};
const fetchDocumentsAfterBulkAction = () => {
const hasNoDocumentsLeft = documents.value?.length === 0;
const currentPage = documentsMeta.value?.page;
if (hasNoDocumentsLeft) {
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
fetchDocuments(pageToFetch);
} else {
fetchDocuments(currentPage);
}
bulkSelectedIds.value = new Set();
};
const onBulkDeleteSuccess = () => {
fetchDocumentsAfterBulkAction();
};
onMounted(() => {
fetchDocuments();
});
@@ -106,6 +176,21 @@ onMounted(() => {
@update:current-page="onPageChange"
@click="handleCreateDocument"
>
<template #subHeader>
<Policy :permissions="['administrator']">
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="documents"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{ 'mb-2': bulkSelectedIds.size > 0 }"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
/>
</Policy>
</template>
<template #knowMore>
<FeatureSpotlightPopover
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
@@ -138,7 +223,13 @@ onMounted(() => {
:external-link="doc.external_link"
:assistant="doc.assistant"
:created-at="doc.created_at"
:is-selected="canManageDocuments && bulkSelectedIds.has(doc.id)"
:selectable="canManageDocuments"
:show-selection-control="shouldShowSelectionControl(doc.id)"
:show-menu="!bulkSelectedIds.has(doc.id)"
@action="handleAction"
@select="handleCardSelect"
@hover="isHovered => handleCardHover(isHovered, doc.id)"
/>
</div>
</template>
@@ -162,5 +253,12 @@ onMounted(() => {
type="Documents"
@delete-success="onDeleteSuccess"
/>
<BulkDeleteDialog
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="AssistantDocument"
@delete-success="onBulkDeleteSuccess"
/>
</PageLayout>
</template>
@@ -316,7 +316,7 @@ onMounted(() => {
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="Responses"
type="AssistantResponse"
@delete-success="onBulkDeleteSuccess"
/>
@@ -361,7 +361,7 @@ onMounted(() => {
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="Responses"
type="AssistantResponse"
@delete-success="onBulkDeleteSuccess"
/>
@@ -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'));
@@ -57,7 +57,10 @@ export default {
},
});
} catch (error) {
useAlert(this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE'));
useAlert(
error.message ||
this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE')
);
}
},
},
@@ -25,17 +25,26 @@ export default createStore({
}
},
handleBulkDelete: async function handleBulkDelete({ dispatch }, ids) {
handleBulkDelete: async function handleBulkDelete(
{ dispatch },
{ type = 'AssistantResponse', ids }
) {
const response = await dispatch('processBulkAction', {
type: 'AssistantResponse',
type,
actionType: 'delete',
ids,
});
// Update the response store after successful API call
await dispatch('captainResponses/removeBulkResponses', ids, {
root: true,
});
if (type === 'AssistantResponse') {
// Update the response store after successful API call
await dispatch('captainResponses/removeBulkResponses', ids, {
root: true,
});
} else if (type === 'AssistantDocument') {
await dispatch('captainDocuments/removeBulkRecords', ids, {
root: true,
});
}
return response;
},
@@ -4,4 +4,12 @@ import { createStore } from '../storeFactory';
export default createStore({
name: 'CaptainDocument',
API: CaptainDocumentAPI,
actions: mutations => ({
removeBulkRecords({ commit, getters }, ids) {
const records = getters.getRecords.filter(
record => !ids.includes(record.id)
);
commit(mutations.SET, records);
},
}),
});
@@ -220,9 +220,8 @@ export const actions = {
sendAnalyticsEvent(channel.type);
return response.data;
} catch (error) {
const errorMessage = error?.response?.data?.message;
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
throw new Error(errorMessage);
return throwErrorMessage(error);
}
},
createWebsiteChannel: async ({ commit }, params) => {
+3 -1
View File
@@ -35,7 +35,9 @@ export default {
};
},
setLocale(locale) {
this.$root.$i18n.locale = locale;
if (locale) {
this.$root.$i18n.locale = locale;
}
},
},
};
+1 -1
View File
@@ -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>
@@ -1,47 +0,0 @@
class Inboxes::BulkAutoAssignmentJob < ApplicationJob
queue_as :scheduled_jobs
include BillingHelper
def perform
Account.feature_assignment_v2.find_each do |account|
if should_skip_auto_assignment?(account)
Rails.logger.info("Skipping auto assignment for account #{account.id}")
next
end
account.inboxes.where(enable_auto_assignment: true).find_each do |inbox|
process_assignment(inbox)
end
end
end
private
def process_assignment(inbox)
allowed_agent_ids = inbox.member_ids_with_assignment_capacity
if allowed_agent_ids.blank?
Rails.logger.info("No agents available to assign conversation to inbox #{inbox.id}")
return
end
assign_conversations(inbox, allowed_agent_ids)
end
def assign_conversations(inbox, allowed_agent_ids)
unassigned_conversations = inbox.conversations.unassigned.open.limit(Limits::AUTO_ASSIGNMENT_BULK_LIMIT)
unassigned_conversations.find_each do |conversation|
::AutoAssignment::AgentAssignmentService.new(
conversation: conversation,
allowed_agent_ids: allowed_agent_ids
).perform
Rails.logger.info("Assigned conversation #{conversation.id} to agent #{allowed_agent_ids.first}")
end
end
def should_skip_auto_assignment?(account)
return false unless ChatwootApp.chatwoot_cloud?
default_plan?(account)
end
end
@@ -17,6 +17,7 @@ module AccountEmailRateLimitable
end
def within_email_rate_limit?
return true unless ChatwootApp.chatwoot_cloud?
return true if emails_sent_today < email_rate_limit
Rails.logger.warn("Account #{id} reached daily email rate limit of #{email_rate_limit}. Sent: #{emails_sent_today}")
@@ -0,0 +1,65 @@
module SocialLinkParser
extend ActiveSupport::Concern
SOCIAL_DOMAIN_MAP = {
whatsapp: %w[wa.me api.whatsapp.com],
line: %w[line.me],
facebook: %w[facebook.com fb.com fb.me],
instagram: %w[instagram.com],
telegram: %w[t.me telegram.me],
tiktok: %w[tiktok.com]
}.freeze
private
def extract_social_from_links(links)
handles = {}
SOCIAL_DOMAIN_MAP.each do |platform, domains|
handles[platform] = find_social_handle(links, platform, domains)
end
handles
end
def find_social_handle(links, platform, domains)
matching_links = links.select do |l|
uri = URI.parse(l)
domains.any? { |d| match_social_domain?(uri.host, d) }
rescue URI::InvalidURIError
false
end
matching_links.each do |link|
handle = parse_social_handle(platform, link)
return handle if handle.present?
end
nil
end
def match_social_domain?(host, domain)
return false if host.blank?
host == domain || host.end_with?(".#{domain}")
end
SHARE_PATH_PREFIXES = %w[sharer share intent dialog].freeze
def parse_social_handle(platform, link)
uri = URI.parse(link)
return extract_whatsapp_phone(uri) if platform == :whatsapp
handle = uri.path.to_s.delete_prefix('/').delete_suffix('/')
return nil if handle.blank?
return nil if SHARE_PATH_PREFIXES.any? { |prefix| handle.start_with?(prefix) }
handle.presence
rescue URI::InvalidURIError
nil
end
# wa.me/1234567890 or api.whatsapp.com/send?phone=1234567890
def extract_whatsapp_phone(uri)
phone = CGI.parse(uri.query.to_s)['phone']&.first
phone = uri.path.to_s.delete_prefix('/').delete_suffix('/') if phone.blank?
phone.presence&.gsub(/[^\d]/, '')
end
end
+93
View File
@@ -0,0 +1,93 @@
class WebsiteBrandingService
include SocialLinkParser
def initialize(url)
@url = normalize_url(url)
end
def perform
doc = fetch_page
return nil if doc.nil?
links = extract_links(doc)
{
business_name: extract_business_name(doc),
language: extract_language(doc),
industry_category: nil,
social_handles: extract_social_from_links(links),
branding: extract_branding(doc)
}
rescue StandardError => e
Rails.logger.error "[WebsiteBranding] #{e.message}"
nil
end
private
def normalize_url(url)
url.match?(%r{\Ahttps?://}) ? url : "https://#{url}"
end
def fetch_page
response = HTTParty.get(@url, follow_redirects: true, timeout: 15)
return nil unless response.success?
Nokogiri::HTML(response.body)
rescue StandardError => e
Rails.logger.error "[WebsiteBranding] Failed to fetch #{@url}: #{e.message}"
nil
end
def extract_business_name(doc)
og_site_name = doc.at_css('meta[property="og:site_name"]')&.[]('content')
return og_site_name.strip if og_site_name.present?
title = doc.at_xpath('//title')&.text
title&.strip&.split(/\s*[|\-–—·:]+\s*/)&.first
end
def extract_language(doc)
doc.at_css('html')&.[]('lang')&.split('-')&.first&.downcase
end
def extract_links(doc)
doc.css('a[href]').filter_map do |a|
href = a['href']&.strip
next if href.blank? || href.start_with?('#', 'javascript:', 'mailto:', 'tel:')
href.start_with?('http') ? href : URI.join(@url, href).to_s
rescue URI::InvalidURIError
nil
end.uniq
end
def extract_branding(doc)
{
favicon: extract_favicon(doc),
primary_color: extract_theme_color(doc)
}
end
def extract_favicon(doc)
favicon = doc.at_css('link[rel*="icon"]')&.[]('href')
return nil if favicon.blank?
resolve_url(favicon)
end
def extract_theme_color(doc)
doc.at_css('meta[name="theme-color"]')&.[]('content')
end
def resolve_url(url)
return nil if url.blank?
return url if url.start_with?('http')
URI.join(@url, url).to_s
rescue URI::InvalidURIError
nil
end
end
WebsiteBrandingService.prepend_mod_with('WebsiteBrandingService')
+2 -2
View File
@@ -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 %>
@@ -0,0 +1,12 @@
<div class="max-w-6xl h-full w-full flex-grow flex flex-col items-center justify-center mx-auto py-16 px-4 relative">
<div class="text-center mb-12">
<div class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-300">
<span class="text-5xl font-medium">i</span>
</div>
</div>
<h1 class="text-6xl text-center font-semibold text-slate-800 dark:text-slate-100 leading-relaxed"><%= I18n.t('public_portal.not_active.title') %></h1>
<p class="text-center text-slate-700 dark:text-slate-300 my-1"><%= I18n.t('public_portal.not_active.description') %></p>
<div class="text-center my-8">
<p class="text-slate-600 dark:text-slate-400 text-sm"><%= I18n.t('public_portal.not_active.action') %></p>
</div>
</div>
+3 -3
View File
@@ -104,10 +104,10 @@
display_name: Audit Logs
enabled: false
premium: true
- name: response_bot
display_name: Response Bot
- name: custom_tools
display_name: Custom Tools
enabled: false
deprecated: true
premium: true
- name: message_reply_to
display_name: Message Reply To
enabled: false
+2 -1
View File
@@ -42,7 +42,8 @@ LANGUAGES_CONFIG = {
37 => { name: 'עִברִית (he)', iso_639_3_code: 'heb', iso_639_1_code: 'he', enabled: true },
38 => { name: 'lietuvių (lt)', iso_639_3_code: 'lit', iso_639_1_code: 'lt', enabled: true },
39 => { name: 'Српски (sr)', iso_639_3_code: 'srp', iso_639_1_code: 'sr', enabled: true },
40 => { name: 'български (bg)', iso_639_3_code: 'bul', iso_639_1_code: 'bg', enabled: true }
40 => { name: 'български (bg)', iso_639_3_code: 'bul', iso_639_1_code: 'bg', enabled: true },
41 => { name: 'Eesti keel (et)', iso_639_3_code: 'est', iso_639_1_code: 'et', enabled: true }
}.filter { |_key, val| val[:enabled] }.freeze
Rails.configuration.i18n.available_locales = LANGUAGES_CONFIG.map { |_index, lang| lang[:iso_639_1_code].to_sym }
+14 -3
View File
@@ -34,7 +34,18 @@ end
# https://github.com/ondrejbartas/sidekiq-cron
Rails.application.reloader.to_prepare do
# TODO: Switch to `load_from_hash!(..., source: 'schedule')` once we have a
# safe cleanup path for YAML-backed cron jobs already persisted in Redis.
Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file) if File.exist?(schedule_file) && Sidekiq.server?
# load_from_hash! upserts jobs from the YAML and removes any Redis-persisted
# jobs that share the same source tag but are no longer in the file.
# This ensures deleted schedule entries are cleaned up on deploy.
if File.exist?(schedule_file) && Sidekiq.server?
schedule = YAML.load_file(schedule_file)
# Cron entries removed from schedule.yml but possibly still in Redis
# with source:'dynamic' (predating the source tag). load_from_hash!
# only cleans up source:'schedule' entries, so these need explicit removal.
# Remove names from this list once they've been through a deploy cycle.
%w[bulk_auto_assignment_job].each { |name| Sidekiq::Cron::Job.destroy(name) }
Sidekiq::Cron::Job.load_from_hash!(schedule, source: 'schedule')
end
end
+4
View File
@@ -423,6 +423,10 @@ en:
title: Page not found
description: We couldn't find the page you were looking for.
back_to_home: Go to home page
not_active:
title: Help Center Unavailable
description: Please contact the site administrator for more information.
action: If you are the administrator, please upgrade your plan to restore access.
slack_unfurl:
fields:
name: Name
+12
View File
@@ -134,6 +134,18 @@ codepen:
</iframe>
</div>
guidejar:
regex: 'https?://(?:www\.)?guidejar\.com/(?:embed|guides)/(?<guide_id>[^&/?]+)'
template: |
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.guidejar.com/embed/%{guide_id}?type=1&controls=on"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
github_gist:
regex: 'https?://gist\.github\.com/(?<username>[^/]+)/(?<gist_id>[a-f0-9]+)'
template: |
-8
View File
@@ -4,7 +4,6 @@
# executed daily at 0000 UTC
# schedules daily deferred jobs at stable times for each installation
# keep the existing schedule key while the cron loader still uses load_from_hash
internal_check_new_versions_job:
cron: '0 0 * * *'
class: 'Internal::TriggerDailyScheduledItemsJob'
@@ -50,13 +49,6 @@ delete_accounts_job:
class: 'Internal::DeleteAccountsJob'
queue: scheduled_jobs
# executed every 15 minutes
# to assign unassigned conversations for all inboxes
bulk_auto_assignment_job:
cron: '*/15 * * * *'
class: 'Inboxes::BulkAutoAssignmentJob'
queue: scheduled_jobs
# executed every 30 minutes for assignment_v2
periodic_assignment_job:
cron: '*/30 * * * *'
@@ -0,0 +1,22 @@
class RepurposeResponseBotFlagForCustomTools < ActiveRecord::Migration[7.1]
def up
# The response_bot flag (deprecated) has been renamed to custom_tools.
# Disable it on any accounts that had response_bot enabled so the repurposed
# flag starts in its intended default-off state.
Account.feature_custom_tools.find_each(batch_size: 100) do |account|
account.disable_features(:custom_tools)
account.save!(validate: false)
end
# Remove the stale response_bot entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
# ConfigLoader only adds new flags; it never removes renamed ones.
# Leaving it would cause NoMethodError in enable_default_features when
# creating new accounts (feature_response_bot= no longer exists).
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
return if config&.value.blank?
config.value = config.value.reject { |f| f['name'] == 'response_bot' }
config.save!
GlobalConfig.clear_cache
end
end
@@ -4,7 +4,7 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
before_action :validate_params
before_action :type_matches?
MODEL_TYPE = ['AssistantResponse'].freeze
MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze
def create
@responses = process_bulk_action
@@ -28,6 +28,8 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
case params[:type]
when 'AssistantResponse'
handle_assistant_responses
when 'AssistantDocument'
handle_documents
end
end
@@ -45,6 +47,16 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
end
end
def handle_documents
return [] unless params[:fields][:status] == 'delete'
documents = Current.account.captain_documents.where(id: params[:ids])
return [] unless documents.exists?
documents.destroy_all
[]
end
def permitted_params
params.permit(:type, ids: [], fields: [:status])
end
@@ -1,6 +1,7 @@
module Enterprise::Inbox
def member_ids_with_assignment_capacity
return super unless enable_auto_assignment?
return filter_by_capacity(available_agents).map(&:user_id) if auto_assignment_v2_enabled?
max_assignment_limit = auto_assignment_config['max_assignment_limit']
overloaded_agent_ids = max_assignment_limit.present? ? get_agent_ids_over_assignment_limit(max_assignment_limit) : []
@@ -21,7 +21,7 @@ module Enterprise::InboxAgentAvailability
end
def capacity_filtering_enabled?
account.feature_enabled?('assignment_v2') &&
account.feature_enabled?('advanced_assignment') &&
account.account_users.joins(:agent_capacity_policy).exists?
end
@@ -7,21 +7,12 @@ class Enterprise::Billing::CreateStripeCustomerService
return if existing_subscription?
customer_id = prepare_customer_id
subscription = Stripe::Subscription.create(
{
customer: customer_id,
items: [{ price: price_id, quantity: default_quantity }]
}
)
account.update!(
custom_attributes: {
stripe_customer_id: customer_id,
stripe_price_id: subscription['plan']['id'],
stripe_product_id: subscription['plan']['product'],
plan_name: default_plan['name'],
subscribed_quantity: subscription['quantity']
}
)
subscription = Stripe::Subscription.create(customer: customer_id, items: [{ price: price_id, quantity: default_quantity }])
custom_attributes = build_custom_attributes(customer_id, subscription)
custom_attributes.except!('is_creating_customer')
account.update!(custom_attributes: custom_attributes)
Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
end
private
@@ -66,4 +57,23 @@ class Enterprise::Billing::CreateStripeCustomerService
)
subscriptions.data.present?
end
def build_custom_attributes(customer_id, subscription)
(account.custom_attributes || {}).merge(
'stripe_customer_id' => customer_id,
'stripe_price_id' => subscription['plan']['id'],
'stripe_product_id' => subscription['plan']['product'],
'plan_name' => default_plan['name'],
'subscribed_quantity' => subscription['quantity'],
'subscription_status' => subscription['status'],
'subscription_ends_on' => subscription_ends_on(subscription)
)
end
def subscription_ends_on(subscription)
period_end = subscription['current_period_end']
return if period_end.blank?
Time.zone.at(period_end)
end
end
@@ -2,29 +2,9 @@ class Enterprise::Billing::HandleStripeEventService
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.freeze
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
# Each higher tier includes all features from the lower tiers
# Basic features available starting with the Startups plan
STARTUP_PLAN_FEATURES = %w[
inbound_emails
help_center
campaigns
team_management
channel_facebook
channel_email
channel_instagram
captain_integration
advanced_search_indexing
advanced_search
linear_integration
].freeze
# Additional features available starting with the Business plan
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze
# Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
STARTUP_PLAN_FEATURES = Enterprise::Billing::ReconcilePlanFeaturesService::STARTUP_PLAN_FEATURES
BUSINESS_PLAN_FEATURES = Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES
ENTERPRISE_PLAN_FEATURES = Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES
def perform(event:)
@event = event
@@ -49,7 +29,7 @@ class Enterprise::Billing::HandleStripeEventService
previous_usage = capture_previous_usage
update_account_attributes(subscription, plan)
update_plan_features
Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
if billing_period_renewed?
ActiveRecord::Base.transaction do
@@ -94,34 +74,6 @@ class Enterprise::Billing::HandleStripeEventService
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
end
def update_plan_features
if default_plan?
disable_all_premium_features
else
enable_features_for_current_plan
end
# Enable any manually managed features configured in internal_attributes
enable_account_manually_managed_features
account.save!
end
def disable_all_premium_features
# Disable all features (for default Hacker plan)
account.disable_features(*STARTUP_PLAN_FEATURES)
account.disable_features(*BUSINESS_PLAN_FEATURES)
account.disable_features(*ENTERPRISE_PLAN_FEATURES)
end
def enable_features_for_current_plan
# First disable all premium features to handle downgrades
disable_all_premium_features
# Then enable features based on the current plan
enable_plan_specific_features
end
def handle_subscription_credits(plan, previous_usage)
current_limits = account.limits || {}
@@ -153,19 +105,6 @@ class Enterprise::Billing::HandleStripeEventService
config[plan_name.downcase]&.symbolize_keys
end
def enable_plan_specific_features
plan_name = account.custom_attributes['plan_name']
return if plan_name.blank?
case plan_name
when 'Startups' then account.enable_features(*STARTUP_PLAN_FEATURES)
when 'Business'
account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES)
when 'Enterprise'
account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES, *ENTERPRISE_PLAN_FEATURES)
end
end
def subscription
@subscription ||= @event.data.object
end
@@ -197,19 +136,4 @@ class Enterprise::Billing::HandleStripeEventService
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
end
def default_plan?
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
default_plan = cloud_plans.first || {}
account.custom_attributes['plan_name'] == default_plan['name']
end
def enable_account_manually_managed_features
# Get manually managed features from internal attributes using the service
service = Internal::Accounts::InternalAttributesService.new(account)
features = service.manually_managed_features
# Enable each feature
account.enable_features(*features) if features.present?
end
end
@@ -0,0 +1,61 @@
class Enterprise::Billing::ReconcilePlanFeaturesService
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
# Each higher tier includes all features from the lower tiers
STARTUP_PLAN_FEATURES = %w[
inbound_emails
help_center
campaigns
team_management
channel_facebook
channel_email
channel_instagram
captain_integration
advanced_search_indexing
advanced_search
linear_integration
].freeze
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze
pattr_initialize [:account!]
def perform
account.disable_features(*PREMIUM_PLAN_FEATURES)
account.enable_features(*current_plan_features)
account.enable_features(*manually_managed_features)
account.save!
end
private
def current_plan_features
return [] if default_plan?
case account.custom_attributes['plan_name']
when 'Startups' then STARTUP_PLAN_FEATURES
when 'Business' then STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES
when 'Enterprise' then PREMIUM_PLAN_FEATURES
else []
end
end
def default_plan?
default_plan_name = cloud_plans.first&.dig('name')
return false if default_plan_name.blank?
plan_name = account.custom_attributes['plan_name']
plan_name.blank? || plan_name == default_plan_name
end
def cloud_plans
@cloud_plans ||= InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
end
def manually_managed_features
@manually_managed_features ||= Internal::Accounts::InternalAttributesService.new(account).manually_managed_features
end
end
@@ -73,8 +73,13 @@ class Enterprise::Billing::TopupCheckoutService
description: description
)
Stripe::Invoice.finalize_invoice(invoice.id, { auto_advance: false })
Stripe::Invoice.pay(invoice.id)
finalize_and_pay(invoice.id)
end
def finalize_and_pay(invoice_id)
Stripe::Invoice.finalize_invoice(invoice_id, { auto_advance: false })
invoice = Stripe::Invoice.retrieve(invoice_id)
Stripe::Invoice.pay(invoice_id) unless invoice.status == 'paid'
end
def fulfill_credits(credits, topup_option)
@@ -0,0 +1,112 @@
module Enterprise::WebsiteBrandingService
FIRECRAWL_SCRAPE_ENDPOINT = 'https://api.firecrawl.dev/v2/scrape'.freeze
INDUSTRY_CATEGORIES = [
'Technology',
'E-commerce',
'Healthcare',
'Education',
'Finance',
'Real Estate',
'Marketing',
'Travel & Hospitality',
'Food & Beverage',
'Media & Entertainment',
'Professional Services',
'Non-profit',
'Other'
].freeze
def perform
return super unless firecrawl_enabled?
response = perform_firecrawl_request
process_firecrawl_response(response)
rescue StandardError => e
Rails.logger.error "[WebsiteBranding] Firecrawl failed: #{e.message}, falling back to basic scrape"
super
end
private
def firecrawl_enabled?
firecrawl_api_key.present?
end
def firecrawl_api_key
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value
end
def perform_firecrawl_request
HTTParty.post(
FIRECRAWL_SCRAPE_ENDPOINT,
body: scrape_payload.to_json,
headers: {
'Authorization' => "Bearer #{firecrawl_api_key}",
'Content-Type' => 'application/json'
}
)
end
def scrape_payload
{
url: @url,
onlyMainContent: false,
formats: [
{
type: 'json',
schema: extract_schema,
prompt: 'Extract the business name, primary language, and industry category from this website.'
},
'branding',
'links'
]
}
end
def extract_schema
{
type: 'object',
properties: {
business_name: { type: 'string', description: 'The name of the business or company' },
language: { type: 'string', description: 'Primary language as ISO 639-1 code (e.g., en, es, fr)' },
industry_category: { type: 'string', enum: INDUSTRY_CATEGORIES, description: 'Industry category for this business' }
},
required: %w[business_name]
}
end
def process_firecrawl_response(response)
raise "API Error: #{response.message} (Status: #{response.code})" unless response.success?
format_firecrawl_response(response)
end
def format_firecrawl_response(response)
data = response.parsed_response
extract = data.dig('data', 'json') || {}
brand = data.dig('data', 'branding') || {}
links = data.dig('data', 'links') || []
{
business_name: extract['business_name'],
language: extract['language'],
industry_category: extract['industry_category'],
social_handles: extract_social_from_links(links),
branding: extract_firecrawl_branding(brand)
}
end
def extract_firecrawl_branding(brand)
{
favicon: url_or_nil(brand.dig('images', 'favicon')),
primary_color: brand.dig('colors', 'primary')
}
end
def url_or_nil(value)
return nil if value.blank? || !value.start_with?('http')
value
end
end
@@ -53,8 +53,8 @@ class Internal::Accounts::InternalAttributesService
# Get list of valid features that can be manually managed
def valid_feature_list
# Business and Enterprise plan features only
Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES +
Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES
Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES +
Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES
end
# Account notes functionality removed for now
@@ -3,8 +3,6 @@ You are evaluating whether a customer support conversation is complete and can b
The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language.
A conversation is INCOMPLETE (keep open) if ANY of these apply:
- The assistant suggested the customer try something or take an action — they may still be attempting it
- The assistant directed the customer to an external resource, link, or contact — they may still be following up
- The assistant asked a question or requested information that the customer hasn't provided
- The customer asked a question that wasn't fully answered
- The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet
+1 -1
View File
@@ -5,7 +5,7 @@ class ChatwootMarkdownRenderer
def render_message
markdown_renderer = BaseMarkdownRenderer.new
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough, :autolink])
html = markdown_renderer.render(doc)
render_as_html_safe(html)
end
+1 -1
View File
@@ -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.7",
"@chatwoot/prosemirror-schema": "1.3.8",
"@chatwoot/utils": "^0.0.52",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
+5 -5
View File
@@ -26,8 +26,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
specifier: 1.3.7
version: 1.3.7
specifier: 1.3.8
version: 1.3.8
'@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.7':
resolution: {integrity: sha512-N+Gicecp18TSEJQoRtGZXkp8R+kC0iPSms8ezu1k8U+ySY9FAENzFQQ1rBVSSC4hDFwb9/EbSI9IFqDjHGds7g==}
'@chatwoot/prosemirror-schema@1.3.8':
resolution: {integrity: sha512-Vr8eUdydmVr7iRnNky4jXKX3XD4z5HAS4bV7zJXxA4av4ig5qjTldDOg7c/C8rqYNKGR5UEOEu9CQfGcjfKVXg==}
'@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.7':
'@chatwoot/prosemirror-schema@1.3.8':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
+7 -1
View File
@@ -21,7 +21,7 @@ describe 'Markdown Embeds Configuration' do
end
it 'contains expected embed types' do
expected_types = %w[youtube loom vimeo mp4 arcade_tab arcade wistia bunny codepen github_gist]
expected_types = %w[youtube loom vimeo mp4 arcade_tab arcade wistia bunny codepen guidejar github_gist]
expect(config.keys).to match_array(expected_types)
end
end
@@ -73,6 +73,12 @@ describe 'Markdown Embeds Configuration' do
{ url: 'https://codepen.io/username/pen/abcdef', expected: { 'user' => 'username', 'pen_id' => 'abcdef' } },
{ url: 'https://www.codepen.io/testuser/pen/xyz123', expected: { 'user' => 'testuser', 'pen_id' => 'xyz123' } }
],
'guidejar' => [
{ url: 'https://www.guidejar.com/embed/i2qMQRp26rtRxpZczmaA', expected: { 'guide_id' => 'i2qMQRp26rtRxpZczmaA' } },
{ url: 'https://guidejar.com/guides/i2qMQRp26rtRxpZczmaA', expected: { 'guide_id' => 'i2qMQRp26rtRxpZczmaA' } },
{ url: 'https://guidejar.com/guides/d6a6fdc2-4812-4777-897e-ec1b0c64238f',
expected: { 'guide_id' => 'd6a6fdc2-4812-4777-897e-ec1b0c64238f' } }
],
'github_gist' => [
{ url: 'https://gist.github.com/username/1234567890abcdef1234567890abcdef',
expected: { 'username' => 'username', 'gist_id' => '1234567890abcdef1234567890abcdef' } },
@@ -13,6 +13,12 @@ RSpec.describe Public::Api::V1::PortalsController, type: :request do
end
describe 'GET /public/api/v1/portals/{portal_slug}' do
it 'redirects to the portal default locale when locale is not present' do
get "/hc/#{portal.slug}"
expect(response).to redirect_to("/hc/#{portal.slug}/#{portal.default_locale}")
end
it 'Show portal and categories belonging to the portal' do
get "/hc/#{portal.slug}/en"
@@ -14,6 +14,14 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
status: 'pending'
)
end
let!(:documents) do
create_list(
:captain_document,
2,
assistant: assistant,
account: account
)
end
def json_response
JSON.parse(response.body, symbolize_names: true)
@@ -98,6 +106,28 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
end
end
context 'when deleting documents' do
let(:document_delete_params) do
{
type: 'AssistantDocument',
ids: documents.map(&:id),
fields: { status: 'delete' }
}
end
it 'deletes the documents and returns an empty array' do
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: document_delete_params,
headers: admin.create_new_auth_token,
as: :json
end.to change(Captain::Document, :count).by(-2)
expect(response).to have_http_status(:ok)
expect(json_response).to eq([])
end
end
context 'with missing parameters' do
let(:missing_params) do
{
@@ -281,6 +281,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice)
allow(Stripe::InvoiceItem).to receive(:create)
allow(Stripe::Invoice).to receive(:finalize_invoice)
allow(Stripe::Invoice).to receive(:retrieve).and_return(Struct.new(:status).new('open'))
allow(Stripe::Invoice).to receive(:pay)
allow(Stripe::Billing::CreditGrant).to receive(:create)
end
@@ -0,0 +1,46 @@
require 'rails_helper'
RSpec.describe 'Public Help Center Access', type: :request do
let(:plan_name) { 'Startups' }
let!(:account) { create(:account, custom_attributes: { 'plan_name' => plan_name }) }
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, account: account, custom_domain: 'docs-helpcenter.example.com') }
let!(:category) { create(:category, portal: portal, account: account, locale: 'en', slug: 'category-slug') }
let!(:article) { create(:article, category: category, portal: portal, account: account, author: agent, status: :published) }
around do |example|
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com', HELPCENTER_URL: 'https://help.chatwoot.com' do
previous_deployment_env = InstallationConfig.find_by(name: 'DEPLOYMENT_ENV')&.value
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud')
example.run
ensure
config = InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize
previous_deployment_env.present? ? config.update!(value: previous_deployment_env) : config.destroy!
host! 'www.example.com'
end
end
it 'blocks chatwoot-hosted portal pages when the help center feature is disabled' do
account.disable_features!(:help_center)
host! 'help.chatwoot.com'
get "/hc/#{portal.slug}/en"
expect(response).to have_http_status(:payment_required)
expect(response.body).to include('Help Center Unavailable')
end
context 'when the account is on the default plan' do
let(:plan_name) { 'Hacker' }
it 'still allows access if the feature flag is enabled' do
account.enable_features!(:help_center)
host! portal.custom_domain
get "/hc/#{portal.slug}/articles/#{article.slug}"
expect(response).to have_http_status(:ok)
end
end
end
+97
View File
@@ -37,6 +37,103 @@ RSpec.describe Inbox do
end
end
describe 'member_ids_with_assignment_capacity with V2 capacity' do
let(:account) { create(:account) }
let(:v2_inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
let(:agent_capacity_policy) { create(:agent_capacity_policy, account: account) }
let!(:agent1) { create(:user, account: account, role: :agent, auto_offline: false) }
let!(:agent2) { create(:user, account: account, role: :agent, auto_offline: false) }
before do
create(:inbox_member, inbox: v2_inbox, user: agent1)
create(:inbox_member, inbox: v2_inbox, user: agent2)
allow(OnlineStatusTracker).to receive(:get_available_users).and_return(
agent1.id.to_s => 'online',
agent2.id.to_s => 'online'
)
end
context 'when assignment_v2 is enabled with capacity policies' do
before do
account.enable_features('assignment_v2', 'advanced_assignment')
account.save!
create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: v2_inbox, conversation_limit: 1)
agent1.account_users.find_by(account: account).update!(agent_capacity_policy: agent_capacity_policy)
agent2.account_users.find_by(account: account).update!(agent_capacity_policy: agent_capacity_policy)
end
it 'filters out agents at capacity' do
create(:conversation, inbox: v2_inbox, account: account, assignee: agent1, status: :open)
result = v2_inbox.member_ids_with_assignment_capacity
expect(result).to include(agent2.id)
expect(result).not_to include(agent1.id)
end
it 'filters out all agents when all are at capacity' do
create(:conversation, inbox: v2_inbox, account: account, assignee: agent1, status: :open)
create(:conversation, inbox: v2_inbox, account: account, assignee: agent2, status: :open)
expect(v2_inbox.member_ids_with_assignment_capacity).to be_empty
end
it 'skips V1 max_assignment_limit when V2 is enabled' do
v2_inbox.update(auto_assignment_config: { max_assignment_limit: 100 })
create(:conversation, inbox: v2_inbox, account: account, assignee: agent1, status: :open)
result = v2_inbox.member_ids_with_assignment_capacity
expect(result).not_to include(agent1.id)
end
end
context 'when assignment_v2 is enabled without capacity policies' do
before do
account.enable_features('assignment_v2', 'advanced_assignment')
account.save!
end
it 'returns all online agents' do
result = v2_inbox.member_ids_with_assignment_capacity
expect(result).to contain_exactly(agent1.id, agent2.id)
end
end
context 'when advanced_assignment is disabled (downgraded account with stale policies)' do
before do
account.enable_features('assignment_v2')
account.save!
create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: v2_inbox, conversation_limit: 1)
agent1.account_users.find_by(account: account).update!(agent_capacity_policy: agent_capacity_policy)
create(:conversation, inbox: v2_inbox, account: account, assignee: agent1, status: :open)
end
it 'does not enforce capacity limits' do
result = v2_inbox.member_ids_with_assignment_capacity
expect(result).to include(agent1.id)
end
end
context 'when assignment_v2 is disabled (V1 path)' do
before do
v2_inbox.update(auto_assignment_config: { max_assignment_limit: 2 })
end
it 'uses V1 max_assignment_limit' do
create_list(:conversation, 2, inbox: v2_inbox, account: account, assignee: agent1, status: :open)
result = v2_inbox.member_ids_with_assignment_capacity
expect(result).not_to include(agent1.id)
expect(result).to include(agent2.id)
end
end
end
describe 'audit log' do
context 'when inbox is created' do
it 'has associated audit log created' do
@@ -7,6 +7,16 @@ describe Enterprise::Billing::CreateStripeCustomerService do
let!(:admin1) { create(:user, account: account, role: :administrator) }
let(:admin2) { create(:user, account: account, role: :administrator) }
let(:subscriptions_list) { double }
let(:current_period_end) { 1_686_567_520 }
let(:subscription_ends_on) { Time.zone.at(current_period_end).as_json }
let(:created_subscription) do
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2,
status: 'active',
current_period_end: current_period_end
}.with_indifferent_access
end
describe '#perform' do
before do
@@ -18,18 +28,44 @@ describe Enterprise::Billing::CreateStripeCustomerService do
)
end
it 'preserves unrelated custom attributes, clears is_creating_customer, and reconciles default-plan features' do
account.update!(
custom_attributes: {
'is_creating_customer' => true,
'onboarding_source' => 'billing_page',
'subscription_status' => 'past_due',
'subscription_ends_on' => 1.day.ago
}
)
account.enable_features!(:help_center)
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
create_stripe_customer_service.new(account: account).perform
expect(account.reload.custom_attributes).to include(
'stripe_customer_id' => customer.id,
'stripe_price_id' => 'price_random_number',
'stripe_product_id' => 'prod_random_number',
'subscribed_quantity' => 2,
'plan_name' => 'A Plan Name',
'onboarding_source' => 'billing_page',
'subscription_status' => 'active',
'subscription_ends_on' => subscription_ends_on
)
expect(account.custom_attributes).not_to have_key('is_creating_customer')
expect(account).not_to be_feature_enabled('help_center')
end
it 'does not call stripe methods if customer id is present' do
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
allow(subscriptions_list).to receive(:data).and_return([])
allow(Stripe::Customer).to receive(:create)
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(Stripe::Subscription).to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
create_stripe_customer_service.new(account: account).perform
@@ -44,7 +80,9 @@ describe Enterprise::Billing::CreateStripeCustomerService do
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
plan_name: 'A Plan Name',
subscription_status: 'active',
subscription_ends_on: subscription_ends_on
}.with_indifferent_access
)
end
@@ -53,14 +91,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription)
.to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
create_stripe_customer_service.new(account: account).perform
@@ -75,7 +106,9 @@ describe Enterprise::Billing::CreateStripeCustomerService do
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
plan_name: 'A Plan Name',
subscription_status: 'active',
subscription_ends_on: subscription_ends_on
}.with_indifferent_access
)
end
@@ -96,12 +129,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription).to receive(:create).and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
create_stripe_customer_service.new(account: account).perform
@@ -24,6 +24,7 @@ describe Enterprise::Billing::TopupCheckoutService do
allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice)
allow(Stripe::InvoiceItem).to receive(:create)
allow(Stripe::Invoice).to receive(:finalize_invoice)
allow(Stripe::Invoice).to receive(:retrieve).and_return(Struct.new(:status).new('open'))
allow(Stripe::Invoice).to receive(:pay)
allow(Stripe::Billing::CreditGrant).to receive(:create)
end
@@ -58,5 +59,19 @@ describe Enterprise::Billing::TopupCheckoutService do
expect(error.message).to eq(I18n.t('errors.topup.plan_not_eligible'))
end
end
it 'calls pay when invoice is open after finalization' do
service.create_checkout_session(credits: 1000)
expect(Stripe::Invoice).to have_received(:pay).with('inv_test123')
end
it 'skips pay when invoice is already paid via Stripe credits' do
allow(Stripe::Invoice).to receive(:retrieve).and_return(Struct.new(:status).new('paid'))
service.create_checkout_session(credits: 1000)
expect(Stripe::Invoice).not_to have_received(:pay)
end
end
end
@@ -0,0 +1,171 @@
require 'rails_helper'
# Simulate the prepend_mod_with behavior for testing
test_klass = Class.new(WebsiteBrandingService) do
prepend Enterprise::WebsiteBrandingService
end
RSpec.describe Enterprise::WebsiteBrandingService do
describe '#perform' do
subject(:service) { test_klass.new(url) }
let(:url) { 'https://example.com' }
let(:api_key) { 'test-firecrawl-api-key' }
let(:scrape_endpoint) { described_class::FIRECRAWL_SCRAPE_ENDPOINT }
let(:fallback_html) { '<html lang="en"><head><title>Fallback</title></head><body></body></html>' }
let(:success_response_body) do
{
success: true,
data: {
json: {
business_name: 'Acme Corp',
language: 'en',
industry_category: 'Technology'
},
branding: {
images: { logo: 'https://example.com/logo.png', favicon: 'https://example.com/favicon.png' },
colors: { primary: '#FF5733' }
},
links: [
'https://example.com/about',
'https://facebook.com/acmecorp',
'https://instagram.com/acme_corp',
'https://wa.me/1234567890',
'https://t.me/acmecorp',
'https://tiktok.com/@acmetok'
]
}
}.to_json
end
before do
stub_request(:get, url).to_return(status: 200, body: fallback_html, headers: { 'content-type' => 'text/html' })
end
context 'when firecrawl is configured and API returns success' do
before do
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
stub_request(:post, scrape_endpoint)
.with(headers: { 'Authorization' => "Bearer #{api_key}", 'Content-Type' => 'application/json' })
.to_return(status: 200, body: success_response_body, headers: { 'content-type' => 'application/json' })
end
it 'returns business info and branding from firecrawl' do
result = service.perform
expect(result).to eq({
business_name: 'Acme Corp',
language: 'en',
industry_category: 'Technology',
social_handles: {
whatsapp: '1234567890',
line: nil,
facebook: 'acmecorp',
instagram: 'acme_corp',
telegram: 'acmecorp',
tiktok: '@acmetok'
},
branding: {
favicon: 'https://example.com/favicon.png',
primary_color: '#FF5733'
}
})
end
end
context 'when firecrawl API returns an error' do
before do
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
stub_request(:post, scrape_endpoint)
.to_return(status: 422, body: '{"error": "Invalid URL"}', headers: {})
end
it 'falls back to basic scrape' do
result = service.perform
expect(result[:business_name]).to eq('Fallback')
expect(result[:industry_category]).to be_nil
end
end
context 'when firecrawl raises an exception' do
before do
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
stub_request(:post, scrape_endpoint).to_raise(StandardError.new('connection refused'))
end
it 'falls back to basic scrape' do
result = service.perform
expect(result[:business_name]).to eq('Fallback')
end
end
context 'when firecrawl is not configured' do
it 'uses basic scrape' do
expect(HTTParty).not_to receive(:post)
result = service.perform
expect(result[:business_name]).to eq('Fallback')
end
end
context 'when WhatsApp link uses api.whatsapp.com format' do
before do
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
response = {
success: true,
data: {
json: { business_name: 'Acme Corp' },
links: ['https://api.whatsapp.com/send?phone=5511999999999&text=Hello']
}
}.to_json
stub_request(:post, scrape_endpoint)
.to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' })
end
it 'extracts phone number from query param' do
result = service.perform
expect(result[:social_handles][:whatsapp]).to eq('5511999999999')
end
end
context 'when WhatsApp link uses wa.me format' do
before do
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
response = {
success: true,
data: {
json: { business_name: 'Acme Corp' },
links: ['https://wa.me/+5511999999999']
}
}.to_json
stub_request(:post, scrape_endpoint)
.to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' })
end
it 'extracts phone number from path' do
result = service.perform
expect(result[:social_handles][:whatsapp]).to eq('5511999999999')
end
end
context 'when links contain lookalike domains' do
before do
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
response = {
success: true,
data: {
json: { business_name: 'Acme Corp' },
links: ['https://notfacebook.com/page', 'https://fakeinstagram.com/user']
}
}.to_json
stub_request(:post, scrape_endpoint)
.to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' })
end
it 'does not match lookalike domains' do
result = service.perform
expect(result[:social_handles][:facebook]).to be_nil
expect(result[:social_handles][:instagram]).to be_nil
end
end
end
end
@@ -1,93 +0,0 @@
require 'rails_helper'
RSpec.describe Inboxes::BulkAutoAssignmentJob do
let(:account) { create(:account, custom_attributes: { 'plan_name' => 'Startups' }) }
let(:agent) { create(:user, account: account, role: :agent, auto_offline: false) }
let(:inbox) { create(:inbox, account: account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil, status: :open) }
let(:assignment_service) { double }
describe '#perform' do
before do
allow(assignment_service).to receive(:perform)
end
context 'when inbox has inbox members' do
before do
create(:inbox_member, user: agent, inbox: inbox)
account.enable_features!('assignment_v2')
inbox.update!(enable_auto_assignment: true)
end
it 'assigns unassigned conversations in enabled inboxes' do
allow(AutoAssignment::AgentAssignmentService).to receive(:new).with(
conversation: conversation,
allowed_agent_ids: [agent.id]
).and_return(assignment_service)
described_class.perform_now
expect(AutoAssignment::AgentAssignmentService).to have_received(:new).with(
conversation: conversation,
allowed_agent_ids: [agent.id]
)
end
it 'skips inboxes with auto assignment disabled' do
inbox.update!(enable_auto_assignment: false)
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
described_class.perform_now
expect(AutoAssignment::AgentAssignmentService).not_to have_received(:new).with(
conversation: conversation,
allowed_agent_ids: [agent.id]
)
end
context 'when account is on default plan in chatwoot cloud' do
before do
account.update!(custom_attributes: {})
InstallationConfig.create(name: 'CHATWOOT_CLOUD_PLANS', value: [{ 'name' => 'default' }])
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
end
it 'skips auto assignment' do
allow(Rails.logger).to receive(:info)
expect(Rails.logger).to receive(:info).with("Skipping auto assignment for account #{account.id}")
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
described_class.perform_now
end
end
end
context 'when inbox has no members' do
before do
account.enable_features!('assignment_v2')
inbox.update!(enable_auto_assignment: true)
end
it 'does not assign conversations' do
allow(Rails.logger).to receive(:info)
expect(Rails.logger).to receive(:info).with("No agents available to assign conversation to inbox #{inbox.id}")
described_class.perform_now
end
end
context 'when assignment_v2 feature is disabled' do
before do
account.disable_features!('assignment_v2')
end
it 'skips auto assignment' do
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
described_class.perform_now
end
end
end
end
+11 -7
View File
@@ -6,11 +6,9 @@ RSpec.describe ChatwootMarkdownRenderer do
let(:doc) { instance_double(CommonMarker::Node) }
let(:renderer) { described_class.new(markdown_content) }
let(:markdown_renderer) { instance_double(CustomMarkdownRenderer) }
let(:base_markdown_renderer) { instance_double(BaseMarkdownRenderer) }
let(:html_content) { '<p>This is a <em>test</em> content with <sup>markdown</sup></p>' }
before do
allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT, [:strikethrough]).and_return(doc)
allow(CustomMarkdownRenderer).to receive(:new).and_return(markdown_renderer)
allow(markdown_renderer).to receive(:render).with(doc).and_return(html_content)
end
@@ -64,22 +62,28 @@ RSpec.describe ChatwootMarkdownRenderer do
end
describe '#render_message' do
let(:message_html_content) { '<p>This is a <em>test</em> content with ^markdown^</p>' }
let(:rendered_message) { renderer.render_message }
before do
allow(CommonMarker).to receive(:render_html).with(markdown_content).and_return(message_html_content)
allow(BaseMarkdownRenderer).to receive(:new).and_return(base_markdown_renderer)
allow(base_markdown_renderer).to receive(:render).with(doc).and_return(message_html_content)
allow(CommonMarker).to receive(:render_doc).and_call_original
allow(BaseMarkdownRenderer).to receive(:new).and_call_original
end
it 'renders the markdown message to html' do
expect(rendered_message.to_s).to eq(message_html_content)
expect(rendered_message.to_s).to eq("<p>This is a <em>test</em> content with ^markdown^</p>\n")
end
it 'returns an html safe string' do
expect(rendered_message).to be_html_safe
end
context 'with bare URLs' do
let(:markdown_content) { 'Visit https://example.com for details' }
it 'converts bare URLs to links' do
expect(renderer.render_message.to_s).to eq("<p>Visit <a href=\"https://example.com\">https://example.com</a> for details</p>\n")
end
end
end
describe '#render_markdown_to_plain_text' do
+26
View File
@@ -184,6 +184,32 @@ describe CustomMarkdownRenderer do
end
end
context 'when link is a GuideJar embed URL' do
let(:guidejar_url) { 'https://www.guidejar.com/embed/i2qMQRp26rtRxpZczmaA' }
it 'renders an iframe with GuideJar embed code' do
output = render_markdown_link(guidejar_url)
expect(output).to include('src="https://www.guidejar.com/embed/i2qMQRp26rtRxpZczmaA?type=1&controls=on"')
expect(output).to include('allowfullscreen')
end
end
context 'when link is a GuideJar guides URL' do
let(:guidejar_url) { 'https://guidejar.com/guides/d6a6fdc2-4812-4777-897e-ec1b0c64238f' }
it 'renders an iframe with GuideJar embed code' do
output = render_markdown_link(guidejar_url)
expect(output).to include('src="https://www.guidejar.com/embed/d6a6fdc2-4812-4777-897e-ec1b0c64238f?type=1&controls=on"')
expect(output).to include('allowfullscreen')
end
it 'wraps iframe in responsive container' do
output = render_markdown_link(guidejar_url)
expect(output).to include('position: relative; padding-bottom: 62.5%; height: 0;')
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
end
end
context 'when link is a Bunny.net iframe URL' do
let(:bunny_url) { 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
@@ -23,6 +23,7 @@ RSpec.describe AccountEmailRateLimitable do
describe '#within_email_rate_limit?' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
account.update!(limits: { 'emails' => 2 })
end
@@ -34,6 +35,28 @@ RSpec.describe AccountEmailRateLimitable do
2.times { account.increment_email_sent_count }
expect(account).not_to be_within_email_rate_limit
end
context 'when self-hosted' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
2.times { account.increment_email_sent_count }
end
it 'always returns true regardless of limit' do
expect(account).to be_within_email_rate_limit
end
end
context 'when chatwoot cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
2.times { account.increment_email_sent_count }
end
it 'returns false when at limit' do
expect(account).not_to be_within_email_rate_limit
end
end
end
describe '#increment_email_sent_count' do
@@ -0,0 +1,156 @@
require 'rails_helper'
RSpec.describe WebsiteBrandingService do
describe '#perform' do
let(:url) { 'https://example.com' }
let(:html_body) do
<<~HTML
<html lang="en">
<head>
<title>Acme Corp | Home</title>
<meta property="og:site_name" content="Acme Corp" />
<meta property="og:image" content="https://example.com/og-image.png" />
<meta name="theme-color" content="#FF5733" />
<link rel="icon" href="/favicon.ico" />
</head>
<body>
<header><a href="/">Home</a></header>
<footer>
<a href="https://facebook.com/acmecorp">Facebook</a>
<a href="https://instagram.com/acme_corp">Instagram</a>
<a href="https://wa.me/1234567890">WhatsApp</a>
<a href="https://t.me/acmecorp">Telegram</a>
<a href="https://tiktok.com/@acmetok">TikTok</a>
</footer>
</body>
</html>
HTML
end
before do
stub_request(:get, url).to_return(status: 200, body: html_body, headers: { 'content-type' => 'text/html' })
end
it 'extracts business info, branding, and social handles' do
result = described_class.new(url).perform
expect(result).to eq({
business_name: 'Acme Corp',
language: 'en',
industry_category: nil,
social_handles: {
whatsapp: '1234567890',
line: nil,
facebook: 'acmecorp',
instagram: 'acme_corp',
telegram: 'acmecorp',
tiktok: '@acmetok'
},
branding: {
favicon: 'https://example.com/favicon.ico',
primary_color: '#FF5733'
}
})
end
context 'when og:site_name is missing' do
let(:html_body) do
<<~HTML
<html lang="fr">
<head><title>Mon Entreprise - Bienvenue</title></head>
<body></body>
</html>
HTML
end
it 'falls back to the first segment of the title' do
result = described_class.new(url).perform
expect(result[:business_name]).to eq('Mon Entreprise')
expect(result[:language]).to eq('fr')
end
end
context 'when the page fails to load' do
before { stub_request(:get, url).to_return(status: 500, body: '') }
it 'returns nil' do
expect(described_class.new(url).perform).to be_nil
end
end
context 'when a network error occurs' do
before { stub_request(:get, url).to_raise(StandardError.new('connection refused')) }
it 'logs the error and returns nil' do
expect(Rails.logger).to receive(:error).with(/connection refused/)
expect(described_class.new(url).perform).to be_nil
end
end
context 'when URL has no scheme' do
before do
stub_request(:get, 'https://example.com').to_return(status: 200, body: html_body, headers: { 'content-type' => 'text/html' })
end
it 'prepends https://' do
result = described_class.new('example.com').perform
expect(result[:business_name]).to eq('Acme Corp')
end
end
context 'when WhatsApp link uses api.whatsapp.com format' do
let(:html_body) do
<<~HTML
<html lang="en">
<head><title>Test</title></head>
<body><a href="https://api.whatsapp.com/send?phone=5511999999999&text=Hello">Chat</a></body>
</html>
HTML
end
it 'extracts phone from query param' do
result = described_class.new(url).perform
expect(result[:social_handles][:whatsapp]).to eq('5511999999999')
end
end
context 'when links contain lookalike domains' do
let(:html_body) do
<<~HTML
<html lang="en">
<head><title>Test</title></head>
<body>
<a href="https://notfacebook.com/page">Not FB</a>
<a href="https://fakeinstagram.com/user">Not IG</a>
</body>
</html>
HTML
end
it 'does not match lookalike domains' do
result = described_class.new(url).perform
expect(result[:social_handles][:facebook]).to be_nil
expect(result[:social_handles][:instagram]).to be_nil
end
end
context 'when favicon uses a relative path without leading slash' do
let(:html_body) do
<<~HTML
<html lang="en">
<head>
<title>Test</title>
<link rel="icon" href="favicon.ico" />
</head>
<body></body>
</html>
HTML
end
it 'resolves the relative favicon URL' do
result = described_class.new(url).perform
expect(result[:branding][:favicon]).to eq('https://example.com/favicon.ico')
end
end
end
end
@@ -1,6 +1,6 @@
tags:
- Messages API
operationId: list-all-converation-messages
operationId: list-all-conversation-messages
summary: List all messages
description: List all messages in the conversation
security: []
+1 -1
View File
@@ -1366,7 +1366,7 @@
"tags": [
"Messages API"
],
"operationId": "list-all-converation-messages",
"operationId": "list-all-conversation-messages",
"summary": "List all messages",
"description": "List all messages in the conversation",
"security": [],
+1 -1
View File
@@ -536,7 +536,7 @@
"tags": [
"Messages API"
],
"operationId": "list-all-converation-messages",
"operationId": "list-all-conversation-messages",
"summary": "List all messages",
"description": "List all messages in the conversation",
"security": [],