diff --git a/.bundler-audit.yml b/.bundler-audit.yml index 7cb453c01..908d97175 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -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 diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index 664a1964f..a8e22d878 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -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' diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb index ebfcb310a..3fb200269 100644 --- a/app/controllers/public/api/v1/portals/categories_controller.rb +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -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' diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index df4552432..a187ca8a8 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -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 diff --git a/app/controllers/public_controller.rb b/app/controllers/public_controller.rb index 3b83a2210..b266b725b 100644 --- a/app/controllers/public_controller.rb +++ b/app/controllers/public_controller.rb @@ -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 diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 8912c03d1..a706e2df5 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -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'); diff --git a/app/javascript/dashboard/assets/scss/_base.scss b/app/javascript/dashboard/assets/scss/_base.scss index 84c8a4b0f..108da3889 100644 --- a/app/javascript/dashboard/assets/scss/_base.scss +++ b/app/javascript/dashboard/assets/scss/_base.scss @@ -106,6 +106,10 @@ select { &[disabled] { @apply field-disabled; } + + option:not(:disabled) { + @apply bg-n-solid-2 text-n-slate-12; + } } // Textarea diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue index bb5f03451..30ebfc448 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue @@ -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 }) => { diff --git a/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue index 2144f6f05..e17b26999 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue @@ -316,7 +316,7 @@ onMounted(() => { v-if="bulkSelectedIds" ref="bulkDeleteDialog" :bulk-ids="bulkSelectedIds" - type="Responses" + type="AssistantResponse" @delete-success="onBulkDeleteSuccess" /> diff --git a/app/javascript/dashboard/routes/dashboard/captain/responses/Pending.vue b/app/javascript/dashboard/routes/dashboard/captain/responses/Pending.vue index b26658f9c..661b6ced7 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/responses/Pending.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/responses/Pending.vue @@ -361,7 +361,7 @@ onMounted(() => { v-if="bulkSelectedIds" ref="bulkDeleteDialog" :bulk-ids="bulkSelectedIds" - type="Responses" + type="AssistantResponse" @delete-success="onBulkDeleteSuccess" /> diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue index 5be704c24..0502ebc1b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue @@ -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')); diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Api.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Api.vue index 15054fe1e..cffac463f 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Api.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Api.vue @@ -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') + ); } }, }, diff --git a/app/javascript/dashboard/store/captain/bulkActions.js b/app/javascript/dashboard/store/captain/bulkActions.js index 42ffcd294..436801092 100644 --- a/app/javascript/dashboard/store/captain/bulkActions.js +++ b/app/javascript/dashboard/store/captain/bulkActions.js @@ -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; }, diff --git a/app/javascript/dashboard/store/captain/document.js b/app/javascript/dashboard/store/captain/document.js index f5766f827..76d0c9124 100644 --- a/app/javascript/dashboard/store/captain/document.js +++ b/app/javascript/dashboard/store/captain/document.js @@ -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); + }, + }), }); diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index 49926f378..3f374dce2 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -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) => { diff --git a/app/javascript/v3/App.vue b/app/javascript/v3/App.vue index ef7107beb..c3f9b1734 100644 --- a/app/javascript/v3/App.vue +++ b/app/javascript/v3/App.vue @@ -35,7 +35,9 @@ export default { }; }, setLocale(locale) { - this.$root.$i18n.locale = locale; + if (locale) { + this.$root.$i18n.locale = locale; + } }, }, }; diff --git a/app/javascript/widget/assets/scss/woot.scss b/app/javascript/widget/assets/scss/woot.scss index 07aa6a0e3..0044ccdfc 100755 --- a/app/javascript/widget/assets/scss/woot.scss +++ b/app/javascript/widget/assets/scss/woot.scss @@ -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 { diff --git a/app/javascript/widget/composables/useDarkMode.js b/app/javascript/widget/composables/useDarkMode.js index bc19c456b..407d90980 100644 --- a/app/javascript/widget/composables/useDarkMode.js +++ b/app/javascript/widget/composables/useDarkMode.js @@ -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, diff --git a/app/javascript/widget/views/ArticleViewer.vue b/app/javascript/widget/views/ArticleViewer.vue index 9289d0546..bc4cf775c 100644 --- a/app/javascript/widget/views/ArticleViewer.vue +++ b/app/javascript/widget/views/ArticleViewer.vue @@ -10,7 +10,7 @@ export default { diff --git a/app/jobs/inboxes/bulk_auto_assignment_job.rb b/app/jobs/inboxes/bulk_auto_assignment_job.rb deleted file mode 100644 index 9e808648b..000000000 --- a/app/jobs/inboxes/bulk_auto_assignment_job.rb +++ /dev/null @@ -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 diff --git a/app/models/concerns/account_email_rate_limitable.rb b/app/models/concerns/account_email_rate_limitable.rb index 806906fe0..5f69e22ee 100644 --- a/app/models/concerns/account_email_rate_limitable.rb +++ b/app/models/concerns/account_email_rate_limitable.rb @@ -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}") diff --git a/app/services/concerns/social_link_parser.rb b/app/services/concerns/social_link_parser.rb new file mode 100644 index 000000000..fa0cfca06 --- /dev/null +++ b/app/services/concerns/social_link_parser.rb @@ -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 diff --git a/app/services/website_branding_service.rb b/app/services/website_branding_service.rb new file mode 100644 index 000000000..89326e4b6 --- /dev/null +++ b/app/services/website_branding_service.rb @@ -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') diff --git a/app/views/layouts/portal.html.erb b/app/views/layouts/portal.html.erb index 78418881a..52d8e2789 100644 --- a/app/views/layouts/portal.html.erb +++ b/app/views/layouts/portal.html.erb @@ -58,9 +58,9 @@ By default, it renders: } - +
-
+
<% if !@is_plain_layout_enabled %> <%= render "public/api/v1/portals/header", portal: @portal %> <% end %> diff --git a/app/views/public/api/v1/portals/not_active.html.erb b/app/views/public/api/v1/portals/not_active.html.erb new file mode 100644 index 000000000..af3ecb43f --- /dev/null +++ b/app/views/public/api/v1/portals/not_active.html.erb @@ -0,0 +1,12 @@ +
+
+
+ i +
+
+

