From db96d0a34c1b2742c6dedd3a4bb3c80a647d15c4 Mon Sep 17 00:00:00 2001 From: Muhsin Date: Sat, 28 Feb 2026 17:47:37 +0400 Subject: [PATCH] chore: shopify tool calling --- app/helpers/shopify/integration_helper.rb | 2 +- .../integrations/shopify/client_service.rb | 83 ++++++++ .../integrations/shopify/orders_service.rb | 162 ++++++++++++++ .../integrations/shopify/products_service.rb | 168 +++++++++++++++ captain-shopify-upgrade-guide.md | 180 ++++++++++++++++ config/agents/tools.yml | 10 + enterprise/app/models/captain/assistant.rb | 29 ++- .../models/concerns/captain_tools_helpers.rb | 2 +- .../lib/captain/prompts/assistant.liquid | 8 +- .../lib/captain/tools/shopify_base_tool.rb | 158 ++++++++++++++ .../captain/tools/shopify_get_orders_tool.rb | 72 +++++++ .../tools/shopify_search_products_tool.rb | 53 +++++ shopify_advanced.md | 199 ++++++++++++++++++ .../captain/tools/shopify_base_tool_spec.rb | 80 +++++++ .../tools/shopify_get_orders_tool_spec.rb | 132 ++++++++++++ .../shopify_search_products_tool_spec.rb | 62 ++++++ .../models/captain/assistant_spec.rb | 75 +++++++ .../shopify/orders_service_spec.rb | 138 ++++++++++++ .../shopify/products_service_spec.rb | 123 +++++++++++ 19 files changed, 1730 insertions(+), 6 deletions(-) create mode 100644 app/services/integrations/shopify/client_service.rb create mode 100644 app/services/integrations/shopify/orders_service.rb create mode 100644 app/services/integrations/shopify/products_service.rb create mode 100644 captain-shopify-upgrade-guide.md create mode 100644 enterprise/lib/captain/tools/shopify_base_tool.rb create mode 100644 enterprise/lib/captain/tools/shopify_get_orders_tool.rb create mode 100644 enterprise/lib/captain/tools/shopify_search_products_tool.rb create mode 100644 shopify_advanced.md create mode 100644 spec/enterprise/lib/captain/tools/shopify_base_tool_spec.rb create mode 100644 spec/enterprise/lib/captain/tools/shopify_get_orders_tool_spec.rb create mode 100644 spec/enterprise/lib/captain/tools/shopify_search_products_tool_spec.rb create mode 100644 spec/enterprise/models/captain/assistant_spec.rb create mode 100644 spec/services/integrations/shopify/orders_service_spec.rb create mode 100644 spec/services/integrations/shopify/products_service_spec.rb diff --git a/app/helpers/shopify/integration_helper.rb b/app/helpers/shopify/integration_helper.rb index 6aad93211..cd622845f 100644 --- a/app/helpers/shopify/integration_helper.rb +++ b/app/helpers/shopify/integration_helper.rb @@ -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 # diff --git a/app/services/integrations/shopify/client_service.rb b/app/services/integrations/shopify/client_service.rb new file mode 100644 index 000000000..34422b81a --- /dev/null +++ b/app/services/integrations/shopify/client_service.rb @@ -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 diff --git a/app/services/integrations/shopify/orders_service.rb b/app/services/integrations/shopify/orders_service.rb new file mode 100644 index 000000000..2c6649b38 --- /dev/null +++ b/app/services/integrations/shopify/orders_service.rb @@ -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 diff --git a/app/services/integrations/shopify/products_service.rb b/app/services/integrations/shopify/products_service.rb new file mode 100644 index 000000000..502c5ba4c --- /dev/null +++ b/app/services/integrations/shopify/products_service.rb @@ -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 diff --git a/captain-shopify-upgrade-guide.md b/captain-shopify-upgrade-guide.md new file mode 100644 index 000000000..22f091016 --- /dev/null +++ b/captain-shopify-upgrade-guide.md @@ -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. diff --git a/config/agents/tools.yml b/config/agents/tools.yml index ff4f7d28f..9c5e0a8bf 100644 --- a/config/agents/tools.yml +++ b/config/agents/tools.yml @@ -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' diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index 4f039d57a..3d9fe2b5b 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -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 diff --git a/enterprise/app/models/concerns/captain_tools_helpers.rb b/enterprise/app/models/concerns/captain_tools_helpers.rb index 34133aac2..8a3cfaa89 100644 --- a/enterprise/app/models/concerns/captain_tools_helpers.rb +++ b/enterprise/app/models/concerns/captain_tools_helpers.rb @@ -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 diff --git a/enterprise/lib/captain/prompts/assistant.liquid b/enterprise/lib/captain/prompts/assistant.liquid index aa94ae1d4..908ee8343 100644 --- a/enterprise/lib/captain/prompts/assistant.liquid +++ b/enterprise/lib/captain/prompts/assistant.liquid @@ -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 diff --git a/enterprise/lib/captain/tools/shopify_base_tool.rb b/enterprise/lib/captain/tools/shopify_base_tool.rb new file mode 100644 index 000000000..cdf78ab28 --- /dev/null +++ b/enterprise/lib/captain/tools/shopify_base_tool.rb @@ -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 diff --git a/enterprise/lib/captain/tools/shopify_get_orders_tool.rb b/enterprise/lib/captain/tools/shopify_get_orders_tool.rb new file mode 100644 index 000000000..2977ac2b7 --- /dev/null +++ b/enterprise/lib/captain/tools/shopify_get_orders_tool.rb @@ -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 diff --git a/enterprise/lib/captain/tools/shopify_search_products_tool.rb b/enterprise/lib/captain/tools/shopify_search_products_tool.rb new file mode 100644 index 000000000..d2d693e0f --- /dev/null +++ b/enterprise/lib/captain/tools/shopify_search_products_tool.rb @@ -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 diff --git a/shopify_advanced.md b/shopify_advanced.md new file mode 100644 index 000000000..49497437d --- /dev/null +++ b/shopify_advanced.md @@ -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. diff --git a/spec/enterprise/lib/captain/tools/shopify_base_tool_spec.rb b/spec/enterprise/lib/captain/tools/shopify_base_tool_spec.rb new file mode 100644 index 000000000..454642873 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/shopify_base_tool_spec.rb @@ -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 diff --git a/spec/enterprise/lib/captain/tools/shopify_get_orders_tool_spec.rb b/spec/enterprise/lib/captain/tools/shopify_get_orders_tool_spec.rb new file mode 100644 index 000000000..28dd4ebd8 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/shopify_get_orders_tool_spec.rb @@ -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 diff --git a/spec/enterprise/lib/captain/tools/shopify_search_products_tool_spec.rb b/spec/enterprise/lib/captain/tools/shopify_search_products_tool_spec.rb new file mode 100644 index 000000000..e3d38a700 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/shopify_search_products_tool_spec.rb @@ -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 diff --git a/spec/enterprise/models/captain/assistant_spec.rb b/spec/enterprise/models/captain/assistant_spec.rb new file mode 100644 index 000000000..7900b2938 --- /dev/null +++ b/spec/enterprise/models/captain/assistant_spec.rb @@ -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 diff --git a/spec/services/integrations/shopify/orders_service_spec.rb b/spec/services/integrations/shopify/orders_service_spec.rb new file mode 100644 index 000000000..cea77c026 --- /dev/null +++ b/spec/services/integrations/shopify/orders_service_spec.rb @@ -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 diff --git a/spec/services/integrations/shopify/products_service_spec.rb b/spec/services/integrations/shopify/products_service_spec.rb new file mode 100644 index 000000000..bb0f85869 --- /dev/null +++ b/spec/services/integrations/shopify/products_service_spec.rb @@ -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