diff --git a/.gitignore b/.gitignore
index c64fb5c1b..bb0df62a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -94,3 +94,4 @@ yarn-debug.log*
.vscode
.claude/settings.local.json
.cursor
+CLAUDE.local.md
diff --git a/Gemfile b/Gemfile
index 6e4c6aaae..cfc8891df 100644
--- a/Gemfile
+++ b/Gemfile
@@ -180,7 +180,10 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
-gem 'ai-agents', '>= 0.2.1'
+gem 'ai-agents', '>= 0.4.3'
+
+# TODO: Move this gem as a dependency of ai-agents
+gem 'ruby_llm-schema'
gem 'shopify_api'
diff --git a/Gemfile.lock b/Gemfile.lock
index 345ed6660..9ab7f729e 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -25,35 +25,35 @@ GIT
GEM
remote: https://rubygems.org/
specs:
- actioncable (7.1.5.1)
- actionpack (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ actioncable (7.1.5.2)
+ actionpack (= 7.1.5.2)
+ activesupport (= 7.1.5.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
- actionmailbox (7.1.5.1)
- actionpack (= 7.1.5.1)
- activejob (= 7.1.5.1)
- activerecord (= 7.1.5.1)
- activestorage (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ actionmailbox (7.1.5.2)
+ actionpack (= 7.1.5.2)
+ activejob (= 7.1.5.2)
+ activerecord (= 7.1.5.2)
+ activestorage (= 7.1.5.2)
+ activesupport (= 7.1.5.2)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
- actionmailer (7.1.5.1)
- actionpack (= 7.1.5.1)
- actionview (= 7.1.5.1)
- activejob (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ actionmailer (7.1.5.2)
+ actionpack (= 7.1.5.2)
+ actionview (= 7.1.5.2)
+ activejob (= 7.1.5.2)
+ activesupport (= 7.1.5.2)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.2)
- actionpack (7.1.5.1)
- actionview (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ actionpack (7.1.5.2)
+ actionview (= 7.1.5.2)
+ activesupport (= 7.1.5.2)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
@@ -61,38 +61,38 @@ GEM
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
- actiontext (7.1.5.1)
- actionpack (= 7.1.5.1)
- activerecord (= 7.1.5.1)
- activestorage (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ actiontext (7.1.5.2)
+ actionpack (= 7.1.5.2)
+ activerecord (= 7.1.5.2)
+ activestorage (= 7.1.5.2)
+ activesupport (= 7.1.5.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (7.1.5.1)
- activesupport (= 7.1.5.1)
+ actionview (7.1.5.2)
+ activesupport (= 7.1.5.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
active_record_query_trace (1.8)
- activejob (7.1.5.1)
- activesupport (= 7.1.5.1)
+ activejob (7.1.5.2)
+ activesupport (= 7.1.5.2)
globalid (>= 0.3.6)
- activemodel (7.1.5.1)
- activesupport (= 7.1.5.1)
- activerecord (7.1.5.1)
- activemodel (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ activemodel (7.1.5.2)
+ activesupport (= 7.1.5.2)
+ activerecord (7.1.5.2)
+ activemodel (= 7.1.5.2)
+ activesupport (= 7.1.5.2)
timeout (>= 0.4.0)
activerecord-import (2.1.0)
activerecord (>= 4.2)
- activestorage (7.1.5.1)
- actionpack (= 7.1.5.1)
- activejob (= 7.1.5.1)
- activerecord (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ activestorage (7.1.5.2)
+ actionpack (= 7.1.5.2)
+ activejob (= 7.1.5.2)
+ activerecord (= 7.1.5.2)
+ activesupport (= 7.1.5.2)
marcel (~> 1.0)
- activesupport (7.1.5.1)
+ activesupport (7.1.5.2)
base64
benchmark (>= 0.3)
bigdecimal
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
- ai-agents (0.2.1)
+ ai-agents (0.4.3)
ruby_llm (~> 1.3)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
@@ -158,10 +158,10 @@ GEM
barnes (0.0.9)
multi_json (~> 1)
statsd-ruby (~> 1.1)
- base64 (0.2.0)
+ base64 (0.3.0)
bcrypt (3.1.20)
- benchmark (0.4.0)
- bigdecimal (3.1.9)
+ benchmark (0.4.1)
+ bigdecimal (3.2.2)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -602,20 +602,20 @@ GEM
rackup (1.0.1)
rack (< 3)
webrick
- rails (7.1.5.1)
- actioncable (= 7.1.5.1)
- actionmailbox (= 7.1.5.1)
- actionmailer (= 7.1.5.1)
- actionpack (= 7.1.5.1)
- actiontext (= 7.1.5.1)
- actionview (= 7.1.5.1)
- activejob (= 7.1.5.1)
- activemodel (= 7.1.5.1)
- activerecord (= 7.1.5.1)
- activestorage (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ rails (7.1.5.2)
+ actioncable (= 7.1.5.2)
+ actionmailbox (= 7.1.5.2)
+ actionmailer (= 7.1.5.2)
+ actionpack (= 7.1.5.2)
+ actiontext (= 7.1.5.2)
+ actionview (= 7.1.5.2)
+ activejob (= 7.1.5.2)
+ activemodel (= 7.1.5.2)
+ activerecord (= 7.1.5.2)
+ activestorage (= 7.1.5.2)
+ activesupport (= 7.1.5.2)
bundler (>= 1.15.0)
- railties (= 7.1.5.1)
+ railties (= 7.1.5.2)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -623,9 +623,9 @@ GEM
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
- railties (7.1.5.1)
- actionpack (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ railties (7.1.5.2)
+ actionpack (= 7.1.5.2)
+ activesupport (= 7.1.5.2)
irb
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -723,7 +723,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
- ruby_llm (1.3.1)
+ ruby_llm (1.5.1)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -732,6 +732,7 @@ GEM
faraday-retry (>= 1)
marcel (~> 1.0)
zeitwerk (~> 2)
+ ruby_llm-schema (0.1.0)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -916,7 +917,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
- ai-agents (>= 0.2.1)
+ ai-agents (>= 0.4.3)
annotate
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1010,6 +1011,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
+ ruby_llm-schema
scout_apm
scss_lint
seed_dump
diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb
index 18538e842..af96441f8 100644
--- a/app/controllers/api/v1/accounts/portals_controller.rb
+++ b/app/controllers/api/v1/accounts/portals_controller.rb
@@ -26,9 +26,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain
process_attached_logo if params[:blob_id].present?
- rescue StandardError => e
- Rails.logger.error e
- render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
+ rescue ActiveRecord::RecordInvalid => e
+ render_record_invalid(e)
end
end
diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
index e7a1f3fa6..3e7d876c3 100644
--- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
+++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
@@ -1,8 +1,10 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :validate_feature_enabled!
+ before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
- # Handles the embedded signup callback data from the Facebook SDK
+ # Handles both initial authorization and reauthorization
+ # If inbox_id is present in params, it performs reauthorization
def create
validate_embedded_signup_params!
channel = process_embedded_signup
@@ -16,21 +18,42 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
- code: params[:code],
- business_id: params[:business_id],
- waba_id: params[:waba_id],
- phone_number_id: params[:phone_number_id]
+ params: params.permit(:code, :business_id, :waba_id, :phone_number_id).to_h.symbolize_keys,
+ inbox_id: params[:inbox_id]
)
service.perform
end
- def render_success_response(inbox)
+ def fetch_and_validate_inbox
+ @inbox = Current.account.inboxes.find(params[:inbox_id])
+ validate_reauthorization_required
+ end
+
+ def validate_reauthorization_required
+ return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
+
render json: {
+ success: false,
+ message: I18n.t('inbox.reauthorization.not_required')
+ }, status: :unprocessable_entity
+ end
+
+ def can_upgrade_to_embedded_signup?
+ channel = @inbox.channel
+ return false unless channel.provider == 'whatsapp_cloud'
+
+ true
+ end
+
+ def render_success_response(inbox)
+ response = {
success: true,
id: inbox.id,
name: inbox.name,
channel_type: 'whatsapp'
}
+ response[:message] = I18n.t('inbox.reauthorization.success') if params[:inbox_id].present?
+ render json: response
end
def render_error_response(error)
diff --git a/app/controllers/platform/api/v1/accounts_controller.rb b/app/controllers/platform/api/v1/accounts_controller.rb
index e11cf9d4a..4521930a6 100644
--- a/app/controllers/platform/api/v1/accounts_controller.rb
+++ b/app/controllers/platform/api/v1/accounts_controller.rb
@@ -1,4 +1,11 @@
class Platform::Api::V1::AccountsController < PlatformController
+ def index
+ @resources = @platform_app.platform_app_permissibles
+ .where(permissible_type: 'Account')
+ .includes(:permissible)
+ .map(&:permissible)
+ end
+
def show; end
def create
diff --git a/app/controllers/slack_uploads_controller.rb b/app/controllers/slack_uploads_controller.rb
index 127e77649..6f157c7d5 100644
--- a/app/controllers/slack_uploads_controller.rb
+++ b/app/controllers/slack_uploads_controller.rb
@@ -17,7 +17,12 @@ class SlackUploadsController < ApplicationController
end
def blob_url
- url_for(@blob.representation(resize_to_fill: [250, nil]))
+ # Only generate representations for images
+ if @blob.content_type.start_with?('image/')
+ url_for(@blob.representation(resize_to_fill: [250, nil]))
+ else
+ url_for(@blob)
+ end
end
def avatar_url
diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb
index 455828228..d607ba151 100644
--- a/app/controllers/twilio/callback_controller.rb
+++ b/app/controllers/twilio/callback_controller.rb
@@ -30,7 +30,8 @@ class Twilio::CallbackController < ApplicationController
:NumMedia,
:Latitude,
:Longitude,
- :MessageType
+ :MessageType,
+ :ProfileName
)
end
end
diff --git a/app/finders/notification_finder.rb b/app/finders/notification_finder.rb
index ccfe470a0..e1958827a 100644
--- a/app/finders/notification_finder.rb
+++ b/app/finders/notification_finder.rb
@@ -15,7 +15,13 @@ class NotificationFinder
end
def unread_count
- @notifications.where(read_at: nil).count
+ if type_included?('read')
+ # If we're including read notifications, filter to unread
+ @notifications.where(read_at: nil).count
+ else
+ # Already filtered to unread notifications, just count
+ @notifications.count
+ end
end
def count
@@ -27,7 +33,7 @@ class NotificationFinder
def set_up
find_all_notifications
filter_snoozed_notifications
- fitler_read_notifications
+ filter_read_notifications
end
def find_all_notifications
@@ -38,7 +44,7 @@ class NotificationFinder
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
end
- def fitler_read_notifications
+ def filter_read_notifications
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
end
diff --git a/app/helpers/reporting_event_helper.rb b/app/helpers/reporting_event_helper.rb
index e08b5691c..f0a419cc8 100644
--- a/app/helpers/reporting_event_helper.rb
+++ b/app/helpers/reporting_event_helper.rb
@@ -18,12 +18,25 @@ module ReportingEventHelper
end
def last_non_human_activity(conversation)
- # check if a handoff event already exists
- handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last
+ # Try to get either a handoff or reopened event first
+ # These will always take precedence over any other activity
+ # Also, any of these events can happen at any time in the course of a conversation lifecycle.
+ # So we pick the latest event
+ event = ReportingEvent.where(
+ conversation_id: conversation.id,
+ name: %w[conversation_bot_handoff conversation_opened]
+ ).order(event_end_time: :desc).first
- # if a handoff exists, last non human activity is when the handoff ended,
- # otherwise it's when the conversation was created
- handoff_event&.event_end_time || conversation.created_at
+ return event.event_end_time if event&.event_end_time
+
+ # Fallback to bot resolved event
+ # Because this will be closest to the most accurate activity instead of conversation.created_at
+ bot_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_resolved').last
+
+ return bot_event.event_end_time if bot_event&.event_end_time
+
+ # If no events found, return conversation creation time
+ conversation.created_at
end
private
diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue
index 0fbb20ea9..8da7e7476 100644
--- a/app/javascript/dashboard/App.vue
+++ b/app/javascript/dashboard/App.vue
@@ -137,7 +137,6 @@ export default {
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex flex-col w-full h-screen min-h-0"
- :class="{ 'app-rtl--wrapper': isRTL }"
:dir="isRTL ? 'rtl' : 'ltr'"
>
- {{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
-
-
+ {{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
+
+