<%= I18n.t('public_portal.not_active.title') %>

+

<%= I18n.t('public_portal.not_active.description') %>

+
+

<%= I18n.t('public_portal.not_active.action') %>

+
+
diff --git a/config/features.yml b/config/features.yml index 41515ff64..00f9321b8 100644 --- a/config/features.yml +++ b/config/features.yml @@ -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 diff --git a/config/initializers/languages.rb b/config/initializers/languages.rb index c34f7a605..183a0e54e 100644 --- a/config/initializers/languages.rb +++ b/config/initializers/languages.rb @@ -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 } diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 9511ae68e..7b78b466a 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index b0f1f4e2e..12e76ae37 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/markdown_embeds.yml b/config/markdown_embeds.yml index b826931ec..cc5b997d6 100644 --- a/config/markdown_embeds.yml +++ b/config/markdown_embeds.yml @@ -134,6 +134,18 @@ codepen:
+guidejar: + regex: 'https?://(?:www\.)?guidejar\.com/(?:embed|guides)/(?[^&/?]+)' + template: | +
+ +
+ github_gist: regex: 'https?://gist\.github\.com/(?[^/]+)/(?[a-f0-9]+)' template: | diff --git a/config/schedule.yml b/config/schedule.yml index 153724c25..f1054ad68 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -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 * * * *' diff --git a/db/migrate/20260324102005_repurpose_response_bot_flag_for_custom_tools.rb b/db/migrate/20260324102005_repurpose_response_bot_flag_for_custom_tools.rb new file mode 100644 index 000000000..d6a3199b4 --- /dev/null +++ b/db/migrate/20260324102005_repurpose_response_bot_flag_for_custom_tools.rb @@ -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 diff --git a/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb index 3130a68cc..e7d02a887 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb @@ -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 diff --git a/enterprise/app/models/enterprise/inbox.rb b/enterprise/app/models/enterprise/inbox.rb index 2462122f7..3e156b316 100644 --- a/enterprise/app/models/enterprise/inbox.rb +++ b/enterprise/app/models/enterprise/inbox.rb @@ -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) : [] diff --git a/enterprise/app/models/enterprise/inbox_agent_availability.rb b/enterprise/app/models/enterprise/inbox_agent_availability.rb index 886b55278..68bae03e9 100644 --- a/enterprise/app/models/enterprise/inbox_agent_availability.rb +++ b/enterprise/app/models/enterprise/inbox_agent_availability.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb index e4df1050b..ac1f74860 100644 --- a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb +++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index d3c5b15db..f69edeb45 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb new file mode 100644 index 000000000..953ef0326 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb index e11269110..d0ec3e372 100644 --- a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb +++ b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb @@ -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) diff --git a/enterprise/app/services/enterprise/website_branding_service.rb b/enterprise/app/services/enterprise/website_branding_service.rb new file mode 100644 index 000000000..6efdd5051 --- /dev/null +++ b/enterprise/app/services/enterprise/website_branding_service.rb @@ -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 diff --git a/enterprise/app/services/internal/accounts/internal_attributes_service.rb b/enterprise/app/services/internal/accounts/internal_attributes_service.rb index 7bc69d0a4..116d0a3fc 100644 --- a/enterprise/app/services/internal/accounts/internal_attributes_service.rb +++ b/enterprise/app/services/internal/accounts/internal_attributes_service.rb @@ -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 diff --git a/enterprise/lib/captain/prompts/conversation_completion.liquid b/enterprise/lib/captain/prompts/conversation_completion.liquid index f6f8cd58a..ed81039af 100644 --- a/enterprise/lib/captain/prompts/conversation_completion.liquid +++ b/enterprise/lib/captain/prompts/conversation_completion.liquid @@ -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 diff --git a/lib/chatwoot_markdown_renderer.rb b/lib/chatwoot_markdown_renderer.rb index 50d1755ee..c7adac30a 100644 --- a/lib/chatwoot_markdown_renderer.rb +++ b/lib/chatwoot_markdown_renderer.rb @@ -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 diff --git a/package.json b/package.json index 5bde17603..c8a1b7fdf 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.3.7", + "@chatwoot/prosemirror-schema": "1.3.8", "@chatwoot/utils": "^0.0.52", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f76cd5e2..48edce442 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,8 +26,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.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 diff --git a/spec/config/markdown_embeds_spec.rb b/spec/config/markdown_embeds_spec.rb index f609ac113..e9938aea0 100644 --- a/spec/config/markdown_embeds_spec.rb +++ b/spec/config/markdown_embeds_spec.rb @@ -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' } }, diff --git a/spec/controllers/public/api/v1/portals_controller_spec.rb b/spec/controllers/public/api/v1/portals_controller_spec.rb index c5fa94cff..3dd218c8e 100644 --- a/spec/controllers/public/api/v1/portals_controller_spec.rb +++ b/spec/controllers/public/api/v1/portals_controller_spec.rb @@ -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" diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb index 968649085..eaecdd89e 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb @@ -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 { diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb index 9089b089a..b2a920b07 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb @@ -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 diff --git a/spec/enterprise/controllers/enterprise/public/api/v1/helpcenter_plan_access_spec.rb b/spec/enterprise/controllers/enterprise/public/api/v1/helpcenter_plan_access_spec.rb new file mode 100644 index 000000000..ecba226d7 --- /dev/null +++ b/spec/enterprise/controllers/enterprise/public/api/v1/helpcenter_plan_access_spec.rb @@ -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 diff --git a/spec/enterprise/models/inbox_spec.rb b/spec/enterprise/models/inbox_spec.rb index 4ba1a7021..cfcbdd573 100644 --- a/spec/enterprise/models/inbox_spec.rb +++ b/spec/enterprise/models/inbox_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb index f5b0bbe86..0dd8189c4 100644 --- a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb index 24c7889de..fa4c052a1 100644 --- a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/website_branding_service_spec.rb b/spec/enterprise/services/enterprise/website_branding_service_spec.rb new file mode 100644 index 000000000..0907db518 --- /dev/null +++ b/spec/enterprise/services/enterprise/website_branding_service_spec.rb @@ -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) { 'Fallback' } + 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 diff --git a/spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb b/spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb deleted file mode 100644 index 5e7e3d7cc..000000000 --- a/spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb +++ /dev/null @@ -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 diff --git a/spec/lib/chatwoot_markdown_renderer_spec.rb b/spec/lib/chatwoot_markdown_renderer_spec.rb index 5b66ed869..da2a50316 100644 --- a/spec/lib/chatwoot_markdown_renderer_spec.rb +++ b/spec/lib/chatwoot_markdown_renderer_spec.rb @@ -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) { '

