chore: shopify tool calling

This commit is contained in:
Muhsin
2026-02-28 17:47:37 +04:00
parent 7f5d9f0f76
commit db96d0a34c
19 changed files with 1730 additions and 6 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
module Shopify::IntegrationHelper
REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments].freeze
REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments read_products].freeze
# Generates a signed JWT token for Shopify integration
#
@@ -0,0 +1,83 @@
class Integrations::Shopify::ClientService
include Shopify::IntegrationHelper
API_VERSION = '2025-01'.freeze
def initialize(account:)
@account = account
end
# rubocop:disable Metrics/AbcSize
def fetch_client
Rails.logger.info("[Integrations::Shopify::ClientService] fetch_client account_id=#{@account.id}")
unless hook
Rails.logger.warn("[Integrations::Shopify::ClientService] not_connected account_id=#{@account.id}")
return failure(:not_connected, 'Shopify integration is not connected.')
end
setup_shopify_context
Rails.logger.info(
"[Integrations::Shopify::ClientService] connected account_id=#{@account.id} " \
"shop=#{hook.reference_id} scopes=#{granted_scopes.join(',')}"
)
success(
client: ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session),
hook: hook,
scopes: granted_scopes
)
rescue StandardError => e
Rails.logger.error("[Integrations::Shopify::ClientService] #{e.class}: #{e.message}")
failure(:provider_error, 'Unable to communicate with Shopify.')
end
# rubocop:enable Metrics/AbcSize
def scopes_include?(*required_scopes)
required_scopes.flatten.all? { |scope| granted_scopes.include?(scope) }
end
def granted_scopes
parse_scopes(scope_value)
end
private
def hook
@hook ||= Integrations::Hook.find_by(account: @account, app_id: 'shopify', status: :enabled)
end
def setup_shopify_context
raise 'Shopify client credentials are missing.' if client_id.blank? || client_secret.blank?
ShopifyAPI::Context.setup(
api_key: client_id,
api_secret_key: client_secret,
api_version: API_VERSION,
scope: REQUIRED_SCOPES.join(','),
is_embedded: true,
is_private: false
)
end
def shopify_session
ShopifyAPI::Auth::Session.new(shop: hook.reference_id, access_token: hook.access_token)
end
def scope_value
settings = hook&.settings || {}
settings['scope'] || settings[:scope]
end
def parse_scopes(raw_scope)
raw_scope.to_s.split(',').map(&:strip).reject(&:blank?).uniq
end
def success(data)
{ ok: true, data: data }
end
def failure(code, message)
{ ok: false, error: { code: code, message: message } }
end
end
@@ -0,0 +1,162 @@
class Integrations::Shopify::OrdersService
REQUIRED_SCOPES = %w[read_customers read_orders].freeze
MAX_LIMIT = 10
MAX_LINE_ITEMS = 5
def initialize(account:)
@account = account
@client_service = Integrations::Shopify::ClientService.new(account: account)
end
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
def orders_for_contact(email:, phone_number:, limit: MAX_LIMIT)
Rails.logger.info(
"[Integrations::Shopify::OrdersService] orders_for_contact account_id=#{@account.id} " \
"email_present=#{email.present?} phone_present=#{phone_number.present?} limit=#{limit}"
)
identifiers = normalized_identifiers(email, phone_number)
return missing_identifier_result if identifiers[:email].blank? && identifiers[:phone_number].blank?
client_result = @client_service.fetch_client
return client_result unless client_result[:ok]
unless orders_scopes_granted?
Rails.logger.warn(
"[Integrations::Shopify::OrdersService] missing_scope account_id=#{@account.id} " \
"required=#{REQUIRED_SCOPES.join(',')} granted=#{@client_service.granted_scopes.join(',')}"
)
return insufficient_scope_result
end
customers = fetch_customers(client_result[:data][:client], identifiers[:email], identifiers[:phone_number])
Rails.logger.info(
"[Integrations::Shopify::OrdersService] customer_lookup account_id=#{@account.id} " \
"customers_count=#{customers.length}"
)
return no_customer_result if customers.empty?
orders = fetch_orders(client_result[:data][:client], customers.first['id'], normalized_limit(limit))
Rails.logger.info(
"[Integrations::Shopify::OrdersService] orders_lookup account_id=#{@account.id} " \
"customer_id=#{customers.first['id']} orders_count=#{orders.length}"
)
return failure(:no_results, 'No orders found for the customer.') if orders.empty?
success(
orders: orders.map { |order| normalize_order(order, client_result[:data][:hook].reference_id) }
)
rescue ShopifyAPI::Errors::HttpResponseError => e
Rails.logger.error("[Integrations::Shopify::OrdersService] Shopify error: #{e.message}")
failure(:provider_error, 'Shopify order lookup failed.')
rescue StandardError => e
Rails.logger.error("[Integrations::Shopify::OrdersService] #{e.class}: #{e.message}")
failure(:provider_error, 'Shopify order lookup failed.')
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
private
def normalized_identifiers(email, phone_number)
{
email: email.to_s.strip,
phone_number: phone_number.to_s.strip
}
end
def missing_identifier_result
Rails.logger.info("[Integrations::Shopify::OrdersService] missing_identifier account_id=#{@account.id}")
failure(:missing_identifier, 'A contact email or phone number is required.')
end
def no_customer_result
Rails.logger.info("[Integrations::Shopify::OrdersService] no_customer_result account_id=#{@account.id}")
failure(:no_results, 'No matching Shopify customer found.')
end
def orders_scopes_granted?
@client_service.scopes_include?(REQUIRED_SCOPES)
end
def insufficient_scope_result
failure(
:insufficient_scope,
'Shopify integration is missing customer/order read scopes.'
)
end
def fetch_customers(client, email, phone_number)
query_parts = []
query_parts << "email:#{email}" if email.present?
query_parts << "phone:#{phone_number}" if phone_number.present?
response = client.get(
path: 'customers/search.json',
query: {
query: query_parts.join(' OR '),
fields: 'id,email,phone'
}
)
response.body['customers'] || []
end
def fetch_orders(client, customer_id, limit)
response = client.get(
path: 'orders.json',
query: {
customer_id: customer_id,
status: 'any',
limit: limit,
fields: 'id,name,created_at,total_price,currency,fulfillment_status,financial_status,line_items'
}
)
response.body['orders'] || []
end
def normalize_order(order, shop_domain)
{
id: order['id'],
name: order['name'],
created_at: order['created_at'],
total_price: order['total_price'],
currency: order['currency'],
financial_status: order['financial_status'],
fulfillment_status: order['fulfillment_status'],
line_items: normalize_line_items(order['line_items']),
admin_url: admin_url(shop_domain, order['id'])
}
end
def normalize_line_items(line_items)
Array(line_items).first(MAX_LINE_ITEMS).map do |line_item|
{
title: line_item['title'],
quantity: line_item['quantity'],
price: line_item['price']
}
end
end
def admin_url(shop_domain, order_id)
return nil if shop_domain.blank? || order_id.blank?
"https://#{shop_domain}/admin/orders/#{order_id}"
end
def normalized_limit(limit)
value = limit.to_i
return MAX_LIMIT if value <= 0
[value, MAX_LIMIT].min
end
def success(data)
{ ok: true, data: data }
end
def failure(code, message)
{ ok: false, error: { code: code, message: message } }
end
end
@@ -0,0 +1,168 @@
class Integrations::Shopify::ProductsService
REQUIRED_SCOPE = 'read_products'.freeze
MAX_LIMIT = 10
FALLBACK_MULTIPLIER = 5
MAX_FALLBACK_LIMIT = 50
def initialize(account:)
@account = account
@client_service = Integrations::Shopify::ClientService.new(account: account)
end
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def search_products(query:, limit: MAX_LIMIT)
Rails.logger.info(
"[Integrations::Shopify::ProductsService] search_products account_id=#{@account.id} " \
"query=#{query.inspect} limit=#{limit}"
)
return no_products_result(query) if query.blank?
client_result = @client_service.fetch_client
return client_result unless client_result[:ok]
unless product_scope_granted?
Rails.logger.warn(
"[Integrations::Shopify::ProductsService] missing_scope account_id=#{@account.id} " \
"required=#{REQUIRED_SCOPE} granted=#{@client_service.granted_scopes.join(',')}"
)
return failure(:insufficient_scope, 'Shopify integration is missing read_products scope.')
end
products = fetch_products(client_result[:data][:client], query, normalized_limit(limit))
Rails.logger.info(
"[Integrations::Shopify::ProductsService] shopify_response account_id=#{@account.id} " \
"query=#{query.inspect} products_count=#{products.length}"
)
return no_products_result(query) if products.empty?
success(
products: products.map { |product| normalize_product(product, client_result[:data][:hook].reference_id) }
)
rescue ShopifyAPI::Errors::HttpResponseError => e
Rails.logger.error("[Integrations::Shopify::ProductsService] Shopify error: #{e.message}")
failure(:provider_error, 'Shopify product search failed.')
rescue StandardError => e
Rails.logger.error("[Integrations::Shopify::ProductsService] #{e.class}: #{e.message}")
failure(:provider_error, 'Shopify product search failed.')
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
private
def product_scope_granted?
@client_service.scopes_include?(REQUIRED_SCOPE)
end
def no_products_result(query)
Rails.logger.info(
"[Integrations::Shopify::ProductsService] no_results account_id=#{@account.id} query=#{query.inspect}"
)
failure(:no_results, 'No products found for the provided query.')
end
def fetch_products(client, query, limit)
products = fetch_products_by_title(client, query, limit)
return products if products.any?
fallback_limit = [(limit * FALLBACK_MULTIPLIER), MAX_FALLBACK_LIMIT].min
Rails.logger.info(
"[Integrations::Shopify::ProductsService] fallback_keyword_search account_id=#{@account.id} " \
"query=#{query.inspect} fallback_limit=#{fallback_limit}"
)
fallback_products = fetch_active_products(client, fallback_limit)
filter_products_by_keyword(fallback_products, query).first(limit)
end
def fetch_products_by_title(client, query, limit)
response = client.get(
path: 'products.json',
query: product_query(limit: limit, title: query)
)
response.body['products'] || []
end
def fetch_active_products(client, limit)
response = client.get(
path: 'products.json',
query: product_query(limit: limit)
)
response.body['products'] || []
end
def product_query(limit:, title: nil)
{
title: title,
status: 'active',
limit: limit,
fields: 'id,title,vendor,product_type,handle,variants'
}.compact
end
def filter_products_by_keyword(products, query)
keyword = query.to_s.downcase.strip
return [] if keyword.blank?
products.select do |product|
searchable_text = [product['title'], product['vendor'], product['product_type']].compact.join(' ').downcase
searchable_text.include?(keyword)
end
end
def normalize_product(product, shop_domain)
variants = Array(product['variants'])
first_variant = variants.first || {}
{
id: product['id'],
title: product['title'],
vendor: product['vendor'],
product_type: product['product_type'],
handle: product['handle'],
storefront_url: storefront_url(shop_domain, product['handle']),
price: first_variant['price'],
availability: availability_summary(variants)
}
end
def storefront_url(shop_domain, handle)
return nil if shop_domain.blank? || handle.blank?
"https://#{shop_domain}/products/#{handle}"
end
def availability_summary(variants)
return 'Out of stock' if variants.empty?
available_count = variants.count { |variant| variant_available?(variant) }
return 'Out of stock' if available_count.zero?
return 'In stock' if available_count == variants.size
"Partially in stock (#{available_count}/#{variants.size} variants)"
end
def variant_available?(variant)
return variant['available'] if [true, false].include?(variant['available'])
quantity = variant['inventory_quantity']
return quantity.to_i.positive? unless quantity.nil?
variant['inventory_policy'] == 'continue'
end
def normalized_limit(limit)
value = limit.to_i
return MAX_LIMIT if value <= 0
[value, MAX_LIMIT].min
end
def success(data)
{ ok: true, data: data }
end
def failure(code, message)
{ ok: false, error: { code: code, message: message } }
end
end
+180
View File
@@ -0,0 +1,180 @@
# Captain + Shopify Upgrade — Investigation and Implementation
## 1. Objective
Document the investigation, fixes, and validation steps for Shopify-powered Captain flows in Chatwoot, focused on:
- Product search reliability
- Order lookup reliability
- Runtime observability (logs)
This guide reflects the final state where **order lookup is by contact email/phone only** (not by order ID).
## 2. Reported Symptoms
### Product flow
- Captain returned incomplete/incorrect product answers for known products.
- Shopify REST API warning was observed for `products.json` deprecation.
### Order flow
- Customer had a valid Shopify order visible in UI.
- Captain still responded with:
- `I need the contact email or phone number...`
- Logs showed:
- `shopify_get_orders_identity_resolved ... email_present: false, phone_present: false`
- `missing_identifier`
## 3. Root Cause
For order lookup, Captain tool identity was initially resolved from `state[:contact]` only.
- In the failing conversation, contact state had:
- `contact.email = nil`
- `contact.phone_number = nil`
- User shared email in message text, but tool did not use message text/history for identity.
## 4. Final Implementation Summary
## 4.1 Product search improvements
- Added stronger logging around Shopify client initialization and product search.
- Added fallback matching strategy in product search when strict title filtering produced empty results.
## 4.2 Order lookup improvements (final behavior)
- Kept Shopify order lookup API in service as:
- `orders_for_contact(email:, phone_number:, limit:)`
- Added robust identity resolution for Captain order tool:
- Explicit tool args (if provided by model)
- Contact state
- Current user input text (`captain_v2_trace_current_input`)
- User trace history (`captain_v2_trace_input`) for confirmation turns like `Yes`
- Final outcome: order lookup remains **email/phone based**, but now survives missing contact fields when identity exists in conversation text.
## 4.3 Error handling and UX
- Missing identifier message is:
- `I need the contact email or phone number to look up Shopify orders.`
- Domain/provider errors are mapped to safe user-facing messages.
## 4.4 Prompt guidance
- Captain prompt guidance for Shopify order queries remains generic for order lookup via Shopify order tool.
## 5. Key Files
- Service:
- `app/services/integrations/shopify/client_service.rb`
- `app/services/integrations/shopify/products_service.rb`
- `app/services/integrations/shopify/orders_service.rb`
- Captain tools:
- `enterprise/lib/captain/tools/shopify_base_tool.rb`
- `enterprise/lib/captain/tools/shopify_search_products_tool.rb`
- `enterprise/lib/captain/tools/shopify_get_orders_tool.rb`
- Prompt:
- `enterprise/lib/captain/prompts/assistant.liquid`
- Specs:
- `spec/services/integrations/shopify/products_service_spec.rb`
- `spec/services/integrations/shopify/orders_service_spec.rb`
- `spec/enterprise/lib/captain/tools/shopify_search_products_tool_spec.rb`
- `spec/enterprise/lib/captain/tools/shopify_base_tool_spec.rb`
- `spec/enterprise/lib/captain/tools/shopify_get_orders_tool_spec.rb`
## 6. QA Checklist
### Product search
1. Ask for a known product keyword (example: `Do you have snowboard?`).
2. Verify Captain returns matching Shopify products with links.
3. Check logs for:
- `shopify_search_products_requested`
- `ProductsService ... search_products`
- fallback log path when strict title match is empty
### Order lookup (email/phone)
1. Ensure contact sidebar may have empty email/phone.
2. Ask: `Where is my order russel.winfield@example.com?`
3. Verify Captain resolves identity and attempts Shopify lookup.
4. Confirmation flow:
- User provides email in earlier turn
- Later replies only `Yes`
- Verify tool still resolves email from trace history and proceeds
5. Check logs for:
- `shopify_get_orders_requested`
- `shopify_get_orders_identity_resolved` with `email_present: true` or `phone_present: true`
- no `missing_identifier` when identity exists in message/history
## 7. Spec/Lint Commands Used
```bash
bundle exec rspec spec/services/integrations/shopify/orders_service_spec.rb \
spec/enterprise/lib/captain/tools/shopify_base_tool_spec.rb \
spec/enterprise/lib/captain/tools/shopify_get_orders_tool_spec.rb
```
```bash
bundle exec rubocop \
app/services/integrations/shopify/orders_service.rb \
enterprise/lib/captain/tools/shopify_base_tool.rb \
enterprise/lib/captain/tools/shopify_get_orders_tool.rb \
spec/services/integrations/shopify/orders_service_spec.rb \
spec/enterprise/lib/captain/tools/shopify_get_orders_tool_spec.rb
```
## 8. Known Non-Blocking Noise
- Sidekiq scheduled `Discord::PollAllChannelsJob` may fail with:
- `NameError: uninitialized constant Discord`
- This is unrelated to Shopify Captain product/order behavior.
## 9. Rollback Guidance
If required, rollback can be done by reverting Shopify-related tool/service commits only:
- Revert the Shopify service/tool/spec files listed in Section 5.
- Re-run the spec and rubocop commands in Section 7.
## 10. Missed in This Iteration (Planned for Next Version)
The following items were identified during implementation/testing but intentionally deferred:
1. Shopify order lookup by order ID (optional mode)
- We validated this path technically, but final behavior in this release is email/phone-only.
- Next version plan:
- Add `order_id` lookup behind a feature flag.
- Keep email/phone as default to avoid accidental misrouting.
2. Contact identity persistence from conversation
- Current fix resolves identity from message/trace at runtime.
- Next version plan:
- Optionally persist verified email/phone back to contact profile (with guardrails), so subsequent turns need fewer recoveries.
3. Shopify API modernization (REST -> GraphQL)
- Logs still show REST deprecation warnings for product endpoints.
- Next version plan:
- Move product/order reads to Shopify GraphQL Admin API.
- Preserve response contracts expected by Captain tools.
4. End-to-end regression suite for conversational identity flows
- Current tests are service + tool unit specs.
- Next version plan:
- Add integration-level specs for real multi-turn conversation flows:
- email in first turn + `Yes` confirmation turn
- phone-only lookup
- empty identity -> prompt for identifier
5. Better operator-facing diagnostics
- Logs are present, but triage still needs manual correlation.
- Next version plan:
- Add structured log keys and dashboards for:
- identity source used (`contact`, `current_input`, `trace_history`)
- tool success/failure reason distribution
- Shopify error categories by account
6. Non-Shopify scheduled job noise
- `Discord::PollAllChannelsJob` `NameError` is unrelated but pollutes logs.
- Next version plan:
- Gate/disable scheduler for disconnected integrations in dev/test to reduce noise during Captain investigations.
+10
View File
@@ -30,6 +30,16 @@
description: 'Search FAQ responses using semantic similarity'
icon: 'search'
- id: shopify_search_products
title: 'Shopify: Search Products'
description: 'Search products in the connected Shopify store'
icon: 'shopping-bag'
- id: shopify_get_orders
title: 'Shopify: Get Orders'
description: "Look up a customer's orders from Shopify"
icon: 'shopping-cart'
- id: resolve_conversation
title: 'Resolve Conversation'
description: 'Resolve a conversation when the issue has been addressed'
+28 -1
View File
@@ -37,6 +37,7 @@ class Captain::Assistant < ApplicationRecord
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
SHOPIFY_TOOL_IDS = %w[shopify_search_products shopify_get_orders].freeze
validates :name, presence: true
validates :description, presence: true
@@ -52,6 +53,7 @@ class Captain::Assistant < ApplicationRecord
def available_agent_tools
tools = self.class.built_in_agent_tools.dup
tools = filter_shopify_tool_metadata(tools)
custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata)
tools.concat(custom_tools)
@@ -92,10 +94,25 @@ class Captain::Assistant < ApplicationRecord
end
def agent_tools
[
tools = [
self.class.resolve_tool_class('faq_lookup').new(self),
self.class.resolve_tool_class('handoff').new(self)
]
if shopify_tools_enabled_for_v2?
tools.concat(
SHOPIFY_TOOL_IDS.filter_map do |tool_id|
tool_class = self.class.resolve_tool_class(tool_id)
tool_class&.new(self)
end
)
end
tools
end
def shopify_tools_enabled_for_v2?
account.feature_enabled?('captain_integration_v2') && shopify_connected?
end
def prompt_context
@@ -118,4 +135,14 @@ class Captain::Assistant < ApplicationRecord
def default_avatar_url
"#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg"
end
def filter_shopify_tool_metadata(tools)
return tools if shopify_tools_enabled_for_v2?
tools.reject { |tool| SHOPIFY_TOOL_IDS.include?(tool[:id]) }
end
def shopify_connected?
Integrations::Hook.exists?(account_id: account_id, app_id: 'shopify', status: :enabled)
end
end
@@ -22,7 +22,7 @@ module Concerns::CaptainToolsHelpers
# @param tool_id [String] The snake_case tool identifier
# @return [Class, nil] The tool class if found, nil if not resolvable
def resolve_tool_class(tool_id)
class_name = "Captain::Tools::#{tool_id.classify}Tool"
class_name = "Captain::Tools::#{tool_id.camelize}Tool"
class_name.safe_constantize
end
@@ -66,9 +66,11 @@ If unclear, ask clarifying questions to determine if a scenario applies:
If no specialized scenario clearly matches, handle it yourself in the following way
### For Questions and Information Requests
1. **First, check existing knowledge**: Use `captain--tools--faq_lookup` tool to search for relevant information
2. **If not found in FAQs**: Try to ask clarifying questions to gather more information
3. **If unable to answer**: Use `captain--tools--handoff` tool to transfer to a human expert
1. **For Shopify product queries**: Use `captain--tools--shopify_search_products` first to find matching products
2. **For Shopify order status/lookup queries**: Use `captain--tools--shopify_get_orders` first to check customer orders
3. **For general factual questions**: Use `captain--tools--faq_lookup` tool to search for relevant information
4. **If not found in tools**: Ask clarifying questions to gather more information
5. **If unable to answer**: Use `captain--tools--handoff` tool to transfer to a human expert
### For Complex or Unclear Requests
1. **Ask clarifying questions**: Gather more information if needed
@@ -0,0 +1,158 @@
class Captain::Tools::ShopifyBaseTool < Captain::Tools::BasePublicTool
SHOPIFY_APP_ID = 'shopify'.freeze
V2_FEATURE_FLAG = 'captain_integration_v2'.freeze
EMAIL_REGEX = /\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b/i
PHONE_REGEX = /(?:\+\d[\d\s().-]{7,}\d|\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4})/
def active?
v2_enabled? && shopify_connected?
end
private
def v2_enabled?
@assistant.account.feature_enabled?(V2_FEATURE_FLAG)
end
def shopify_connected?
Integrations::Hook.exists?(account_id: @assistant.account_id, app_id: SHOPIFY_APP_ID, status: :enabled)
end
def eligibility_error_message
unless v2_enabled?
log_tool_usage('shopify_tool_not_eligible', { reason: 'captain_v2_disabled' })
return 'This Shopify tool is available only for Captain V2.'
end
unless shopify_connected?
log_tool_usage('shopify_tool_not_eligible', { reason: 'shopify_not_connected' })
return 'Shopify integration is not connected for this account.'
end
nil
end
def resolve_contact_identity(state, override_identity = {})
inferred_identity = extract_identity_from_current_input(state)
history_identity = extract_identity_from_trace_history(state)
{
email: resolve_identity_value(:email, override_identity, state, inferred_identity, history_identity),
phone_number: resolve_identity_value(:phone_number, override_identity, state, inferred_identity, history_identity)
}
end
def format_domain_error(error)
case error&.dig(:code)
when :not_connected
'Shopify integration is not connected. Please connect Shopify and try again.'
when :insufficient_scope
'Shopify integration requires additional permissions. Please reauthorize Shopify integration and try again.'
when :missing_identifier
'I need the contact email or phone number to look up Shopify orders.'
when :no_results
error[:message].presence || 'No matching Shopify records were found.'
else
'I could not fetch Shopify data right now. Please try again shortly.'
end
end
def extract_identity_from_current_input(state)
current_input = extract_current_input_text(state)
return empty_identity if current_input.blank?
extract_identity_from_text(current_input)
end
def extract_current_input_text(state)
raw_input = state&.dig(:captain_v2_trace_current_input).to_s
return '' if raw_input.blank?
extract_trace_content_text(parse_trace_payload(raw_input))
end
def extract_identity_from_trace_history(state)
trace_input = state&.dig(:captain_v2_trace_input).to_s
parsed_input = parse_trace_payload(trace_input)
return empty_identity unless parsed_input.is_a?(Array)
user_messages = parsed_input.reverse.filter_map { |entry| trace_user_text(entry) }
first_identity_from_messages(user_messages)
end
def extract_trace_content_text(content)
return '' if content.blank?
return content.to_s unless content.is_a?(Array)
content.filter_map { |part| part['text'] if part.is_a?(Hash) && part['type'] == 'text' }.join(' ')
end
def trace_user_text(entry)
return unless entry.is_a?(Hash) && entry['role'] == 'user'
extract_trace_content_text(entry['content']).presence
end
def first_identity_from_messages(messages)
identity = empty_identity
messages.each do |message|
identity[:email] ||= normalize_email(message)
identity[:phone_number] ||= normalize_phone(message[PHONE_REGEX])
break if identity.values.all?(&:present?)
end
identity
end
def extract_identity_from_text(text)
{
email: normalize_email(text),
phone_number: normalize_phone(text[PHONE_REGEX])
}
end
def resolve_identity_value(key, override_identity, state, inferred_identity, history_identity)
[
normalized_identity_value(key, override_identity[key]),
normalized_identity_value(key, state&.dig(:contact, key)),
normalized_identity_value(key, inferred_identity[key]),
normalized_identity_value(key, history_identity[key])
].find(&:present?)
end
def normalized_identity_value(key, value)
return normalize_email(value) if key == :email
normalize_phone(value)
end
def empty_identity
{ email: nil, phone_number: nil }
end
def parse_trace_payload(raw_input)
return raw_input unless raw_input.strip.start_with?('[', '{')
JSON.parse(raw_input)
rescue JSON::ParserError
raw_input
end
def normalize_email(raw_value)
value = raw_value.to_s.strip
return nil if value.blank?
value[EMAIL_REGEX]
end
def normalize_phone(raw_value)
value = raw_value.to_s.strip
return nil if value.blank?
normalized = value.gsub(/[^\d+]/, '')
return nil unless normalized.match?(/\A\+?\d{7,15}\z/)
normalized
end
end
@@ -0,0 +1,72 @@
class Captain::Tools::ShopifyGetOrdersTool < Captain::Tools::ShopifyBaseTool
description "Look up a customer's orders from Shopify"
param :email, type: 'string', desc: 'Customer email used for order lookup', required: false
param :phone_number, type: 'string', desc: 'Customer phone number used for order lookup', required: false
# rubocop:disable Metrics/AbcSize
def perform(tool_context, email: nil, phone_number: nil)
log_tool_usage('shopify_get_orders_requested', { email_present: email.present?, phone_present: phone_number.present? })
return eligibility_error_message if eligibility_error_message
identity = resolve_contact_identity(tool_context&.state, { email: email, phone_number: phone_number })
log_tool_usage(
'shopify_get_orders_identity_resolved',
{ email_present: identity[:email].present?, phone_present: identity[:phone_number].present? }
)
result = orders_service.orders_for_contact(email: identity[:email], phone_number: identity[:phone_number], limit: 10)
unless result[:ok]
log_tool_usage('shopify_get_orders_failed', { error: result[:error] })
return format_domain_error(result[:error])
end
log_tool_usage('shopify_get_orders_success', { orders_count: result[:data][:orders].length })
format_orders(result[:data][:orders])
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @assistant.account).capture_exception
'I could not fetch Shopify orders right now. Please try again shortly.'
end
# rubocop:enable Metrics/AbcSize
private
def orders_service
@orders_service ||= Integrations::Shopify::OrdersService.new(account: @assistant.account)
end
def format_orders(orders)
lines = ["Found #{orders.length} Shopify orders:"]
orders.each_with_index do |order, index|
lines << order_line(index, order)
lines << " Items: #{line_items_summary(order[:line_items])}"
end
lines.join("\n")
end
def order_line(index, order)
[
"#{index + 1}. #{order[:name]}",
"Date: #{order[:created_at]}",
"Total: #{formatted_total(order)}",
"Payment: #{order[:financial_status]}",
"Fulfillment: #{order[:fulfillment_status]}",
"Admin: #{order[:admin_url]}"
].join(' | ')
end
def line_items_summary(line_items)
items = Array(line_items)
return 'N/A' if items.empty?
items.map { |item| "#{item[:quantity]}x #{item[:title]}" }.join(', ')
end
def formatted_total(order)
return 'N/A' if order[:total_price].blank?
[order[:currency], order[:total_price]].compact.join(' ')
end
end
@@ -0,0 +1,53 @@
class Captain::Tools::ShopifySearchProductsTool < Captain::Tools::ShopifyBaseTool
description 'Search products in the connected Shopify store'
param :query, type: 'string', desc: 'Search query for product name or keywords'
def perform(_tool_context, query:)
log_tool_usage('shopify_search_products_requested', { query: query })
return eligibility_error_message if eligibility_error_message
result = products_service.search_products(query: query, limit: 10)
unless result[:ok]
log_tool_usage('shopify_search_products_failed', { query: query, error: result[:error] })
return format_domain_error(result[:error])
end
log_tool_usage('shopify_search_products_success', { query: query, products_count: result[:data][:products].length })
format_products(result[:data][:products])
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @assistant.account).capture_exception
'I could not fetch Shopify products right now. Please try again shortly.'
end
private
def products_service
@products_service ||= Integrations::Shopify::ProductsService.new(account: @assistant.account)
end
def format_products(products)
lines = ["Found #{products.length} matching Shopify products:"]
products.each_with_index do |product, index|
lines << product_line(index, product)
end
lines.join("\n")
end
def product_line(index, product)
[
"#{index + 1}. #{product[:title]}",
"Price: #{formatted_price(product[:price])}",
"Availability: #{product[:availability]}",
"URL: #{product[:storefront_url]}"
].join(' | ')
end
def formatted_price(price)
return 'N/A' if price.blank?
price.to_s
end
end
+199
View File
@@ -0,0 +1,199 @@
# `shopify_advanced.md` + Implementation Runbook (Enterprise, Captain V2, Products+Orders)
## Summary
Implement advanced Shopify support for Captain as **Enterprise + Captain V2 only**, with **phase 1 limited to product search and order lookup**.
No Captain V1 work, no cart/checkout work in this phase.
## Deliverables
1. Root doc file: `shopify_advanced.md` (this plan content).
2. Backend Shopify service layer (shared, read-only).
3. Two new Captain built-in tools:
- `shopify_search_products`
- `shopify_get_orders`
4. Tool exposure/runtime gating to Captain V2 + connected Shopify only.
5. Scope update: add `read_products`.
6. Specs for services, tools, and gating.
## Important Interface Changes
1. New Captain tool IDs:
- `shopify_search_products`
- `shopify_get_orders`
2. Shopify required scopes update:
- add `read_products` to `REQUIRED_SCOPES`.
3. No new public REST endpoints for Captain.
## Files to Create
### 1) Shared Shopify services
1. `app/services/integrations/shopify/client_service.rb`
2. `app/services/integrations/shopify/products_service.rb`
3. `app/services/integrations/shopify/orders_service.rb`
### 2) Enterprise Captain tools
1. `enterprise/lib/captain/tools/shopify_base_tool.rb`
2. `enterprise/lib/captain/tools/shopify_search_products_tool.rb`
3. `enterprise/lib/captain/tools/shopify_get_orders_tool.rb`
### 3) Specs
1. `spec/services/integrations/shopify/products_service_spec.rb`
2. `spec/services/integrations/shopify/orders_service_spec.rb`
3. `spec/enterprise/lib/captain/tools/shopify_base_tool_spec.rb`
4. `spec/enterprise/lib/captain/tools/shopify_search_products_tool_spec.rb`
5. `spec/enterprise/lib/captain/tools/shopify_get_orders_tool_spec.rb`
## Files to Modify
1. [`/Users/muhsink/Documents/chatwoot/app/helpers/shopify/integration_helper.rb`](/Users/muhsink/Documents/chatwoot/app/helpers/shopify/integration_helper.rb)
- Add `read_products` to `REQUIRED_SCOPES`.
2. [`/Users/muhsink/Documents/chatwoot/config/agents/tools.yml`](/Users/muhsink/Documents/chatwoot/config/agents/tools.yml)
- Register the 2 new tools with title/description/icon.
3. [`/Users/muhsink/Documents/chatwoot/enterprise/app/models/captain/assistant.rb`](/Users/muhsink/Documents/chatwoot/enterprise/app/models/captain/assistant.rb)
- Add helper `shopify_tools_enabled_for_v2?`.
- Auto-append Shopify tools in `agent_tools` when eligible.
- Filter tool metadata in `available_agent_tools` unless eligible.
4. [`/Users/muhsink/Documents/chatwoot/enterprise/lib/captain/prompts/assistant.liquid`](/Users/muhsink/Documents/chatwoot/enterprise/lib/captain/prompts/assistant.liquid)
- Add short instruction: use Shopify tools for product/order queries before FAQ fallback.
5. Optional refactor only (no contract change):
- [`/Users/muhsink/Documents/chatwoot/app/controllers/api/v1/accounts/integrations/shopify_controller.rb`](/Users/muhsink/Documents/chatwoot/app/controllers/api/v1/accounts/integrations/shopify_controller.rb)
- Reuse `OrdersService` internally for consistency.
## Implementation Details
### A. `ClientService`
Responsibilities:
1. Load connected hook (`app_id: 'shopify'`, status enabled).
2. Build Shopify REST client from hook token.
3. Parse granted scopes from hook settings.
4. Return normalized failure objects for:
- `:not_connected`
- `:provider_error`
### B. `ProductsService`
Method:
- `search_products(query:, limit: 10)`
Behavior:
1. Validate connection + `read_products`.
2. Fetch active products by title query.
3. Normalize output:
- `id`, `title`, `vendor`, `product_type`, `handle`, `storefront_url`
- first variant `price`
- availability summary from variants.
4. Return normalized result/error:
- `:insufficient_scope`, `:no_results`, `:provider_error`.
### C. `OrdersService`
Method:
- `orders_for_contact(email:, phone_number:, limit: 10)`
Behavior:
1. Require at least one identifier (`email` or `phone_number`).
2. Validate connection + scopes `read_customers` and `read_orders`.
3. Search customer by `email OR phone`.
4. Fetch orders for first matched customer.
5. Normalize output:
- `id`, `name`, `created_at`, `total_price`, `currency`
- `financial_status`, `fulfillment_status`
- `line_items` (capped)
- `admin_url`.
6. Return normalized result/error:
- `:missing_identifier`, `:no_results`, `:provider_error`.
### D. `ShopifyBaseTool`
Common behavior:
1. Extend `BasePublicTool`.
2. `active?` true only when:
- `captain_integration_v2` enabled
- Shopify hook enabled for account.
3. Common helpers:
- `v2_enabled?`
- `shopify_connected?`
- `resolve_contact_identity(tool_context.state)`
- scope guard + standardized reconnect message.
4. Tool-safe deterministic messages, no exceptions leaked.
### E. `shopify_search_products` tool
Params:
1. `query` (required string).
Behavior:
1. Call `ProductsService#search_products`.
2. Format compact plain text list (max 10):
- title
- price
- availability
- product URL.
3. Deterministic fallback messages for each domain error.
### F. `shopify_get_orders` tool
Params:
1. none (phase 1).
Behavior:
1. Resolve contact email/phone from conversation state.
2. Call `OrdersService#orders_for_contact`.
3. Format compact plain text list (max 10):
- order name/date/total/status/admin URL
- top line items.
4. Deterministic fallback messages for each domain error.
## Gating Rules (Decision-Complete)
1. Tool visibility in UI:
- Hidden unless `captain_integration_v2` and Shopify connected.
2. Tool execution:
- If invoked when ineligible, return non-execution message (no crash).
3. Scenario validity:
- Existing `available_tool_ids`-based validation remains source of truth.
## Test Cases
### Service specs
1. Product search success with normalized fields.
2. Product no results.
3. Product missing scope.
4. Orders success by email.
5. Orders success by phone.
6. Orders missing identifier.
7. Orders no matching customer.
8. Provider error mapping for both services.
### Tool specs
1. `active?` false when V2 disabled.
2. `active?` false when Shopify disconnected.
3. Product tool success formatting.
4. Product tool missing scope message.
5. Orders tool success formatting.
6. Orders tool missing identity message.
7. Orders tool no-result message.
8. Provider error safe messaging.
### Model/gating specs
1. `available_agent_tools` hides Shopify tools unless eligible.
2. `agent_tools` includes Shopify tools only when eligible.
### Regression
1. Existing Shopify integration request specs still pass.
2. Existing Captain tools unaffected.
## Acceptance Criteria
1. With V2 enabled + Shopify connected + proper scopes:
- “Do you have sneakers?” triggers `shopify_search_products` and returns relevant products with links.
- “Where is my order?” triggers `shopify_get_orders` and returns recent order details.
2. With older Shopify install missing product scope:
- Product tool returns reconnect guidance; no crash.
3. With V2 off or no Shopify connection:
- Shopify tools are not shown and not executed.
## Assumptions
1. Enterprise-only feature is acceptable.
2. Captain V2 only.
3. Phase 1 excludes abandoned checkouts/cart.
4. Plain text output only.
5. English copy updates only where needed.
## `shopify_advanced.md` Content
Use this entire document as the initial content of `shopify_advanced.md` at repo root.
@@ -0,0 +1,80 @@
require 'rails_helper'
RSpec.describe Captain::Tools::ShopifyBaseTool, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:tool_class) do
Class.new(described_class) do
def perform(*)
'ok'
end
end
end
let(:tool) { tool_class.new(assistant) }
describe '#active?' do
it 'returns false when captain v2 is disabled' do
create(:integrations_hook, :shopify, account: account)
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
expect(tool.active?).to be false
end
it 'returns false when shopify is disconnected' do
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
expect(tool.active?).to be false
end
it 'returns true when v2 is enabled and shopify is connected' do
create(:integrations_hook, :shopify, account: account)
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
expect(tool.active?).to be true
end
end
describe '#resolve_contact_identity' do
it 'returns email and phone from state' do
identity = tool.send(:resolve_contact_identity, { contact: { email: 'john@example.com', phone_number: '+15551234567' } })
expect(identity).to eq({ email: 'john@example.com', phone_number: '+15551234567' })
end
it 'falls back to extracting email from current input when contact identity is missing' do
identity = tool.send(
:resolve_contact_identity,
{ contact: {}, captain_v2_trace_current_input: 'Where is my order Russel.winfield@example.com?' }
)
expect(identity).to eq({ email: 'Russel.winfield@example.com', phone_number: nil })
end
it 'falls back to extracting email from trace history when current input has no identity' do
trace_input = [
{ role: 'user', content: 'Where is my order Russel.winfield@example.com?' },
{ role: 'assistant', content: 'Please confirm your email' },
{ role: 'user', content: 'Yes' }
].to_json
identity = tool.send(
:resolve_contact_identity,
{ contact: {}, captain_v2_trace_current_input: 'Yes', captain_v2_trace_input: trace_input }
)
expect(identity).to eq({ email: 'Russel.winfield@example.com', phone_number: nil })
end
it 'prioritizes explicit identity over contact and inferred identity' do
state = {
contact: { email: 'contact@example.com', phone_number: '+15550001111' },
captain_v2_trace_current_input: 'my alternate is inferred@example.com'
}
overrides = { email: 'explicit@example.com', phone_number: '+15551234567' }
identity = tool.send(:resolve_contact_identity, state, overrides)
expect(identity).to eq({ email: 'explicit@example.com', phone_number: '+15551234567' })
end
end
end
@@ -0,0 +1,132 @@
require 'rails_helper'
RSpec.describe Captain::Tools::ShopifyGetOrdersTool, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:tool) { described_class.new(assistant) }
let(:tool_context) { Struct.new(:state).new({ contact: { email: 'john@example.com', phone_number: '+15551234567' } }) }
before do
create(:integrations_hook, :shopify, account: account)
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
end
describe '#perform' do
it 'formats successful order results' do
service = instance_double(Integrations::Shopify::OrdersService)
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
allow(service).to receive(:orders_for_contact).with(email: 'john@example.com', phone_number: '+15551234567', limit: 10).and_return(
{
ok: true,
data: {
orders: [
{
name: '#1001',
created_at: '2026-01-10T10:00:00Z',
currency: 'USD',
total_price: '89.50',
financial_status: 'paid',
fulfillment_status: 'fulfilled',
admin_url: 'https://store/admin/orders/91',
line_items: [{ title: 'Red Sneakers', quantity: 1 }]
}
]
}
}
)
result = tool.perform(tool_context)
expect(result).to include('Found 1 Shopify orders:')
expect(result).to include('#1001')
expect(result).to include('https://store/admin/orders/91')
end
it 'returns missing identity message' do
service = instance_double(Integrations::Shopify::OrdersService)
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
allow(service).to receive(:orders_for_contact).and_return(
{ ok: false, error: { code: :missing_identifier, message: 'missing' } }
)
result = tool.perform(Struct.new(:state).new({ contact: {} }))
expect(result).to eq('I need the contact email or phone number to look up Shopify orders.')
end
it 'falls back to extracting email from current input text when contact identity is missing' do
service = instance_double(Integrations::Shopify::OrdersService)
context = Struct.new(:state).new(
{ contact: {}, captain_v2_trace_current_input: 'Where is my order Russel.winfield@example.com?' }
)
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
allow(service).to receive(:orders_for_contact).with(email: 'Russel.winfield@example.com', phone_number: nil, limit: 10).and_return(
{ ok: false, error: { code: :no_results, message: 'No orders found for the customer.' } }
)
result = tool.perform(context)
expect(result).to eq('No orders found for the customer.')
end
it 'uses explicit arguments over inferred state identity' do
service = instance_double(Integrations::Shopify::OrdersService)
context = Struct.new(:state).new(
{ contact: {}, captain_v2_trace_current_input: 'Where is my order inferred@example.com?' }
)
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
allow(service).to receive(:orders_for_contact).with(email: 'explicit@example.com', phone_number: nil, limit: 10).and_return(
{ ok: false, error: { code: :no_results, message: 'No orders found for the customer.' } }
)
result = tool.perform(context, email: 'explicit@example.com')
expect(result).to eq('No orders found for the customer.')
end
it 'uses trace history identity when current input is a plain confirmation' do
service = instance_double(Integrations::Shopify::OrdersService)
trace_input = [
{ role: 'user', content: 'Where is my order Russel.winfield@example.com?' },
{ role: 'assistant', content: 'Please confirm your email' },
{ role: 'user', content: 'Yes' }
].to_json
context = Struct.new(:state).new(
{ contact: {}, captain_v2_trace_current_input: 'Yes', captain_v2_trace_input: trace_input }
)
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
allow(service).to receive(:orders_for_contact).with(email: 'Russel.winfield@example.com', phone_number: nil, limit: 10).and_return(
{ ok: false, error: { code: :no_results, message: 'No orders found for the customer.' } }
)
result = tool.perform(context)
expect(result).to eq('No orders found for the customer.')
end
it 'returns no result message from service' do
service = instance_double(Integrations::Shopify::OrdersService)
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
allow(service).to receive(:orders_for_contact).and_return(
{ ok: false, error: { code: :no_results, message: 'No orders found for the customer.' } }
)
result = tool.perform(tool_context)
expect(result).to eq('No orders found for the customer.')
end
it 'returns safe provider error message' do
service = instance_double(Integrations::Shopify::OrdersService)
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
allow(service).to receive(:orders_for_contact).and_raise(StandardError, 'boom')
result = tool.perform(tool_context)
expect(result).to eq('I could not fetch Shopify orders right now. Please try again shortly.')
end
end
end
@@ -0,0 +1,62 @@
require 'rails_helper'
RSpec.describe Captain::Tools::ShopifySearchProductsTool, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:tool) { described_class.new(assistant) }
before do
create(:integrations_hook, :shopify, account: account)
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
end
describe '#perform' do
it 'formats successful product search results' do
service = instance_double(Integrations::Shopify::ProductsService)
allow(Integrations::Shopify::ProductsService).to receive(:new).with(account: account).and_return(service)
allow(service).to receive(:search_products).with(query: 'sneakers', limit: 10).and_return(
{
ok: true,
data: {
products: [
{
title: 'Red Sneakers',
price: '120.00',
availability: 'In stock',
storefront_url: 'https://store/products/red-sneakers'
}
]
}
}
)
result = tool.perform(nil, query: 'sneakers')
expect(result).to include('Found 1 matching Shopify products:')
expect(result).to include('Red Sneakers')
expect(result).to include('https://store/products/red-sneakers')
end
it 'returns reconnect guidance for missing scope' do
service = instance_double(Integrations::Shopify::ProductsService)
allow(Integrations::Shopify::ProductsService).to receive(:new).with(account: account).and_return(service)
allow(service).to receive(:search_products).and_return(
{ ok: false, error: { code: :insufficient_scope, message: 'missing scope' } }
)
result = tool.perform(nil, query: 'sneakers')
expect(result).to eq('Shopify integration requires additional permissions. Please reauthorize Shopify integration and try again.')
end
it 'returns safe provider error message' do
service = instance_double(Integrations::Shopify::ProductsService)
allow(Integrations::Shopify::ProductsService).to receive(:new).with(account: account).and_return(service)
allow(service).to receive(:search_products).and_raise(StandardError, 'boom')
result = tool.perform(nil, query: 'sneakers')
expect(result).to eq('I could not fetch Shopify products right now. Please try again shortly.')
end
end
end
@@ -0,0 +1,75 @@
require 'rails_helper'
RSpec.describe Captain::Assistant, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
describe '#available_agent_tools' do
before do
allow(described_class).to receive(:built_in_agent_tools).and_return([
{ id: 'faq_lookup', title: 'FAQ' },
{ id: 'shopify_search_products', title: 'Products' },
{ id: 'shopify_get_orders', title: 'Orders' }
])
end
it 'hides shopify tools when account is not eligible for v2 shopify' do
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
expect(assistant.available_agent_tools.pluck(:id)).to eq(['faq_lookup'])
end
it 'shows shopify tools when v2 is enabled and shopify is connected' do
create(:integrations_hook, :shopify, account: account)
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
expect(assistant.available_agent_tools.pluck(:id)).to contain_exactly('faq_lookup', 'shopify_search_products', 'shopify_get_orders')
end
end
describe '#agent_tools' do
let(:faq_tool_class) do
Class.new do
def initialize(_assistant); end
end
end
let(:handoff_tool_class) do
Class.new do
def initialize(_assistant); end
end
end
let(:search_tool_class) do
Class.new do
def initialize(_assistant); end
end
end
let(:orders_tool_class) do
Class.new do
def initialize(_assistant); end
end
end
before do
allow(described_class).to receive(:resolve_tool_class).with('faq_lookup').and_return(faq_tool_class)
allow(described_class).to receive(:resolve_tool_class).with('handoff').and_return(handoff_tool_class)
allow(described_class).to receive(:resolve_tool_class).with('shopify_search_products').and_return(search_tool_class)
allow(described_class).to receive(:resolve_tool_class).with('shopify_get_orders').and_return(orders_tool_class)
end
it 'includes only default tools when account is ineligible' do
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
expect(assistant.send(:agent_tools).length).to eq(2)
end
it 'includes shopify tools when account is eligible' do
create(:integrations_hook, :shopify, account: account)
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
expect(assistant.send(:agent_tools).length).to eq(4)
end
end
end
@@ -0,0 +1,138 @@
require 'rails_helper'
RSpec.describe Integrations::Shopify::OrdersService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account) }
let(:shopify_hook) do
create(
:integrations_hook,
:shopify,
account: account,
settings: { scope: 'read_customers,read_orders,read_fulfillments,read_products' }
)
end
let(:shopify_client) { instance_double(ShopifyAPI::Clients::Rest::Admin) }
let(:client_service) { instance_double(Integrations::Shopify::ClientService) }
before do
allow(Integrations::Shopify::ClientService).to receive(:new).with(account: account).and_return(client_service)
allow(client_service).to receive(:fetch_client).and_return(
{
ok: true,
data: {
client: shopify_client,
hook: shopify_hook,
scopes: %w[read_customers read_orders read_fulfillments read_products]
}
}
)
allow(client_service).to receive(:scopes_include?).with(%w[read_customers read_orders]).and_return(true)
end
describe '#orders_for_contact' do
let(:customer_response) do
Struct.new(:body).new({ 'customers' => [{ 'id' => 'cust_1' }] })
end
let(:orders_response) do
Struct.new(:body).new(
{
'orders' => [
{
'id' => 91,
'name' => '#1001',
'created_at' => '2026-01-10T10:00:00Z',
'total_price' => '89.50',
'currency' => 'USD',
'financial_status' => 'paid',
'fulfillment_status' => 'fulfilled',
'line_items' => [{ 'title' => 'Red Sneakers', 'quantity' => 1, 'price' => '89.50' }]
}
]
}
)
end
it 'returns normalized orders for email lookup' do
allow(shopify_client).to receive(:get).with(
path: 'customers/search.json',
query: { query: 'email:john@example.com', fields: 'id,email,phone' }
).and_return(customer_response)
allow(shopify_client).to receive(:get).with(
path: 'orders.json',
query: {
customer_id: 'cust_1',
status: 'any',
limit: 10,
fields: 'id,name,created_at,total_price,currency,fulfillment_status,financial_status,line_items'
}
).and_return(orders_response)
result = service.orders_for_contact(email: 'john@example.com', phone_number: nil)
expect(result[:ok]).to be true
expect(result[:data][:orders]).to eq(
[{
id: 91,
name: '#1001',
created_at: '2026-01-10T10:00:00Z',
total_price: '89.50',
currency: 'USD',
financial_status: 'paid',
fulfillment_status: 'fulfilled',
line_items: [{ title: 'Red Sneakers', quantity: 1, price: '89.50' }],
admin_url: 'https://test-store.myshopify.com/admin/orders/91'
}]
)
end
it 'returns normalized orders for phone lookup' do
allow(shopify_client).to receive(:get).with(
path: 'customers/search.json',
query: { query: 'phone:+15551234567', fields: 'id,email,phone' }
).and_return(customer_response)
allow(shopify_client).to receive(:get).with(
path: 'orders.json',
query: {
customer_id: 'cust_1',
status: 'any',
limit: 10,
fields: 'id,name,created_at,total_price,currency,fulfillment_status,financial_status,line_items'
}
).and_return(orders_response)
result = service.orders_for_contact(email: nil, phone_number: '+15551234567')
expect(result[:ok]).to be true
expect(result[:data][:orders].first[:id]).to eq(91)
end
it 'returns missing_identifier when no identity is available' do
result = service.orders_for_contact(email: nil, phone_number: nil)
expect(result[:ok]).to be false
expect(result.dig(:error, :code)).to eq(:missing_identifier)
end
it 'returns no_results when customer is not found' do
allow(shopify_client).to receive(:get).with(
path: 'customers/search.json',
query: { query: 'email:john@example.com', fields: 'id,email,phone' }
).and_return(Struct.new(:body).new({ 'customers' => [] }))
result = service.orders_for_contact(email: 'john@example.com', phone_number: nil)
expect(result[:ok]).to be false
expect(result.dig(:error, :code)).to eq(:no_results)
end
it 'maps provider exceptions to provider_error' do
allow(shopify_client).to receive(:get).and_raise(StandardError, 'provider down')
result = service.orders_for_contact(email: 'john@example.com', phone_number: nil)
expect(result[:ok]).to be false
expect(result.dig(:error, :code)).to eq(:provider_error)
end
end
end
@@ -0,0 +1,123 @@
require 'rails_helper'
RSpec.describe Integrations::Shopify::ProductsService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account) }
let(:shopify_hook) do
create(
:integrations_hook,
:shopify,
account: account,
settings: { scope: 'read_customers,read_orders,read_fulfillments,read_products' }
)
end
let(:shopify_client) { instance_double(ShopifyAPI::Clients::Rest::Admin) }
let(:client_service) { instance_double(Integrations::Shopify::ClientService) }
before do
allow(Integrations::Shopify::ClientService).to receive(:new).with(account: account).and_return(client_service)
allow(client_service).to receive(:fetch_client).and_return(
{
ok: true,
data: {
client: shopify_client,
hook: shopify_hook,
scopes: %w[read_customers read_orders read_fulfillments read_products]
}
}
)
allow(client_service).to receive(:granted_scopes).and_return(%w[read_customers read_orders read_fulfillments])
end
describe '#search_products' do
it 'returns normalized product fields on success' do
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(true)
allow(shopify_client).to receive(:get).and_return(
Struct.new(:body).new(
{
'products' => [
{
'id' => 11,
'title' => 'Red Sneakers',
'vendor' => 'Acme',
'product_type' => 'Shoes',
'handle' => 'red-sneakers',
'variants' => [{ 'price' => '129.99', 'available' => true }]
}
]
}
)
)
result = service.search_products(query: 'sneakers')
expect(result[:ok]).to be true
expect(result[:data][:products]).to eq(
[{
id: 11,
title: 'Red Sneakers',
vendor: 'Acme',
product_type: 'Shoes',
handle: 'red-sneakers',
storefront_url: 'https://test-store.myshopify.com/products/red-sneakers',
price: '129.99',
availability: 'In stock'
}]
)
end
it 'returns no_results when products are empty' do
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(true)
allow(shopify_client).to receive(:get).and_return(Struct.new(:body).new({ 'products' => [] }))
result = service.search_products(query: 'sneakers')
expect(result[:ok]).to be false
expect(result.dig(:error, :code)).to eq(:no_results)
end
it 'falls back to keyword filtering when title search is empty' do
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(true)
title_search_empty = Struct.new(:body).new({ 'products' => [] })
active_products = Struct.new(:body).new(
{
'products' => [
{
'id' => 22,
'title' => 'The Collection Snowboard: Liquid',
'vendor' => 'Snow',
'product_type' => 'Snowboard',
'handle' => 'collection-snowboard-liquid',
'variants' => [{ 'price' => '150.00', 'available' => true }]
}
]
}
)
allow(shopify_client).to receive(:get).and_return(title_search_empty, active_products)
result = service.search_products(query: 'snowboard')
expect(result[:ok]).to be true
expect(result[:data][:products].first[:title]).to eq('The Collection Snowboard: Liquid')
end
it 'returns insufficient_scope when read_products is missing' do
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(false)
result = service.search_products(query: 'sneakers')
expect(result[:ok]).to be false
expect(result.dig(:error, :code)).to eq(:insufficient_scope)
end
it 'maps provider exceptions to provider_error' do
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(true)
allow(shopify_client).to receive(:get).and_raise(StandardError, 'provider down')
result = service.search_products(query: 'sneakers')
expect(result[:ok]).to be false
expect(result.dig(:error, :code)).to eq(:provider_error)
end
end
end