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/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/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_controller.rb b/app/controllers/api/v1/accounts_controller.rb index f6c40feb9..7c8b939f4 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 - permitted = params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label) + permitted = params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label, + conversation_required_attributes: []) permitted[:captain_models] = params[:captain_models].to_unsafe_h if params[:captain_models].present? permitted[:captain_features] = params[:captain_features].to_unsafe_h if params[:captain_features].present? permitted 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/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/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 });