This is a test content with markdown

' } 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) { '

This is a test content with ^markdown^

' } 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("

This is a test content with ^markdown^

\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("

Visit https://example.com for details

\n") + end + end end describe '#render_markdown_to_plain_text' do diff --git a/spec/lib/custom_markdown_renderer_spec.rb b/spec/lib/custom_markdown_renderer_spec.rb index 1237eae2c..3415a811d 100644 --- a/spec/lib/custom_markdown_renderer_spec.rb +++ b/spec/lib/custom_markdown_renderer_spec.rb @@ -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' } diff --git a/spec/models/concerns/account_email_rate_limitable_spec.rb b/spec/models/concerns/account_email_rate_limitable_spec.rb index 919c5f621..fb9a86144 100644 --- a/spec/models/concerns/account_email_rate_limitable_spec.rb +++ b/spec/models/concerns/account_email_rate_limitable_spec.rb @@ -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 diff --git a/spec/services/website_branding_service_spec.rb b/spec/services/website_branding_service_spec.rb new file mode 100644 index 000000000..19598fb59 --- /dev/null +++ b/spec/services/website_branding_service_spec.rb @@ -0,0 +1,156 @@ +require 'rails_helper' + +RSpec.describe WebsiteBrandingService do + describe '#perform' do + let(:url) { 'https://example.com' } + let(:html_body) do + <<~HTML + + + Acme Corp | Home + + + + + + +
Home
+ + + + 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 + + Mon Entreprise - Bienvenue + + + 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 + + Test + Chat + + 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 + + Test + + Not FB + Not IG + + + 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 + + + Test + + + + + 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 diff --git a/swagger/paths/public/inboxes/messages/index.yml b/swagger/paths/public/inboxes/messages/index.yml index 68e34fed7..4477d66c3 100644 --- a/swagger/paths/public/inboxes/messages/index.yml +++ b/swagger/paths/public/inboxes/messages/index.yml @@ -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: [] diff --git a/swagger/swagger.json b/swagger/swagger.json index 3a9c39728..94d1f04d3 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -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": [], diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json index f95b1bed3..763e090b1 100644 --- a/swagger/tag_groups/client_swagger.json +++ b/swagger/tag_groups/client_swagger.json @@ -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": [],