+
diff --git a/app/services/line/send_on_line_service.rb b/app/services/line/send_on_line_service.rb
index b0b6d828d..3c9d6cf17 100644
--- a/app/services/line/send_on_line_service.rb
+++ b/app/services/line/send_on_line_service.rb
@@ -44,10 +44,15 @@ class Line::SendOnLineService < Base::SendOnChannelService
# Support only image and video for now, https://developers.line.biz/en/reference/messaging-api/#image-message
next unless attachment.file_type == 'image' || attachment.file_type == 'video'
+ # Use file_url (permanent redirect-based URL) instead of download_url (signed URL that expires in 5 minutes).
+ # LINE mobile app lazy-loads images and may fetch them well after the message is sent.
+ original_url = attachment.file_url
+ preview_url = attachment.thumb_url.presence || original_url
+
{
type: attachment.file_type,
- originalContentUrl: attachment.download_url,
- previewImageUrl: attachment.download_url
+ originalContentUrl: original_url,
+ previewImageUrl: preview_url
}
end
end
diff --git a/app/services/notification/push_notification_service.rb b/app/services/notification/push_notification_service.rb
index 125ad9113..90f835ecb 100644
--- a/app/services/notification/push_notification_service.rb
+++ b/app/services/notification/push_notification_service.rb
@@ -79,7 +79,7 @@ class Notification::PushNotificationService
subscription.destroy!
when WebPush::TooManyRequests
Rails.logger.warn "WebPush rate limited for #{user.email} on account #{notification.account.id}: #{error.message}"
- when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout
+ when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, Socket::ResolutionError
Rails.logger.error "WebPush operation error: #{error.message}"
else
ChatwootExceptionTracker.new(error, account: notification.account).capture_exception
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/config/initializers/sentry.rb b/config/initializers/sentry.rb
index ae21d7f61..eff36bfc5 100644
--- a/config/initializers/sentry.rb
+++ b/config/initializers/sentry.rb
@@ -7,7 +7,7 @@ if ENV['SENTRY_DSN'].present?
# We recommend adjusting the value in production:
config.traces_sample_rate = 0.1 if ENV['ENABLE_SENTRY_TRANSACTIONS']
- config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException']
+ config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException', 'MutexApplicationJob::LockAcquisitionError']
# to track post data in sentry
config.send_default_pii = true unless ENV['DISABLE_SENTRY_PII']
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 12e76ae37..e6308c43c 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -387,6 +387,7 @@ en:
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
custom_tool:
slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
+ limit_exceeded: 'You can create a maximum of %{limit} custom tools per account'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/routes.rb b/config/routes.rb
index 9d442600e..58c574efe 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -72,7 +72,9 @@ Rails.application.routes.draw do
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
end
- resources :custom_tools
+ resources :custom_tools do
+ post :test, on: :collection
+ end
resources :documents, only: [:index, :show, :create, :destroy]
resource :tasks, only: [], controller: 'tasks' do
post :rewrite
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
index 3137ded09..fab952960 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
@@ -1,16 +1,19 @@
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
before_action :current_account
+ before_action :ensure_custom_tools_enabled
before_action -> { check_authorization(Captain::CustomTool) }
before_action :set_custom_tool, only: [:show, :update, :destroy]
def index
- @custom_tools = account_custom_tools.enabled
+ @custom_tools = account_custom_tools
end
def show; end
def create
@custom_tool = account_custom_tools.create!(custom_tool_params)
+ rescue Captain::CustomTool::LimitExceededError => e
+ render_could_not_create_error(e.message)
end
def update
@@ -22,8 +25,22 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
head :no_content
end
+ def test
+ tool = account_custom_tools.new(custom_tool_params)
+ result = execute_test_request(tool)
+ render json: { status: result.code.to_i, body: result.body.to_s.truncate(500) }
+ rescue StandardError => e
+ render json: { error: e.message }, status: :unprocessable_content
+ end
+
private
+ def ensure_custom_tools_enabled
+ return if Current.account.feature_enabled?('custom_tools') || Current.account.feature_enabled?('captain_integration_v2')
+
+ render json: { error: 'Custom tools are not enabled for this account' }, status: :forbidden
+ end
+
def set_custom_tool
@custom_tool = account_custom_tools.find(params[:id])
end
@@ -32,6 +49,11 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
@account_custom_tools ||= Current.account.captain_custom_tools
end
+ def execute_test_request(tool)
+ http_tool = Captain::Tools::HttpTool.new(nil, tool)
+ http_tool.send(:execute_http_request, tool.endpoint_url, nil, nil)
+ end
+
def custom_tool_params
params.require(:custom_tool).permit(
:title,
diff --git a/enterprise/app/models/captain/custom_tool.rb b/enterprise/app/models/captain/custom_tool.rb
index bf3f351dd..27d54d308 100644
--- a/enterprise/app/models/captain/custom_tool.rb
+++ b/enterprise/app/models/captain/custom_tool.rb
@@ -24,6 +24,10 @@
# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE
#
class Captain::CustomTool < ApplicationRecord
+ class LimitExceededError < StandardError; end
+
+ MAX_PER_ACCOUNT = 15
+
include Concerns::Toolable
include Concerns::SafeEndpointValidatable
@@ -31,6 +35,10 @@ class Captain::CustomTool < ApplicationRecord
NAME_PREFIX = 'custom'.freeze
NAME_SEPARATOR = '_'.freeze
+ # OpenAI enforces a 64-char limit on function names. The slug is used
+ # verbatim as the tool name in LLM requests, so it must fit within this limit.
+ MAX_SLUG_LENGTH = 64
+ COLLISION_SUFFIX_LENGTH = 7 # "_" + 6 random alphanumeric chars
PARAM_SCHEMA_VALIDATION = {
'type': 'array',
'items': {
@@ -52,8 +60,9 @@ class Captain::CustomTool < ApplicationRecord
enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth
before_validation :generate_slug
+ before_create :ensure_within_limit
- validates :slug, presence: true, uniqueness: { scope: :account_id }
+ validates :slug, presence: true, uniqueness: { scope: :account_id }, length: { maximum: MAX_SLUG_LENGTH }
validates :title, presence: true
validates :endpoint_url, presence: true
validates_with JsonSchemaValidator,
@@ -73,21 +82,29 @@ class Captain::CustomTool < ApplicationRecord
private
+ def ensure_within_limit
+ # Lock the account row to serialize concurrent creates and prevent exceeding the cap
+ Account.lock.find(account_id)
+ return if account.captain_custom_tools.count < MAX_PER_ACCOUNT
+
+ raise LimitExceededError, I18n.t('captain.custom_tool.limit_exceeded', limit: MAX_PER_ACCOUNT)
+ end
+
def generate_slug
return if slug.present?
return if title.blank?
- paramterized_title = title.parameterize(separator: NAME_SEPARATOR)
-
- base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}"
+ parameterized_title = title.parameterize(separator: NAME_SEPARATOR)
+ base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{parameterized_title}".truncate(MAX_SLUG_LENGTH, omission: '')
self.slug = find_unique_slug(base_slug)
end
def find_unique_slug(base_slug)
return base_slug unless slug_exists?(base_slug)
+ truncated = base_slug.truncate(MAX_SLUG_LENGTH - COLLISION_SUFFIX_LENGTH, omission: '')
5.times do
- slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
+ slug_candidate = "#{truncated}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
return slug_candidate unless slug_exists?(slug_candidate)
end
diff --git a/enterprise/app/models/concerns/toolable.rb b/enterprise/app/models/concerns/toolable.rb
index f40ac4a65..828cd50c5 100644
--- a/enterprise/app/models/concerns/toolable.rb
+++ b/enterprise/app/models/concerns/toolable.rb
@@ -1,15 +1,23 @@
module Concerns::Toolable
extend ActiveSupport::Concern
- def tool(assistant)
+ # Isolated namespace for user-defined custom tool classes.
+ # Keeps them separate from built-in classes in Captain::Tools (e.g., HttpTool, CustomHttpTool).
+ module CustomTools; end
+
+ def tool(assistant, base_class: Captain::Tools::HttpTool, **)
custom_tool_record = self
- # Convert slug to valid Ruby constant name (replace hyphens with underscores, then camelize)
class_name = custom_tool_record.slug.underscore.camelize
# Always create a fresh class to reflect current metadata
- tool_class = Class.new(Captain::Tools::HttpTool) do
+ tool_slug = custom_tool_record.slug
+ tool_class = Class.new(base_class) do
description custom_tool_record.description
+ # Override name to use the slug directly, avoiding the namespace prefix
+ # that RubyLLM's default normalization would produce (e.g., "captain--tools--custom_dog_facts").
+ define_method(:name) { tool_slug }
+
custom_tool_record.param_schema.each do |param_def|
param param_def['name'].to_sym,
type: param_def['type'],
@@ -18,17 +26,14 @@ module Concerns::Toolable
end
end
- # Register the dynamically created class as a constant in the Captain::Tools namespace.
- # This is required because RubyLLM's Tool base class derives the tool name from the class name
- # (via Class#name). Anonymous classes created with Class.new have no name and return empty strings,
- # which causes "Invalid 'tools[].function.name': empty string" errors from the LLM API.
- # By setting it as a constant, the class gets a proper name (e.g., "Captain::Tools::CatFactLookup")
- # which RubyLLM extracts and normalizes to "cat-fact-lookup" for the LLM API.
- # We refresh the constant on each call to ensure tool metadata changes are reflected.
- Captain::Tools.send(:remove_const, class_name) if Captain::Tools.const_defined?(class_name, false)
- Captain::Tools.const_set(class_name, tool_class)
+ # Register as a constant so the class gets a proper name (Class#name).
+ # Anonymous classes return nil for #name, which causes "Invalid 'tools[].function.name':
+ # empty string" errors from the LLM API. We use CustomTools as the namespace to avoid
+ # collisions with real classes in Captain::Tools.
+ CustomTools.send(:remove_const, class_name) if CustomTools.const_defined?(class_name, false)
+ CustomTools.const_set(class_name, tool_class)
- tool_class.new(assistant, self)
+ tool_class.new(assistant, self, **)
end
def build_request_url(params)
diff --git a/enterprise/app/policies/captain/custom_tool_policy.rb b/enterprise/app/policies/captain/custom_tool_policy.rb
index b88a23860..297ecbb99 100644
--- a/enterprise/app/policies/captain/custom_tool_policy.rb
+++ b/enterprise/app/policies/captain/custom_tool_policy.rb
@@ -11,6 +11,10 @@ class Captain::CustomToolPolicy < ApplicationPolicy
@account_user.administrator?
end
+ def test?
+ @account_user.administrator?
+ end
+
def update?
@account_user.administrator?
end
diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb
index 57bbe0c96..2dba3af16 100644
--- a/enterprise/app/services/captain/llm/assistant_chat_service.rb
+++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb
@@ -30,7 +30,12 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
private
def build_tools
- [Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
+ tools = [Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
+ return tools unless custom_tools_enabled?
+
+ tools + @assistant.account.captain_custom_tools.enabled.map do |ct|
+ ct.tool(@assistant, base_class: Captain::Tools::CustomHttpTool, conversation: @conversation)
+ end
end
def system_message
@@ -38,11 +43,24 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
role: 'system',
content: Captain::Llm::SystemPromptsService.assistant_response_generator(
@assistant.name, @assistant.config['product_name'], @assistant.config,
- contact: contact_attributes
+ contact: contact_attributes,
+ custom_tools: custom_tools_metadata
)
}
end
+ def custom_tools_metadata
+ return [] unless custom_tools_enabled?
+
+ @assistant.account.captain_custom_tools.enabled.map do |ct|
+ { name: ct.slug, description: ct.description }
+ end
+ end
+
+ def custom_tools_enabled?
+ @assistant.account.feature_enabled?('custom_tools')
+ end
+
def contact_attributes
return nil unless @conversation&.contact
return nil unless @assistant&.feature_contact_attributes
diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb
index 69db203ac..9868f0360 100644
--- a/enterprise/app/services/captain/llm/system_prompts_service.rb
+++ b/enterprise/app/services/captain/llm/system_prompts_service.rb
@@ -152,7 +152,7 @@ class Captain::Llm::SystemPromptsService
# rubocop:enable Metrics/MethodLength
# rubocop:disable Metrics/MethodLength
- def assistant_response_generator(assistant_name, product_name, config = {}, contact: nil)
+ def assistant_response_generator(assistant_name, product_name, config = {}, contact: nil, custom_tools: [])
assistant_citation_guidelines = if config['feature_citation']
<<~CITATION_TEXT
- Always include citations for any information provided, referencing the specific source (document only - skip if it was derived from a conversation).
@@ -187,7 +187,7 @@ class Captain::Llm::SystemPromptsService
#{assistant_citation_guidelines}
#{build_contact_context(contact)}[Task]
- Start by introducing yourself. Then, ask the user to share their question. When they answer, call the search_documentation function. Give a helpful response based on the steps written below.
+ Start by introducing yourself. Then, ask the user to share their question. When they answer, use the most appropriate tool to find information. Give a helpful response based on the steps written below.
- Provide the user with the steps required to complete the action one by one.
- Do not return list numbers in the steps, just the plain text is enough.
@@ -203,6 +203,8 @@ class Captain::Llm::SystemPromptsService
```
- If the answer is not provided in context sections, Respond to the customer and ask whether they want to talk to another support agent . If they ask to Chat with another agent, return `conversation_handoff' as the response in JSON response
#{'- You MUST provide numbered citations at the appropriate places in the text.' if config['feature_citation']}
+
+ #{build_tools_section(custom_tools)}
SYSTEM_PROMPT_MESSAGE
end
@@ -291,6 +293,15 @@ class Captain::Llm::SystemPromptsService
private
+ def build_tools_section(custom_tools)
+ tools_list = custom_tools.map { |t| "- #{t[:name]}: #{t[:description]}" }.join("\n")
+ <<~TOOLS.strip
+ [Available Tools]
+ - search_documentation: Search and retrieve documentation from knowledge base
+ #{tools_list}
+ TOOLS
+ end
+
def build_contact_context(contact)
return '' if contact.nil?
diff --git a/enterprise/app/services/captain/tools/custom_http_tool.rb b/enterprise/app/services/captain/tools/custom_http_tool.rb
new file mode 100644
index 000000000..45e5245bf
--- /dev/null
+++ b/enterprise/app/services/captain/tools/custom_http_tool.rb
@@ -0,0 +1,47 @@
+# V1-compatible wrapper for custom HTTP tools.
+#
+# V2's HttpTool inherits from Agents::Tool which overrides execute(tool_context, **params),
+# making it incompatible with V1's RubyLLM pipeline that calls execute(**keyword_args).
+#
+# This class bridges the gap: it inherits from BaseTool (RubyLLM::Tool) for V1 compatibility
+# and delegates the actual HTTP execution to HttpTool#perform.
+class Captain::Tools::CustomHttpTool < Captain::Tools::BaseTool
+ # BaseTool prepends Instrumentation, but our execute() shadows it in the MRO.
+ # Re-prepend so Langfuse captures tool call input/output/timing.
+ prepend Captain::Tools::Instrumentation
+
+ attr_reader :custom_tool
+
+ def initialize(assistant, custom_tool, conversation: nil)
+ @custom_tool = custom_tool
+ @conversation = conversation
+ super(assistant)
+ end
+
+ def active?
+ @custom_tool.enabled?
+ end
+
+ def execute(**params)
+ http_tool = Captain::Tools::HttpTool.new(assistant, @custom_tool)
+ http_tool.perform(build_tool_context, **params)
+ end
+
+ private
+
+ def build_tool_context
+ state = { account_id: assistant.account_id, assistant_id: assistant.id }
+ add_conversation_state(state) if @conversation
+ OpenStruct.new(state: state)
+ end
+
+ def add_conversation_state(state)
+ state[:conversation] = { id: @conversation.id, display_id: @conversation.display_id }
+ state[:contact] = slice_record_attrs(@conversation.contact, :id, :email, :phone_number)
+ state[:contact_inbox] = slice_record_attrs(@conversation.contact_inbox, :id, :hmac_verified)
+ end
+
+ def slice_record_attrs(record, *keys)
+ record&.attributes&.symbolize_keys&.slice(*keys)
+ end
+end
diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
index 953ef0326..932cee661 100644
--- a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
+++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
@@ -17,7 +17,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
linear_integration
].freeze
- BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze
+ BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze
diff --git a/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder
index 778b30061..ba9d8e3eb 100644
--- a/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder
+++ b/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder
@@ -7,7 +7,7 @@ json.http_method custom_tool.http_method
json.request_template custom_tool.request_template
json.response_template custom_tool.response_template
json.auth_type custom_tool.auth_type
-json.auth_config custom_tool.auth_config
+json.auth_config custom_tool.auth_config if Current.user&.administrator?
json.param_schema custom_tool.param_schema
json.enabled custom_tool.enabled
json.account_id custom_tool.account_id
diff --git a/package.json b/package.json
index c8a1b7fdf..ddb6c09cc 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.8",
+ "@chatwoot/prosemirror-schema": "1.3.9",
"@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 48edce442..cb4b2b148 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.8
- version: 1.3.8
+ specifier: 1.3.9
+ version: 1.3.9
'@chatwoot/utils':
specifier: ^0.0.52
version: 0.0.52
@@ -454,8 +454,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.3.8':
- resolution: {integrity: sha512-Vr8eUdydmVr7iRnNky4jXKX3XD4z5HAS4bV7zJXxA4av4ig5qjTldDOg7c/C8rqYNKGR5UEOEu9CQfGcjfKVXg==}
+ '@chatwoot/prosemirror-schema@1.3.9':
+ resolution: {integrity: sha512-nbzvW4Rfe7EC+tHF/wWJK5pIxRzfQj/DDAtZI7pwM9uJfv9yQz6bAUCA7kz7Vq1NF29XOisZaT5W0005ygk1pg==}
'@chatwoot/utils@0.0.52':
resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
@@ -4966,7 +4966,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.3.8':
+ '@chatwoot/prosemirror-schema@1.3.9':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
diff --git a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb
index 603458a01..35bae8e0b 100644
--- a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb
+++ b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb
@@ -164,5 +164,21 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
expect(response).to have_http_status(:ok)
end
end
+
+ it 'resets password for an unconfirmed persisted user on OAuth login' do
+ with_modified_env FRONTEND_URL: 'http://www.example.com' do
+ user = create(:user, email: 'unconfirmed-oauth@example.com', skip_confirmation: false)
+ original_password_digest = user.encrypted_password
+ set_omniauth_config('unconfirmed-oauth@example.com')
+
+ get '/omniauth/google_oauth2/callback'
+ expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
+ follow_redirect!
+
+ user.reload
+ expect(user).to be_confirmed
+ expect(user.encrypted_password).not_to eq(original_password_digest)
+ end
+ end
end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb
index 7a1526995..8f4e406f1 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb
@@ -5,6 +5,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
+ before { account.enable_features!('custom_tools') }
+
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -40,7 +42,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do
expect(json_response[:payload].length).to eq(5)
end
- it 'returns only enabled custom tools' do
+ it 'returns all custom tools including disabled' do
create(:captain_custom_tool, account: account, enabled: true)
create(:captain_custom_tool, account: account, enabled: false)
get "/api/v1/accounts/#{account.id}/captain/custom_tools",
@@ -48,8 +50,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do
as: :json
expect(response).to have_http_status(:success)
- expect(json_response[:payload].length).to eq(1)
- expect(json_response[:payload].first[:enabled]).to be(true)
+ expect(json_response[:payload].length).to eq(2)
end
end
end
diff --git a/spec/services/line/send_on_line_service_spec.rb b/spec/services/line/send_on_line_service_spec.rb
index a7520b8d8..4451a53b9 100644
--- a/spec/services/line/send_on_line_service_spec.rb
+++ b/spec/services/line/send_on_line_service_spec.rb
@@ -161,7 +161,9 @@ describe Line::SendOnLineService do
it 'sends the message with text and attachments' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
- expected_url_regex = %r{rails/active_storage/disk/[a-zA-Z0-9=_\-+]+/avatar\.png}
+ attachment.save!
+ expected_original_url_regex = %r{rails/active_storage/blobs/redirect/[a-zA-Z0-9=_\-+]+/avatar\.png}
+ expected_preview_url_regex = %r{rails/active_storage/representations/redirect/[a-zA-Z0-9=_\-+]+/[a-zA-Z0-9=_\-+]+/avatar\.png}
expect(line_client).to receive(:push_message).with(
message.conversation.contact_inbox.source_id,
@@ -169,8 +171,8 @@ describe Line::SendOnLineService do
{ type: 'text', text: message.content },
{
type: 'image',
- originalContentUrl: match(expected_url_regex),
- previewImageUrl: match(expected_url_regex)
+ originalContentUrl: match(expected_original_url_regex),
+ previewImageUrl: match(expected_preview_url_regex)
}
]
)
@@ -181,16 +183,18 @@ describe Line::SendOnLineService do
it 'sends the message with attachments only' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
+ attachment.save!
message.update!(content: nil)
- expected_url_regex = %r{rails/active_storage/disk/[a-zA-Z0-9=_\-+]+/avatar\.png}
+ expected_original_url_regex = %r{rails/active_storage/blobs/redirect/[a-zA-Z0-9=_\-+]+/avatar\.png}
+ expected_preview_url_regex = %r{rails/active_storage/representations/redirect/[a-zA-Z0-9=_\-+]+/[a-zA-Z0-9=_\-+]+/avatar\.png}
expect(line_client).to receive(:push_message).with(
message.conversation.contact_inbox.source_id,
[
{
type: 'image',
- originalContentUrl: match(expected_url_regex),
- previewImageUrl: match(expected_url_regex)
+ originalContentUrl: match(expected_original_url_regex),
+ previewImageUrl: match(expected_preview_url_regex)
}
]
)