diff --git a/AGENTS.md b/AGENTS.md index ef1d3b26d..474fe6e7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,13 @@ - Avoid writing specs unless explicitly asked - Remove dead/unreachable/unused code - Don’t write multiple versions or backups for the same logic — pick the best approach and implement it +- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs +- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors + +## Commit Messages + +- Prefer Conventional Commits: `type(scope): subject` (scope optional) +- Example: `feat(auth): add user authentication` - Don't reference Claude in commit messages ## Project-Specific @@ -78,3 +85,4 @@ Practical checklist for any change impacting core logic or public APIs - Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs. - When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift. - Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable. +- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`. diff --git a/Gemfile.lock b/Gemfile.lock index b42472ef4..0a669b606 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -140,24 +140,27 @@ GEM actionmailbox (>= 7.1.0) aws-sdk-s3 (~> 1, >= 1.123.0) aws-sdk-sns (~> 1, >= 1.61.0) - aws-eventstream (1.2.0) - aws-partitions (1.760.0) - aws-sdk-core (3.188.0) - aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.5) + aws-eventstream (1.4.0) + aws-partitions (1.1198.0) + aws-sdk-core (3.240.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.64.0) - aws-sdk-core (~> 3, >= 3.165.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.126.0) - aws-sdk-core (~> 3, >= 3.174.0) + logger + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.208.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) + aws-sigv4 (~> 1.5) aws-sdk-sns (1.70.0) aws-sdk-core (~> 3, >= 3.188.0) aws-sigv4 (~> 1.1) - aws-sigv4 (1.5.2) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) barnes (0.0.9) multi_json (~> 1) @@ -827,6 +830,7 @@ GEM faraday-net_http (>= 1) faraday-retry (>= 1) marcel (~> 1.0) + ruby_llm-schema (~> 0.2.1) zeitwerk (~> 2) ruby_llm-schema (0.2.5) ruby_parser (3.20.0) diff --git a/app/builders/year_in_review_builder.rb b/app/builders/year_in_review_builder.rb new file mode 100644 index 000000000..545fe8029 --- /dev/null +++ b/app/builders/year_in_review_builder.rb @@ -0,0 +1,74 @@ +class YearInReviewBuilder + attr_reader :account, :user_id, :year + + def initialize(account:, user_id:, year:) + @account = account + @user_id = user_id + @year = year + end + + def build + { + year: year, + total_conversations: total_conversations_count, + busiest_day: busiest_day_data, + support_personality: support_personality_data + } + end + + private + + def year_range + @year_range ||= begin + start_time = Time.zone.local(year, 1, 1).beginning_of_day + end_time = Time.zone.local(year, 12, 31).end_of_day + start_time..end_time + end + end + + def total_conversations_count + account.conversations + .where(assignee_id: user_id, created_at: year_range) + .count + end + + def busiest_day_data + daily_counts = account.conversations + .where(assignee_id: user_id, created_at: year_range) + .group_by_day(:created_at, range: year_range, time_zone: Time.zone) + .count + + return nil if daily_counts.empty? + + busiest_date, count = daily_counts.max_by { |_date, cnt| cnt } + + return nil if count.zero? + + { + date: busiest_date.strftime('%b %d'), + count: count + } + end + + def support_personality_data + response_time = average_response_time + + return { avg_response_time_seconds: 0 } if response_time.nil? + + { + avg_response_time_seconds: response_time.to_i + } + end + + def average_response_time + avg_time = account.reporting_events + .where( + name: 'first_response', + user_id: user_id, + created_at: year_range + ) + .average(:value) + + avg_time&.to_f + end +end diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 3d894808d..0840d0eea 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -1,4 +1,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController + include AttachmentConcern + before_action :check_authorization before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone] @@ -9,25 +11,32 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont def show; end def create + blobs, actions, error = validate_and_prepare_attachments(params[:actions]) + return render_could_not_create_error(error) if error + @automation_rule = Current.account.automation_rules.new(automation_rules_permit) - @automation_rule.actions = params[:actions] + @automation_rule.actions = actions @automation_rule.conditions = params[:conditions] - render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid? + return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid? @automation_rule.save! - process_attachments - @automation_rule + blobs.each { |blob| @automation_rule.files.attach(blob) } end def update - ActiveRecord::Base.transaction do - automation_rule_update - process_attachments + blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule) + return render_could_not_create_error(error) if error + ActiveRecord::Base.transaction do + @automation_rule.assign_attributes(automation_rules_permit) + @automation_rule.actions = actions if params[:actions] + @automation_rule.conditions = params[:conditions] if params[:conditions] + @automation_rule.save! + blobs.each { |blob| @automation_rule.files.attach(blob) } rescue StandardError => e Rails.logger.error e - render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity + render_could_not_create_error(@automation_rule.errors.messages) end end @@ -43,29 +52,11 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont @automation_rule = new_rule end - def process_attachments - actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' } - return if actions.blank? - - actions.each do |action| - blob_id = action['action_params'] - blob = ActiveStorage::Blob.find_by(id: blob_id) - @automation_rule.files.attach(blob) - end - end - private - def automation_rule_update - @automation_rule.update!(automation_rules_permit) - @automation_rule.actions = params[:actions] if params[:actions] - @automation_rule.conditions = params[:conditions] if params[:conditions] - @automation_rule.save! - end - def automation_rules_permit params.permit( - :name, :description, :event_name, :account_id, :active, + :name, :description, :event_name, :active, conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }], actions: [:action_name, { action_params: [] }] ) diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb index 58ec3bfca..f3b14d49f 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: def permitted_params params.require(:twilio_channel).permit( - :account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid + :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid ) end end diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb new file mode 100644 index 000000000..d17fe35fb --- /dev/null +++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb @@ -0,0 +1,160 @@ +class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController + DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze + DEFAULT_LANGUAGE = 'en'.freeze + + before_action :fetch_inbox + before_action :validate_whatsapp_channel + + def show + template = @inbox.csat_config&.dig('template') + return render json: { template_exists: false } unless template + + template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id) + status_result = @inbox.channel.provider_service.get_template_status(template_name) + + render_template_status_response(status_result, template_name) + rescue StandardError => e + Rails.logger.error "Error fetching CSAT template status: #{e.message}" + render json: { error: e.message }, status: :internal_server_error + end + + def create + template_params = extract_template_params + return render_missing_message_error if template_params[:message].blank? + + # Delete existing template even though we are using a new one. + # We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails. + delete_existing_template_if_needed + + result = create_template_via_provider(template_params) + render_template_creation_result(result) + rescue ActionController::ParameterMissing + render json: { error: 'Template parameters are required' }, status: :unprocessable_entity + rescue StandardError => e + Rails.logger.error "Error creating CSAT template: #{e.message}" + render json: { error: 'Template creation failed' }, status: :internal_server_error + end + + private + + def fetch_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + authorize @inbox, :show? + end + + def validate_whatsapp_channel + return if @inbox.whatsapp? + + render json: { error: 'CSAT template operations only available for WhatsApp channels' }, + status: :bad_request + end + + def extract_template_params + params.require(:template).permit(:message, :button_text, :language) + end + + def render_missing_message_error + render json: { error: 'Message is required' }, status: :unprocessable_entity + end + + def create_template_via_provider(template_params) + template_config = { + message: template_params[:message], + button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT, + base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'), + language: template_params[:language] || DEFAULT_LANGUAGE, + template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id) + } + + @inbox.channel.provider_service.create_csat_template(template_config) + end + + def render_template_creation_result(result) + if result[:success] + render_successful_template_creation(result) + else + render_failed_template_creation(result) + end + end + + def render_successful_template_creation(result) + render json: { + template: { + name: result[:template_name], + template_id: result[:template_id], + status: 'PENDING', + language: result[:language] || DEFAULT_LANGUAGE + } + }, status: :created + end + + def render_failed_template_creation(result) + whatsapp_error = parse_whatsapp_error(result[:response_body]) + error_message = whatsapp_error[:user_message] || result[:error] + + render json: { + error: error_message, + details: whatsapp_error[:technical_details] + }, status: :unprocessable_entity + end + + def delete_existing_template_if_needed + template = @inbox.csat_config&.dig('template') + return true if template.blank? + + template_name = template['name'] + return true if template_name.blank? + + template_status = @inbox.channel.provider_service.get_template_status(template_name) + return true unless template_status[:success] + + deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name) + if deletion_result[:success] + Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}" + true + else + Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}" + false + end + rescue StandardError => e + Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}" + false + end + + def render_template_status_response(status_result, template_name) + if status_result[:success] + render json: { + template_exists: true, + template_name: template_name, + status: status_result[:template][:status], + template_id: status_result[:template][:id] + } + else + render json: { + template_exists: false, + error: 'Template not found' + } + end + end + + def parse_whatsapp_error(response_body) + return { user_message: nil, technical_details: nil } if response_body.blank? + + begin + error_data = JSON.parse(response_body) + whatsapp_error = error_data['error'] || {} + + user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message'] + technical_details = { + code: whatsapp_error['code'], + subcode: whatsapp_error['error_subcode'], + type: whatsapp_error['type'], + title: whatsapp_error['error_user_title'] + }.compact + + { user_message: user_message, technical_details: technical_details } + rescue JSON::ParserError + { user_message: nil, technical_details: response_body } + end + end +end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index ae1d4369a..1c8845c04 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -152,31 +152,37 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def format_csat_config(config) - { - display_type: config['display_type'] || 'emoji', - message: config['message'] || '', - survey_rules: { - operator: config.dig('survey_rules', 'operator') || 'contains', - values: config.dig('survey_rules', 'values') || [] - } + formatted = { + 'display_type' => config['display_type'] || 'emoji', + 'message' => config['message'] || '', + :survey_rules => { + 'operator' => config.dig('survey_rules', 'operator') || 'contains', + 'values' => config.dig('survey_rules', 'values') || [] + }, + 'button_text' => config['button_text'] || 'Please rate us', + 'language' => config['language'] || 'en' } + format_template_config(config, formatted) + formatted + end + + def format_template_config(config, formatted) + formatted['template'] = config['template'] if config['template'].present? end def inbox_attributes [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, - { csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }] + { csat_config: [:display_type, :message, :button_text, :language, + { survey_rules: [:operator, { values: [] }], + template: [:name, :template_id, :created_at, :language] }] }] end def permitted_params(channel_attributes = []) # We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend params.each { |k, v| params[k] = params[k] == 'null' ? nil : v } - - params.permit( - *inbox_attributes, - channel: [:type, *channel_attributes] - ) + params.permit(*inbox_attributes, channel: [:type, *channel_attributes]) end def channel_type_from_params @@ -192,11 +198,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def get_channel_attributes(channel_type) - if channel_type.constantize.const_defined?(:EDITABLE_ATTRS) - channel_type.constantize::EDITABLE_ATTRS.presence - else - [] - end + channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : [] end def whatsapp_channel? diff --git a/app/controllers/api/v1/accounts/macros_controller.rb b/app/controllers/api/v1/accounts/macros_controller.rb index 5dcdd2023..c4e0cd6dd 100644 --- a/app/controllers/api/v1/accounts/macros_controller.rb +++ b/app/controllers/api/v1/accounts/macros_controller.rb @@ -1,4 +1,6 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController + include AttachmentConcern + before_action :fetch_macro, only: [:show, :update, :destroy, :execute] before_action :check_authorization, only: [:show, :update, :destroy, :execute] @@ -11,26 +13,32 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController end def create + blobs, actions, error = validate_and_prepare_attachments(params[:actions]) + return render_could_not_create_error(error) if error + @macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id)) @macro.set_visibility(current_user, permitted_params) - @macro.actions = params[:actions] + @macro.actions = actions - render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid? + return render_could_not_create_error(@macro.errors.messages) unless @macro.valid? @macro.save! - process_attachments - @macro + blobs.each { |blob| @macro.files.attach(blob) } end def update + blobs, actions, error = validate_and_prepare_attachments(params[:actions], @macro) + return render_could_not_create_error(error) if error + ActiveRecord::Base.transaction do - @macro.update!(macros_with_user) + @macro.assign_attributes(macros_with_user) @macro.set_visibility(current_user, permitted_params) - process_attachments + @macro.actions = actions if params[:actions] @macro.save! + blobs.each { |blob| @macro.files.attach(blob) } rescue StandardError => e Rails.logger.error e - render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity + render_could_not_create_error(@macro.errors.messages) end end @@ -47,20 +55,9 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController private - def process_attachments - actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' } - return if actions.blank? - - actions.each do |action| - blob_id = action['action_params'] - blob = ActiveStorage::Blob.find_by(id: blob_id) - @macro.files.attach(blob) - end - end - def permitted_params params.permit( - :name, :account_id, :visibility, + :name, :visibility, actions: [:action_name, { action_params: [] }] ) end diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 57344cc1e..8eb24b757 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def process_attached_logo blob_id = params[:blob_id] - blob = ActiveStorage::Blob.find_by(id: blob_id) + blob = ActiveStorage::Blob.find_signed(blob_id) @portal.logo.attach(blob) end @@ -78,7 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def portal_params params.require(:portal).permit( - :id, :account_id, :color, :custom_domain, :header_text, :homepage_link, + :id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] } ) end diff --git a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb new file mode 100644 index 000000000..7c7320393 --- /dev/null +++ b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb @@ -0,0 +1,15 @@ +class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController + include Tiktok::IntegrationHelper + + def create + redirect_url = Tiktok::AuthClient.authorize_url( + state: generate_tiktok_token(Current.account.id) + ) + + if redirect_url + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end +end diff --git a/app/controllers/api/v1/accounts/upload_controller.rb b/app/controllers/api/v1/accounts/upload_controller.rb index 6530279da..479d8ae1b 100644 --- a/app/controllers/api/v1/accounts/upload_controller.rb +++ b/app/controllers/api/v1/accounts/upload_controller.rb @@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController end def render_success(file_blob) - render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id } + render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id } end def render_error(message, status) diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 773126755..57062a5b2 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -92,7 +92,8 @@ class Api::V1::AccountsController < Api::BaseController end def settings_params - params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label) + params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label, + conversation_required_attributes: []) end def check_signup_enabled diff --git a/app/controllers/api/v2/accounts/year_in_reviews_controller.rb b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb new file mode 100644 index 000000000..7946614bb --- /dev/null +++ b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb @@ -0,0 +1,26 @@ +class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController + def show + year = params[:year] || 2025 + cache_key = "year_in_review_#{Current.account.id}_#{year}" + + cached_data = Current.user.ui_settings&.dig(cache_key) + + if cached_data.present? + render json: cached_data + else + builder = YearInReviewBuilder.new( + account: Current.account, + user_id: Current.user.id, + year: year + ) + + data = builder.build + + ui_settings = Current.user.ui_settings || {} + ui_settings[cache_key] = data + Current.user.update(ui_settings: ui_settings) + + render json: data + end + end +end diff --git a/app/controllers/concerns/attachment_concern.rb b/app/controllers/concerns/attachment_concern.rb new file mode 100644 index 000000000..2652f04be --- /dev/null +++ b/app/controllers/concerns/attachment_concern.rb @@ -0,0 +1,35 @@ +module AttachmentConcern + extend ActiveSupport::Concern + + def validate_and_prepare_attachments(actions, record = nil) + blobs = [] + return [blobs, actions, nil] if actions.blank? + + sanitized = actions.map do |action| + next action unless action[:action_name] == 'send_attachment' + + result = process_attachment_action(action, record, blobs) + return [nil, nil, I18n.t('errors.attachments.invalid')] unless result + + result + end + + [blobs, sanitized, nil] + end + + private + + def process_attachment_action(action, record, blobs) + blob_id = action[:action_params].first + blob = ActiveStorage::Blob.find_signed(blob_id.to_s) + + return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present? + return action if blob_already_attached?(record, blob_id) + + nil + end + + def blob_already_attached?(record, blob_id) + record&.files&.any? { |f| f.blob_id == blob_id.to_i } + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 5003c4c70..abf42517c 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -73,6 +73,7 @@ class DashboardController < ActionController::Base ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''), + TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'), WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''), WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 1a9539bb6..ec51305b5 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -46,6 +46,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], + 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET], 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION], 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN] diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb new file mode 100644 index 000000000..e484905c3 --- /dev/null +++ b/app/controllers/tiktok/callbacks_controller.rb @@ -0,0 +1,144 @@ +class Tiktok::CallbacksController < ApplicationController + include Tiktok::IntegrationHelper + + def show + return handle_authorization_error if params[:error].present? + return handle_ungranted_scopes_error unless all_scopes_granted? + + process_successful_authorization + rescue StandardError => e + handle_error(e) + end + + private + + def all_scopes_granted? + granted_scopes = short_term_access_token[:scope].to_s.split(',') + (Tiktok::AuthClient::REQUIRED_SCOPES - granted_scopes).blank? + end + + def process_successful_authorization + inbox, already_exists = find_or_create_inbox + + if already_exists + redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) + else + redirect_to app_tiktok_inbox_agents_url(account_id: account_id, inbox_id: inbox.id) + end + end + + def handle_error(error) + Rails.logger.error("TikTok Channel creation Error: #{error.message}") + ChatwootExceptionTracker.new(error).capture_exception + + redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message) + end + + # Handles the case when a user denies permissions or cancels the authorization flow + def handle_authorization_error + redirect_to_error_page( + error_type: params[:error] || 'access_denied', + code: params[:error_code], + error_message: params[:error_description] || 'User cancelled the Authorization' + ) + end + + # Handles the case when a user partially accepted the required scopes + def handle_ungranted_scopes_error + redirect_to_error_page( + error_type: 'ungranted_scopes', + code: 400, + error_message: 'User did not grant all the required scopes' + ) + end + + # Centralized method to redirect to error page with appropriate parameters + # This ensures consistent error handling across different error scenarios + # Frontend will handle the error page based on the error_type + def redirect_to_error_page(error_type:, code:, error_message:) + redirect_to app_new_tiktok_inbox_url( + account_id: account_id, + error_type: error_type, + code: code, + error_message: error_message + ) + end + + def find_or_create_inbox + business_details = tiktok_client.business_account_details + channel_tiktok = find_channel + channel_exists = channel_tiktok.present? + + if channel_tiktok + update_channel(channel_tiktok, business_details) + else + channel_tiktok = create_channel_with_inbox(business_details) + end + + # reauthorized will also update cache keys for the associated inbox + channel_tiktok.reauthorized! + + set_avatar(channel_tiktok.inbox, business_details[:profile_image]) if business_details[:profile_image].present? + + [channel_tiktok.inbox, channel_exists] + end + + def create_channel_with_inbox(business_details) + ActiveRecord::Base.transaction do + channel_tiktok = Channel::Tiktok.create!( + account: account, + business_id: short_term_access_token[:business_id], + access_token: short_term_access_token[:access_token], + refresh_token: short_term_access_token[:refresh_token], + expires_at: short_term_access_token[:expires_at], + refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at] + ) + + account.inboxes.create!( + account: account, + channel: channel_tiktok, + name: business_details[:display_name].presence || business_details[:username] + ) + + channel_tiktok + end + end + + def find_channel + Channel::Tiktok.find_by(business_id: short_term_access_token[:business_id], account: account) + end + + def update_channel(channel_tiktok, business_details) + channel_tiktok.update!( + access_token: short_term_access_token[:access_token], + refresh_token: short_term_access_token[:refresh_token], + expires_at: short_term_access_token[:expires_at], + refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at] + ) + + channel_tiktok.inbox.update!(name: business_details[:display_name].presence || business_details[:username]) + end + + def set_avatar(inbox, avatar_url) + Avatar::AvatarFromUrlJob.perform_later(inbox, avatar_url) + end + + def account_id + @account_id ||= verify_tiktok_token(params[:state]) + end + + def account + @account ||= Account.find(account_id) + end + + def short_term_access_token + @short_term_access_token ||= Tiktok::AuthClient.obtain_short_term_access_token(params[:code]) + end + + def tiktok_client + @tiktok_client ||= Tiktok::Client.new( + business_id: short_term_access_token[:business_id], + access_token: short_term_access_token[:access_token] + ) + end +end diff --git a/app/controllers/webhooks/tiktok_controller.rb b/app/controllers/webhooks/tiktok_controller.rb new file mode 100644 index 000000000..efaa1830c --- /dev/null +++ b/app/controllers/webhooks/tiktok_controller.rb @@ -0,0 +1,53 @@ +class Webhooks::TiktokController < ActionController::API + before_action :verify_signature! + + def events + event = JSON.parse(request_payload) + if echo_event? + # Add delay to prevent race condition where echo arrives before send message API completes + # This avoids duplicate messages when echo comes early during API processing + ::Webhooks::TiktokEventsJob.set(wait: 2.seconds).perform_later(event) + else + ::Webhooks::TiktokEventsJob.perform_later(event) + end + + head :ok + end + + private + + def request_payload + @request_payload ||= request.body.read + end + + def verify_signature! + signature_header = request.headers['Tiktok-Signature'] + client_secret = GlobalConfigService.load('TIKTOK_APP_SECRET', nil) + received_timestamp, received_signature = extract_signature_parts(signature_header) + + return head :unauthorized unless client_secret && received_timestamp && received_signature + + signature_payload = "#{received_timestamp}.#{request_payload}" + computed_signature = OpenSSL::HMAC.hexdigest('SHA256', client_secret, signature_payload) + + return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed_signature, received_signature) + + # Check timestamp delay (acceptable delay: 5 seconds) + current_timestamp = Time.current.to_i + delay = current_timestamp - received_timestamp + + return head :unauthorized if delay > 5 + end + + def extract_signature_parts(signature_header) + return [nil, nil] if signature_header.blank? + + keys = signature_header.split(',') + signature_parts = keys.map { |part| part.split('=') }.to_h + [signature_parts['t']&.to_i, signature_parts['s']] + end + + def echo_event? + params[:event] == 'im_send_msg' + end +end diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml index b05c603cd..34c7a8138 100644 --- a/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -78,6 +78,12 @@ instagram: enabled: true icon: 'icon-instagram' config_key: 'instagram' +tiktok: + name: 'TikTok' + description: 'Stay connected with your customers on TikTok' + enabled: true + icon: 'icon-tiktok' + config_key: 'tiktok' whatsapp: name: 'WhatsApp' description: 'Manage your WhatsApp business interactions from Chatwoot.' diff --git a/app/helpers/tiktok/integration_helper.rb b/app/helpers/tiktok/integration_helper.rb new file mode 100644 index 000000000..b2de4a092 --- /dev/null +++ b/app/helpers/tiktok/integration_helper.rb @@ -0,0 +1,47 @@ +module Tiktok::IntegrationHelper + # Generates a signed JWT token for Tiktok integration + # + # @param account_id [Integer] The account ID to encode in the token + # @return [String, nil] The encoded JWT token or nil if client secret is missing + def generate_tiktok_token(account_id) + return if client_secret.blank? + + JWT.encode(token_payload(account_id), client_secret, 'HS256') + rescue StandardError => e + Rails.logger.error("Failed to generate TikTok token: #{e.message}") + nil + end + + # Verifies and decodes a Tiktok JWT token + # + # @param token [String] The JWT token to verify + # @return [Integer, nil] The account ID from the token or nil if invalid + def verify_tiktok_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_secret + @client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil) + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + def decode_token(token, secret) + JWT.decode(token, secret, true, { + algorithm: 'HS256', + verify_expiration: true + }).first['sub'] + rescue StandardError => e + Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}") + nil + end +end diff --git a/app/javascript/dashboard/api/channel/tiktokClient.js b/app/javascript/dashboard/api/channel/tiktokClient.js new file mode 100644 index 000000000..389eb2699 --- /dev/null +++ b/app/javascript/dashboard/api/channel/tiktokClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class TiktokChannel extends ApiClient { + constructor() { + super('tiktok', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new TiktokChannel(); diff --git a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js new file mode 100644 index 000000000..67f74a171 --- /dev/null +++ b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js @@ -0,0 +1,95 @@ +import { Device } from '@twilio/voice-sdk'; +import VoiceAPI from './voiceAPIClient'; + +const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected'); + +class TwilioVoiceClient extends EventTarget { + constructor() { + super(); + this.device = null; + this.activeConnection = null; + this.initialized = false; + this.inboxId = null; + } + + async initializeDevice(inboxId) { + this.destroyDevice(); + + const response = await VoiceAPI.getToken(inboxId); + const { token, account_id } = response || {}; + if (!token) throw new Error('Invalid token'); + + this.device = new Device(token, { + allowIncomingWhileBusy: true, + disableAudioContextSounds: true, + appParams: { account_id }, + }); + + this.device.removeAllListeners(); + this.device.on('connect', conn => { + this.activeConnection = conn; + conn.on('disconnect', this.onDisconnect); + }); + + this.device.on('disconnect', this.onDisconnect); + + this.device.on('tokenWillExpire', async () => { + const r = await VoiceAPI.getToken(this.inboxId); + if (r?.token) this.device.updateToken(r.token); + }); + + this.initialized = true; + this.inboxId = inboxId; + + return this.device; + } + + get hasActiveConnection() { + return !!this.activeConnection; + } + + endClientCall() { + if (this.activeConnection) { + this.activeConnection.disconnect(); + } + this.activeConnection = null; + if (this.device) { + this.device.disconnectAll(); + } + } + + destroyDevice() { + if (this.device) { + this.device.destroy(); + } + this.activeConnection = null; + this.device = null; + this.initialized = false; + this.inboxId = null; + } + + async joinClientCall({ to, conversationId }) { + if (!this.device || !this.initialized || !to) return null; + if (this.activeConnection) return this.activeConnection; + + const params = { + To: to, + is_agent: 'true', + conversation_id: conversationId, + }; + + const connection = await this.device.connect({ params }); + this.activeConnection = connection; + + connection.on('disconnect', this.onDisconnect); + + return connection; + } + + onDisconnect = () => { + this.activeConnection = null; + this.dispatchEvent(createCallDisconnectedEvent()); + }; +} + +export default new TwilioVoiceClient(); diff --git a/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js b/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js new file mode 100644 index 000000000..6e1e548c8 --- /dev/null +++ b/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js @@ -0,0 +1,40 @@ +/* global axios */ +import ApiClient from '../../ApiClient'; +import ContactsAPI from '../../contacts'; + +class VoiceAPI extends ApiClient { + constructor() { + super('voice', { accountScoped: true }); + } + + // eslint-disable-next-line class-methods-use-this + initiateCall(contactId, inboxId) { + return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data); + } + + leaveConference(inboxId, conversationId) { + return axios + .delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { + params: { conversation_id: conversationId }, + }) + .then(r => r.data); + } + + joinConference({ conversationId, inboxId, callSid }) { + return axios + .post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { + conversation_id: conversationId, + call_sid: callSid, + }) + .then(r => r.data); + } + + getToken(inboxId) { + if (!inboxId) return Promise.reject(new Error('Inbox ID is required')); + return axios + .get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`) + .then(r => r.data); + } +} + +export default new VoiceAPI(); diff --git a/app/javascript/dashboard/api/specs/tiktokClient.spec.js b/app/javascript/dashboard/api/specs/tiktokClient.spec.js new file mode 100644 index 000000000..5250e2c7b --- /dev/null +++ b/app/javascript/dashboard/api/specs/tiktokClient.spec.js @@ -0,0 +1,35 @@ +import ApiClient from '../ApiClient'; +import tiktokClient from '../channel/tiktokClient'; + +describe('#TiktokClient', () => { + it('creates correct instance', () => { + expect(tiktokClient).toBeInstanceOf(ApiClient); + expect(tiktokClient).toHaveProperty('generateAuthorization'); + }); + + describe('#generateAuthorization', () => { + const originalAxios = window.axios; + const originalPathname = window.location.pathname; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + window.history.pushState({}, '', '/app/accounts/1/settings'); + }); + + afterEach(() => { + window.axios = originalAxios; + window.history.pushState({}, '', originalPathname); + }); + + it('posts to the authorization endpoint', () => { + tiktokClient.generateAuthorization({ state: 'test-state' }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/tiktok/authorization', + { state: 'test-state' } + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/yearInReview.js b/app/javascript/dashboard/api/yearInReview.js new file mode 100644 index 000000000..fb0661804 --- /dev/null +++ b/app/javascript/dashboard/api/yearInReview.js @@ -0,0 +1,16 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class YearInReviewAPI extends ApiClient { + constructor() { + super('year_in_review', { accountScoped: true, apiVersion: 'v2' }); + } + + get(year) { + return axios.get(`${this.url}`, { + params: { year }, + }); + } +} + +export default new YearInReviewAPI(); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue index e77a0c10d..3dc29738e 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue @@ -44,6 +44,7 @@ const SOCIAL_CONFIG = { LINKEDIN: 'i-ri-linkedin-box-fill', FACEBOOK: 'i-ri-facebook-circle-fill', INSTAGRAM: 'i-ri-instagram-line', + TIKTOK: 'i-ri-tiktok-fill', TWITTER: 'i-ri-twitter-x-fill', GITHUB: 'i-ri-github-fill', }; @@ -65,6 +66,7 @@ const defaultState = { facebook: '', github: '', instagram: '', + tiktok: '', linkedin: '', twitter: '', }, diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue index 75400692e..5ac469088 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue @@ -40,7 +40,12 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });