Compare commits

..
Author SHA1 Message Date
Shivam Mishra b8fd2c61fb feat: kickstart lsq transcript attachment 2025-10-27 20:30:27 +05:30
40 changed files with 781 additions and 1147 deletions
+2 -2
View File
@@ -23,7 +23,7 @@ Metrics/MethodLength:
- 'enterprise/lib/captain/agent.rb'
RSpec/ExampleLength:
Max: 50
Max: 25
Style/Documentation:
Enabled: false
@@ -336,4 +336,4 @@ FactoryBot/RedundantFactoryOption:
Enabled: false
FactoryBot/FactoryAssociationWithStrategy:
Enabled: false
Enabled: false
-1
View File
@@ -21,7 +21,6 @@ gem 'telephone_number'
gem 'time_diff'
gem 'tzinfo-data'
gem 'valid_email2'
gem 'octokit'
# compress javascript config.assets.js_compressor
gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --##
-7
View File
@@ -591,9 +591,6 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
octokit (10.0.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
@@ -820,9 +817,6 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
scout_apm (5.3.3)
parser
scss_lint (0.60.0)
@@ -1063,7 +1057,6 @@ DEPENDENCIES
net-smtp (~> 0.3.4)
newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm
octokit
omniauth (>= 2.1.2)
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
@@ -1,155 +0,0 @@
class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::BaseController
before_action :fetch_hook
before_action :ensure_hook_exists, except: [:destroy]
def destroy
@hook.destroy!
head :ok
end
def repositories
repositories = github_service.repositories
filtered_repos = repositories.map do |repo|
{
full_name: repo[:full_name] || repo.full_name,
private: repo[:private] || repo.private
}
end
render json: filtered_repos
end
def search_repositories
repositories = github_service.search_repositories(params[:q])
filtered_repos = repositories.map do |repo|
{
full_name: repo[:full_name] || repo.full_name,
private: repo[:private] || repo.private
}
end
render json: filtered_repos
end
def assignees
assignees = github_service.assignees(repo_full_name)
filtered_assignees = assignees.map do |assignee|
{
login: assignee.login,
avatar_url: assignee.avatar_url
}
end
render json: filtered_assignees
end
def labels
labels = github_service.labels(repo_full_name)
filtered_labels = labels.map do |label|
{
name: label.name,
color: label.color
}
end
render json: filtered_labels
end
def search_issues
issues = github_service.search_issues(repo_full_name, params[:q])
filtered_issues = issues.map do |issue|
{
number: issue.number,
title: issue.title,
html_url: issue.html_url
}
end
render json: filtered_issues
end
def create_issue
issue_data = github_service.create_issue(
repo_full_name,
params[:title],
params[:body],
issue_options
)
github_issue = create_linked_issue(issue_data)
render json: issue_response(github_issue), status: :created
end
def link_issue
issue_data = github_service.issue(repo_full_name, params[:issue_number])
github_issue = create_linked_issue(issue_data)
render json: issue_response(github_issue), status: :created
end
def linked_issues
issues = GithubIssue.where(conversation_id: params[:conversation_id])
render json: issues.map do |issue|
{
id: issue.id,
issue_number: issue.issue_number,
title: issue.issue_title,
html_url: issue.html_url,
repo_full_name: issue.repo_full_name,
linked_by: {
id: issue.linked_by.id,
name: issue.linked_by.name
},
created_at: issue.created_at
}
end
end
def unlink_issue
github_issue = GithubIssue.find(params[:id])
github_issue.destroy!
head :ok
end
private
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'github')
end
def ensure_hook_exists
render json: { error: 'GitHub integration not configured' }, status: :unauthorized unless @hook
end
def github_service
@github_service ||= Github::GithubService.new(hook: @hook)
end
def repo_full_name
params[:repo_full_name] || "#{params[:owner]}/#{params[:repo]}"
end
def issue_options
options = {}
options[:assignees] = params[:assignees] if params[:assignees].present?
options[:labels] = params[:labels] if params[:labels].present?
options
end
def create_linked_issue(issue_data)
GithubIssue.create!(
conversation_id: params[:conversation_id],
account: Current.account,
repo_full_name: repo_full_name,
issue_number: issue_data.number,
issue_title: issue_data.title,
html_url: issue_data.html_url,
linked_by: Current.user
)
end
def issue_response(github_issue)
{
id: github_issue.id,
issue_number: github_issue.issue_number,
title: github_issue.issue_title,
html_url: github_issue.html_url,
repo_full_name: github_issue.repo_full_name
}
end
end
@@ -1,160 +0,0 @@
class Github::CallbacksController < ApplicationController
include Github::IntegrationHelper
def show
# Validate account context early for all flows that require it
account if params[:code].present?
if params[:installation_id].present? && params[:code].present?
# Both installation and OAuth code present - handle both
handle_installation_with_oauth
elsif params[:installation_id].present?
# Only installation_id present - redirect to OAuth
handle_installation
else
# Only OAuth code present - handle authorization
handle_authorization
end
rescue StandardError => e
Rails.logger.error("Github callback error: #{e.message}")
redirect_to fallback_redirect_uri
end
private
def handle_installation_with_oauth
# Handle both installation and OAuth in one go
installation_id = params[:installation_id]
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/github/callback"
)
handle_response(installation_id)
end
def handle_installation
if params[:setup_action] == 'install'
installation_id = params[:installation_id]
redirect_to build_oauth_url(installation_id)
else
Rails.logger.error("Unknown setup_action: #{params[:setup_action]}")
redirect_to github_integration_settings_url
end
end
def handle_authorization
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/github/callback"
)
handle_response
end
def build_oauth_url(installation_id)
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
# Store installation_id in session for later use
session[:github_installation_id] = installation_id
# For now, redirect to a page that will initiate OAuth with proper account context
# This is a temporary solution until we have a proper account-agnostic setup
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github?setup_action=install&installation_id=#{installation_id}"
end
def oauth_client
app_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
OAuth2::Client.new(
app_id,
app_secret,
{
site: 'https://github.com',
token_url: '/login/oauth/access_token',
authorize_url: '/login/oauth/authorize'
}
)
end
def handle_response(installation_id = nil)
settings = build_hook_settings(installation_id)
hook = create_integration_hook(settings)
hook.save!
cleanup_session_data
redirect_to github_redirect_uri
rescue StandardError => e
Rails.logger.error("Github callback error: #{e.message}")
redirect_to fallback_redirect_uri
end
def build_hook_settings(installation_id)
settings = {
token_type: parsed_body['token_type'],
scope: parsed_body['scope']
}
settings[:installation_id] = installation_id || session[:github_installation_id]
settings.compact
end
def create_integration_hook(settings)
account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'github',
settings: settings
)
end
def cleanup_session_data
session.delete(:github_installation_id)
end
def account
@account ||= account_from_state
end
def account_from_state
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
# Try signed GlobalID first (installation flow)
account = GlobalID::Locator.locate_signed(params[:state])
return account if account
# Fallback to JWT token (direct OAuth flow)
account_id = verify_github_token(params[:state])
return Account.find(account_id) if account_id
raise 'Invalid or expired state'
rescue StandardError
raise ActionController::BadRequest, 'Invalid account context'
end
def github_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/github"
end
def github_integration_settings_url
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github"
end
def fallback_redirect_uri
github_redirect_uri
rescue StandardError
# Fallback if no account context available
"#{ENV.fetch('FRONTEND_URL', nil)}/app/settings/integrations"
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -42,8 +42,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'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],
'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
-47
View File
@@ -1,47 +0,0 @@
module Github::IntegrationHelper
# Generates a signed JWT token for Github 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_github_token(account_id)
return if github_client_secret.blank?
JWT.encode(github_token_payload(account_id), github_client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Github token: #{e.message}")
nil
end
def github_token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
# Verifies and decodes a Github 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_github_token(token)
return if token.blank? || github_client_secret.blank?
github_decode_token(token, github_client_secret)
end
private
def github_client_secret
@github_client_secret ||= GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
end
def github_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 Github token: #{e.message}")
nil
end
end
@@ -15,20 +15,6 @@
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"GITHUB": {
"TITLE": "Connect Github",
"LABEL": "Github Client ID",
"PLACEHOLDER": "Enter your Github Client ID",
"HELP": "Enter your Github Client ID",
"CANCEL": "Cancel",
"SUBMIT": "Connect",
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
}
},
"HEADER": "Integrations",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations",
@@ -352,7 +338,6 @@
}
}
},
"CAPTAIN": {
"NAME": "Captain",
"HEADER_KNOW_MORE": "Know more",
@@ -1,57 +0,0 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
const store = useStore();
const integrationLoaded = ref(false);
const integration = useFunctionGetter('integrations/getIntegration', 'github');
const uiFlags = useMapGetter('integrations/getUIFlags');
const integrationAction = computed(() => {
if (integration.value.enabled) {
return 'disconnect';
}
return integration.value.action;
});
const initializeGithubIntegration = async () => {
await store.dispatch('integrations/get', 'github');
integrationLoaded.value = true;
};
onMounted(() => {
initializeGithubIntegration();
});
</script>
<template>
<div class="flex-grow flex-shrink max-w-6xl p-4 mx-auto overflow-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingGithub">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.TITLE'),
message: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.MESSAGE'),
}"
/>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>
@@ -10,7 +10,7 @@ import SettingsContent from '../Wrapper.vue';
import Linear from './Linear.vue';
import Notion from './Notion.vue';
import Shopify from './Shopify.vue';
import Github from './Github.vue';
export default {
routes: [
{
@@ -100,16 +100,6 @@ export default {
},
props: route => ({ code: route.query.code }),
},
{
path: 'github',
name: 'settings_integrations_github',
component: Github,
meta: {
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
permissions: ['administrator'],
},
props: route => ({ error: route.query.error }),
},
{
path: 'shopify',
name: 'settings_integrations_shopify',
+3 -32
View File
@@ -1,6 +1,5 @@
class Integrations::App
include Linear::IntegrationHelper
include Github::IntegrationHelper
attr_accessor :params
def initialize(params)
@@ -31,15 +30,10 @@ class Integrations::App
params[:fields]
end
# There is no way to get the account_id from the linear/github callback
# so we are using the generate token method to generate a token and encode it in the state parameter
# There is no way to get the account_id from the linear callback
# so we are using the generate_linear_token method to generate a token and encode it in the state parameter
def encode_state
case params[:id]
when 'linear'
generate_linear_token(Current.account.id)
when 'github'
generate_github_token(Current.account.id)
end
generate_linear_token(Current.account.id)
end
def action
@@ -49,8 +43,6 @@ class Integrations::App
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
when 'linear'
build_linear_action
when 'github'
build_github_action
else
params[:action]
end
@@ -62,8 +54,6 @@ class Integrations::App
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'github'
github_enabled?
when 'shopify'
shopify_enabled?(account)
when 'leadsquared'
@@ -88,17 +78,6 @@ class Integrations::App
].join('&')
end
def build_github_action
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
# For GitHub Apps, redirect to installation page
# The standard /installations/new URL should show org/user selection if the app is properly configured
# Include state parameter with signed account ID for account context
github_app_name = GlobalConfigService.load('GITHUB_APP_NAME', 'chatwoot-qa')
state = Current.account.to_signed_global_id(expires_in: 1.hour)
"https://github.com/apps/#{github_app_name}/installations/new?state=#{state}"
end
def enabled?(account)
case params[:id]
when 'webhook'
@@ -122,10 +101,6 @@ class Integrations::App
"#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback"
end
def self.github_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/github/callback"
end
class << self
def apps
Hashie::Mash.new(APPS_CONFIG)
@@ -144,10 +119,6 @@ class Integrations::App
private
def github_enabled?
GlobalConfigService.load('GITHUB_CLIENT_ID', nil).present? && GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil).present?
end
def shopify_enabled?(account)
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
end
+24
View File
@@ -259,6 +259,30 @@ class Message < ApplicationRecord
true
end
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def plain_text_content
# For email messages, extract clean plain text from HTML or text content
if incoming_email? && content_attributes.present? && content_attributes[:email].present?
email_content = content_attributes[:email]
# Try text content first
if email_content[:text_content].present?
text_content = email_content[:text_content][:reply] || email_content[:text_content][:full]
return text_content if text_content.present?
end
# Fall back to HTML content, strip HTML tags
if email_content[:html_content].present?
html_content = email_content[:html_content][:reply] || email_content[:html_content][:full]
return ActionView::Base.full_sanitizer.sanitize(html_content) if html_content.present?
end
end
# For non-email messages, return content as-is
content
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
private
def prevent_message_flooding
@@ -23,7 +23,8 @@ class Crm::Leadsquared::Api::ActivityClient < Crm::Leadsquared::Api::BaseClient
body = {
'ActivityEventName' => name,
'Score' => score.to_i,
'Direction' => direction.to_i
'Direction' => direction.to_i,
'AllowAttachments' => 1
}
response = post(path, {}, body)
@@ -33,4 +34,56 @@ class Crm::Leadsquared::Api::ActivityClient < Crm::Leadsquared::Api::BaseClient
def fetch_activity_types
get('ProspectActivity.svc/ActivityTypes.Get')
end
# https://apidocs.leadsquared.com/file-upload-to-leads/
def upload_file(activity_event_code, file_name, file_content)
raise ArgumentError, 'Activity event code is required' if activity_event_code.blank?
raise ArgumentError, 'File name is required' if file_name.blank?
raise ArgumentError, 'File content is required' if file_content.blank?
# Derive the files host from the endpoint URL
files_host = derive_files_host
form_data = {
FileType: 0, # 0 = Document
Entity: 1, # 1 = Activity
Id: activity_event_code.to_s,
FileStorageType: 0, # 0 = Permanent
EnableResize: false,
AccessKey: @access_key,
SecretKey: @secret_key
}
multipart_post("#{files_host}/File/Upload", file_name, file_content, form_data)
end
# https://apidocs.leadsquared.com/attach-a-file-to-activities/#api
def attach_file_to_activity(activity_id, file_url, file_name, description = nil)
raise ArgumentError, 'Activity ID is required' if activity_id.blank?
raise ArgumentError, 'File URL is required' if file_url.blank?
raise ArgumentError, 'File name is required' if file_name.blank?
path = 'ProspectActivity.svc/Attachment/Add'
body = {
'ProspectActivityId' => activity_id,
'AttachmentName' => file_name,
'Description' => description || 'Full conversation transcript',
'AttachmentFile' => file_url
}
response = post(path, {}, body)
response['Message']['Id']
end
private
def derive_files_host
# Convert API host to files host
# api-in21.leadsquared.com -> files-in21.leadsquared.com
# api-us11.leadsquared.com -> files-us11.leadsquared.com
# api.leadsquared.com -> files.leadsquared.com
host = URI.parse(@base_uri).host
files_host = host.gsub(/^api(-)?/, 'files\1')
"https://#{files_host}"
end
end
@@ -43,6 +43,26 @@ class Crm::Leadsquared::Api::BaseClient
handle_response(response)
end
def multipart_post(full_url, file_name, file_content, form_data = {})
# Create temp file for upload
temp_file = Tempfile.new([File.basename(file_name, '.*'), File.extname(file_name)])
temp_file.binmode
temp_file.write(file_content)
temp_file.rewind
options = {
body: form_data.merge({
uploadFiles: temp_file
})
}
response = self.class.post(full_url, options)
handle_response(response)
ensure
temp_file&.close
temp_file&.unlink
end
private
def headers
@@ -88,7 +88,24 @@ class Crm::Leadsquared::Mappers::ConversationMapper
end
def message_content(message)
message.content.presence || I18n.t('crm.no_content')
# Use plain_text_content for email messages to get clean text
content = message.incoming_email? ? message.plain_text_content : message.content
content.presence || I18n.t('crm.no_content')
end
def full_transcript_text
# Generate complete transcript without truncation for file upload
separator = "\n\n"
header = "Conversation Transcript from #{brand_name}\n\n"
header += "Channel: #{conversation.inbox.name}\n"
header += "Conversation ID: #{conversation.display_id}\n"
header += "View in #{brand_name}: #{conversation_url}\n\n"
header += "Transcript:\n"
header += "#{('=' * 50)}\n\n"
messages_text = transcript_messages.reverse.map { |message| format_message(message) }.join(separator)
header + messages_text
end
def attachment_info(message)
@@ -45,13 +45,22 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
return unless @allow_transcript
return unless conversation.status == 'resolved'
create_conversation_activity(
conversation: conversation,
activity_type: 'transcript',
activity_code_key: 'transcript_activity_code',
metadata_key: 'transcript_activity_id',
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(@hook, conversation)
)
mapper = Crm::Leadsquared::Mappers::ConversationMapper.new(@hook, conversation)
full_transcript = mapper.full_transcript_text
activity_note = mapper.transcript_activity
# Check if full transcript exceeds the limit
if full_transcript.length > Crm::Leadsquared::Mappers::ConversationMapper::ACTIVITY_NOTE_MAX_SIZE
create_transcript_with_attachment(conversation, activity_note, full_transcript)
else
create_conversation_activity(
conversation: conversation,
activity_type: 'transcript',
activity_code_key: 'transcript_activity_code',
metadata_key: 'transcript_activity_id',
activity_note: activity_note
)
end
end
private
@@ -118,4 +127,45 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
lead_id
end
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def create_transcript_with_attachment(conversation, activity_note, full_transcript)
lead_id = get_lead_id(conversation.contact)
return if lead_id.blank?
activity_code = get_activity_code('transcript_activity_code')
# Add note about attached file to the activity note
note_with_attachment_info = "#{activity_note}\n\n[Full transcript attached as file due to length]"
# Create the activity first
activity_id = @activity_client.post_activity(lead_id, activity_code, note_with_attachment_info)
return if activity_id.blank?
# Upload the full transcript as a text file
file_name = "conversation_#{conversation.display_id}_transcript.txt"
upload_response = @activity_client.upload_file(activity_code, file_name, full_transcript)
# Attach the file to the activity using the S3 URL
if upload_response['s3FilePath'].present?
@activity_client.attach_file_to_activity(
activity_id,
upload_response['s3FilePath'],
file_name,
"Full transcript for conversation ##{conversation.display_id}"
)
Rails.logger.info("Attached full transcript file to LeadSquared activity #{activity_id} for conversation #{conversation.id}")
end
# Store the activity ID
metadata = { 'transcript_activity_id' => activity_id }
store_conversation_metadata(conversation, metadata)
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
Rails.logger.error "LeadSquared API error uploading transcript: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
Rails.logger.error "Error uploading transcript to LeadSquared: #{e.message}"
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
end
-60
View File
@@ -1,60 +0,0 @@
class Github::GithubService
attr_reader :hook
def initialize(hook:)
@hook = hook
end
def repositories
client.repositories(nil, type: 'all')
rescue Octokit::Error => e
Rails.logger.error("GitHub API error fetching repositories: #{e.message}")
raise_api_error(e)
end
def assignees(repo_full_name)
client.repository_assignees(repo_full_name)
rescue Octokit::Error => e
Rails.logger.error("GitHub API error fetching assignees for #{repo_full_name}: #{e.message}")
raise_api_error(e)
end
def search_repositories(query)
return repositories if query.blank?
# Get all user's repositories first
user_repos = repositories
# Filter repositories by query string (case-insensitive)
user_repos.select do |repo|
repo.full_name.downcase.include?(query.downcase) ||
(repo.description&.downcase&.include?(query.downcase))
end
rescue Octokit::Error => e
Rails.logger.error("GitHub API error searching repositories with query '#{query}': #{e.message}")
raise_api_error(e)
end
private
def client
@client ||= Octokit::Client.new(
access_token: hook.access_token,
auto_paginate: true,
per_page: 100
)
end
def raise_api_error(error)
case error.response_status
when 401
raise CustomExceptions::Base.new('GitHub authentication failed', 401)
when 403
raise CustomExceptions::Base.new('GitHub API rate limit exceeded or insufficient permissions', 403)
when 404
raise CustomExceptions::Base.new('GitHub resource not found', 404)
else
raise CustomExceptions::Base.new("GitHub API error: #{error.message}", error.response_status || 500)
end
end
end
@@ -44,12 +44,6 @@ class Twilio::IncomingMessageService
twilio_channel.sms? ? params[:From] : params[:From].gsub('whatsapp:', '')
end
def normalized_phone_number
return phone_number unless twilio_channel.whatsapp?
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio)
end
def formatted_phone_number
TelephoneNumber.parse(phone_number).international_number
end
@@ -59,10 +53,8 @@ class Twilio::IncomingMessageService
end
def set_contact
source_id = twilio_channel.whatsapp? ? normalized_phone_number : params[:From]
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: source_id,
source_id: params[:From],
inbox: inbox,
contact_attributes: contact_attributes
).perform
@@ -47,8 +47,17 @@ module Whatsapp::IncomingMessageServiceHelpers
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
end
def argentina_phone_number?(phone_number)
phone_number.match(/^54/)
end
def normalised_argentina_mobil_number(phone_number)
# Remove 9 before country code
phone_number.sub(/^549/, '54')
end
def processed_waid(waid)
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(waid, :cloud)
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
end
def error_webhook_event?(message)
@@ -1,32 +1,23 @@
# Service to handle phone number normalization for WhatsApp messages
# Currently supports Brazil and Argentina phone number format variations
# Supports both WhatsApp Cloud API and Twilio WhatsApp providers
# Designed to be extensible for additional countries in future PRs
#
# Usage: Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
class Whatsapp::PhoneNumberNormalizationService
def initialize(inbox)
@inbox = inbox
end
# @param raw_number [String] The phone number in provider-specific format
# - Cloud: "5541988887777" (clean number)
# - Twilio: "whatsapp:+5541988887777" (prefixed format)
# @param provider [Symbol] :cloud or :twilio
# @return [String] Normalized source_id in provider format or original if not found
def normalize_and_find_contact_by_provider(raw_number, provider)
# Extract clean number based on provider format
clean_number = extract_clean_number(raw_number, provider)
# Main entry point for phone number normalization
# Returns the source_id of an existing contact if found, otherwise returns original waid
def normalize_and_find_contact(waid)
normalizer = find_normalizer_for_country(waid)
return waid unless normalizer
# Find appropriate normalizer for the country
normalizer = find_normalizer_for_country(clean_number)
return raw_number unless normalizer
normalized_waid = normalizer.normalize(waid)
existing_contact_inbox = find_existing_contact_inbox(normalized_waid)
# Normalize the clean number
normalized_clean_number = normalizer.normalize(clean_number)
# Format for provider and check for existing contact
provider_format = format_for_provider(normalized_clean_number, provider)
existing_contact_inbox = find_existing_contact_inbox(provider_format)
existing_contact_inbox&.source_id || raw_number
existing_contact_inbox&.source_id || waid
end
private
@@ -42,26 +33,6 @@ class Whatsapp::PhoneNumberNormalizationService
inbox.contact_inboxes.find_by(source_id: normalized_waid)
end
# Extract clean number from provider-specific format
def extract_clean_number(raw_number, provider)
case provider
when :twilio
raw_number.gsub(/^whatsapp:\+/, '') # Remove prefix: "whatsapp:+5541988887777" → "5541988887777"
else
raw_number # Default fallback for unknown providers
end
end
# Format normalized number for provider-specific storage
def format_for_provider(clean_number, provider)
case provider
when :twilio
"whatsapp:+#{clean_number}" # Add prefix: "5541988887777" → "whatsapp:+5541988887777"
else
clean_number # Default for :cloud and unknown providers: "5541988887777"
end
end
NORMALIZERS = [
Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer,
Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer
@@ -169,11 +169,7 @@
<symbol id="icon-shopify" viewBox="0 0 32 32">
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol>
<symbol id="icon-github" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</symbol>
<symbol id="icon-slack" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
</symbol>

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

-3
View File
@@ -176,9 +176,6 @@
- name: notion_integration
display_name: Notion Integration
enabled: false
- name: github_integration
display_name: Github Integration
enabled: false
- name: captain_integration_v2
display_name: Captain V2
enabled: false
-19
View File
@@ -451,22 +451,3 @@
locked: false
description: 'Token expiry in days'
## ------ End of Customizations for Customers ------ ##
# ------- Github Integration Config ------- #
- name: GITHUB_CLIENT_ID
display_title: 'Github Client ID'
value:
locked: false
description: 'Github client ID'
- name: GITHUB_CLIENT_SECRET
display_title: 'Github Client Secret'
value:
locked: false
description: 'Github client secret'
type: secret
- name: GITHUB_APP_NAME
display_title: 'Github App Name'
value:
locked: false
description: 'Github App name for installation URLs (e.g., "chatwoot-qa")'
## ------ End of Github Integration Config ------- #
-8
View File
@@ -284,11 +284,3 @@ leadsquared:
'conversation_activity_score',
'transcript_activity_score',
]
github:
id: github
logo: github.png
i18n_key: github
hook_type: account
action: https://github.com/login/oauth/authorize
allow_multiple_hooks: false
-4
View File
@@ -312,10 +312,6 @@ en:
name: 'LeadSquared'
short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
github:
name: 'GitHub'
short_description: 'Create and manage GitHub issues directly from conversations.'
description: 'Connect your GitHub repositories to create issues directly from customer conversations. This integration helps you track bugs, feature requests, and development tasks seamlessly.'
captain:
copilot_message_required: Message is required
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
-12
View File
@@ -302,14 +302,6 @@ Rails.application.routes.draw do
delete :destroy
end
end
resource :github, controller: 'github', only: [] do
collection do
delete :destroy
get :repositories
get :search_repositories
get 'repositories/:owner/:repo/assignees', to: 'github#assignees', as: :repository_assignees
end
end
end
resources :working_hours, only: [:update]
@@ -557,10 +549,6 @@ Rails.application.routes.draw do
end
end
namespace :github do
get :callback, to: 'callbacks#show'
end
get 'microsoft/callback', to: 'microsoft/callbacks#show'
get 'google/callback', to: 'google/callbacks#show'
get 'instagram/callback', to: 'instagram/callbacks#show'
@@ -1,6 +0,0 @@
class AddIndexToConversationsIdentifier < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_index :conversations, [:identifier, :account_id], name: 'index_conversations_on_identifier_and_account_id', algorithm: :concurrently
end
end
+1 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_10_22_152158) do
ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -676,7 +676,6 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_22_152158) do
t.index ["contact_id"], name: "index_conversations_on_contact_id"
t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id"
t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at"
t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id"
t.index ["inbox_id"], name: "index_conversations_on_inbox_id"
t.index ["priority"], name: "index_conversations_on_priority"
t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id"
+71
View File
@@ -0,0 +1,71 @@
# LeadSquared CRM Integration (Backend)
## High-Level Flow
- Accounts enable the `leadsquared` app via `config/integration/apps.yml`; the app is gated by the `crm_integration` feature flag and exposes settings for API keys, activity toggles, and scores.
- Creating an `Integrations::Hook` (`app/models/integrations/hook.rb`) for this app stores credentials, marks the hook as `account`-scoped, and enqueues `Crm::SetupJob` to prepare LeadSquared-side prerequisites.
- `Crm::SetupJob` (`app/jobs/crm/setup_job.rb`) instantiates `Crm::Leadsquared::SetupService` to resolve the tenant-specific API endpoint/timezone and ensure required custom activity types exist. The job persists setup output back into the hooks `settings` json.
- Runtime events (`contact.updated`, `conversation.created`, `conversation.resolved`) fan out through `HookListener` (`app/listeners/hook_listener.rb`) to `HookJob` (`app/jobs/hook_job.rb`), which serialises processing per hook with a Redis mutex and delegates to `Crm::Leadsquared::ProcessorService`.
- Processor services under `app/services/crm/leadsquared/` orchestrate LeadSquared API calls, map Chatwoot data into LeadSquared payloads, and store external IDs/activity metadata on contacts and conversations to retain linkage.
## Key Responsibilities by File
- `config/integration/apps.yml`: Declares the LeadSquared app, required credentials (`access_key`, `secret_key`), optional activity toggles, score defaults, and visibility of stored settings. The schema enforces the shape of hook `settings` coming from the UI.
- `app/models/integrations/hook.rb`: Persists integration hooks, enforces uniqueness per account, validates settings via the JSON schema above, checks the `crm_integration` feature flag, and enqueues CRM setup after creation. Stores a deterministic encrypted `access_token` and exposes `feature_allowed?`.
- `app/jobs/crm/setup_job.rb`: Background job that loads the hook, short-circuits disabled/missing hooks, and invokes the right CRM-specific setup service (currently only LeadSquared).
- `app/services/crm/leadsquared/setup_service.rb`: Calls LeadSquareds `Authentication.svc/UserByAccessKey.Get` to derive tenant-specific API and app hosts, updates hook settings (`endpoint_url`, `app_url`, `timezone`), and ensures custom activity types for conversation start/transcript exist (creating them if absent) while persisting their numeric codes.
- `app/jobs/hook_job.rb`: Consumes domain events and routes them per integration. For LeadSquared it locks on `Redis::Alfred::CRM_PROCESS_MUTEX` (`lib/redis/redis_keys.rb`) to avoid creating duplicate leads during rapid event bursts, then passes processing to the CRM service.
- `app/services/crm/base_processor_service.rb`: Shared helpers for CRM processors—enforces interface (`handle_contact_created/updated`, `handle_conversation_created/resolved`), provides helpers for checking contact identifiability, reading/storing external IDs on contacts, and attaching metadata to conversations.
- `app/services/crm/leadsquared/processor_service.rb`: Main runtime handler. Wires API clients, honours the hooks `enable_*` toggles, processes contacts (create/update leads) and conversations (post LeadSquared activities), and records resulting IDs/metadata back to Chatwoot models. Uses `ChatwootExceptionTracker` for surfaced errors.
- `app/services/crm/leadsquared/lead_finder_service.rb`: Retrieves an existing lead ID from stored contact metadata, searches LeadSquared by email or phone when absent, and creates a new lead as a last resort.
- `app/services/crm/leadsquared/api/base_client.rb`: Thin HTTParty wrapper that signs requests with LeadSquared access/secret keys, centralises response parsing, and raises `ApiError` on non-success or malformed responses.
- `app/services/crm/leadsquared/api/lead_client.rb`: Exposes search/update/create operations for leads, including `create_or_update_lead` (default LeadSquared behaviour) and an explicit `update_lead` used when the Chatwoot contact already stores the external ID.
- `app/services/crm/leadsquared/api/activity_client.rb`: Manages posting activities against leads and creating/fetching activity types.
- `app/services/crm/leadsquared/mappers/contact_mapper.rb`: Normalises Chatwoot contact fields to LeadSquared attribute keys, reformats phone numbers to `+<country>-<national>` form, and injects brand/source info.
- `app/services/crm/leadsquared/mappers/conversation_mapper.rb`: Builds human-readable activity notes for conversation creation and transcript events, trimming content to LeadSquared limits and linking back to the Chatwoot conversation URL.
## Lifecycle by Use Case
### Hook Creation & Setup
1. Admin enables the integration, supplying at least `access_key`/`secret_key`.
2. `Integrations::Hook` persists the hook (`hook_type: account`) and enqueues `Crm::SetupJob`.
3. `SetupService` resolves tenant metadata, resets clients to the tenant-specific base URL, and ensures two activity types:
- `<Brand> Conversation Started`
- `<Brand> Conversation Transcript`
Their numeric codes and resolved URLs/timezone are stored back into `hook.settings`.
4. Subsequent API calls use the tenant-specific endpoint and the hooks stored codes/timezone.
### Contact Sync (`contact.updated`)
- Trigger: `HookListener#contact_updated` dispatches when a Chatwoot contact changes.
- `HookJob` locks per hook, instantiates `Crm::Leadsquared::ProcessorService`, and calls `handle_contact`.
- Processor reloads the latest contact, skips if lacking email/phone/social profile, and retrieves any stored LeadSquared ID.
- With an ID it calls `LeadClient#update_lead`; otherwise it calls `LeadClient#create_or_update_lead` and persists the returned `ProspectID` into `contact.additional_attributes['external']['leadsquared_id']`.
- Any API/runtime error is captured via `ChatwootExceptionTracker` and logged; failures do not retry via the mutex job by default.
### Conversation Created (`conversation.created`)
- Trigger: `HookListener#conversation_created`.
- `HookJob` locks and delegates to `handle_conversation_created`.
- Processor exits early if the hook has `enable_conversation_activity` disabled.
- Uses `LeadFinderService` to fetch or create the lead ID tied to the conversations contact (persisting it if newly discovered).
- Builds the activity note via `ConversationMapper.map_conversation_activity`, fetches the configured `conversation_activity_code`, and posts the activity with `ActivityClient#post_activity`.
- Stores the resulting activity ID under `conversation.additional_attributes['leadsquared']['created_activity_id']` for traceability.
### Conversation Resolved (`conversation.resolved`)
- Trigger: `HookListener#conversation_resolved` when status transitions to `resolved`.
- `HookJob` locks and calls `handle_conversation_resolved`.
- Processor requires `enable_transcript_activity` to be true and the conversation status to still be `resolved`.
- Builds a transcript note (reverse-chronological, capped at 1,800 chars, including attachments) via `ConversationMapper.map_transcript_activity`.
- Posts the transcript activity using `transcript_activity_code` and records the returned ID as `conversation.additional_attributes['leadsquared']['transcript_activity_id']`.
## Stored Metadata & Settings
- Hook settings persist credentials, resolved endpoint/app URLs, timezone, activity toggles, scores, and the created activity type codes. These values feed processor behaviour and the frontends “visible properties” list.
- Contacts store `additional_attributes['external']['leadsquared_id']` once a LeadSquared prospect exists.
- Conversations store a nested `additional_attributes['leadsquared']` hash with any activity IDs created.
## Error Handling & Concurrency
- All API wrappers raise `ApiError` on non-success; callers rescue to log and forward to `ChatwootExceptionTracker`.
- `HookJob` inherits from `MutexApplicationJob`; coupled with the per-hook Redis mutex it prevents concurrent processing of contact/conversation events that would otherwise create duplicate leads.
- Unsupported events or disabled hooks short-circuit in `HookListener`/`HookJob`, avoiding unnecessary API calls.
## Extensibility Notes
- The LeadSquared implementation follows the `Crm::BaseProcessorService` contract, simplifying future CRM additions.
- Setup work is encapsulated so additional CRMs can hook into `Crm::SetupJob#create_setup_service`.
- Feature flag (`crm_integration`) and hook JSON schema guardrails ensure enterprise-only exposure and predictable payloads.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

@@ -10,7 +10,7 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
context 'when authenticated and authorized' do
before do
# Create conversations across 24 hours at different times
base_time = Time.utc(2024, 1, 14, 23, 0) # Start at 23:00 to span 2 days
base_time = Time.utc(2024, 1, 15, 0, 0) # Start at midnight UTC
# Create conversations every 4 hours across 24 hours
6.times do |i|
@@ -23,38 +23,36 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
end
it 'timezone_offset affects data grouping and timestamps correctly' do
travel_to Time.utc(2024, 1, 15, 12, 0) do
Time.use_zone('UTC') do
base_time = Time.utc(2024, 1, 14, 23, 0) # Start at 23:00 to span 2 days
base_params = {
metric: 'conversations_count',
type: 'account',
since: (base_time - 1.day).to_i.to_s,
until: (base_time + 2.days).to_i.to_s,
group_by: 'day'
}
Time.use_zone('UTC') do
base_time = Time.utc(2024, 1, 15, 0, 0)
base_params = {
metric: 'conversations_count',
type: 'account',
since: (base_time - 1.day).to_i.to_s,
until: (base_time + 2.days).to_i.to_s,
group_by: 'day'
}
responses = [0, -8, 9].map do |offset|
get "/api/v2/accounts/#{account.id}/reports",
params: base_params.merge(timezone_offset: offset),
headers: admin.create_new_auth_token, as: :json
response.parsed_body
end
data_entries = responses.map { |r| r.select { |e| e['value'] > 0 } }
totals = responses.map { |r| r.sum { |e| e['value'] } }
timestamps = responses.map { |r| r.map { |e| e['timestamp'] } }
# Data conservation and redistribution
expect(totals.uniq).to eq([6])
expect(data_entries[0].map { |e| e['value'] }).to eq([1, 5])
expect(data_entries[1].map { |e| e['value'] }).to eq([3, 3])
expect(data_entries[2].map { |e| e['value'] }).to eq([4, 2])
# Timestamp differences
expect(timestamps.uniq.size).to eq(3)
timestamps[0].zip(timestamps[1]).each { |utc, pst| expect(utc - pst).to eq(-28_800) }
responses = [0, -8, 9].map do |offset|
get "/api/v2/accounts/#{account.id}/reports",
params: base_params.merge(timezone_offset: offset),
headers: admin.create_new_auth_token, as: :json
response.parsed_body
end
data_entries = responses.map { |r| r.select { |e| e['value'] > 0 } }
totals = responses.map { |r| r.sum { |e| e['value'] } }
timestamps = responses.map { |r| r.map { |e| e['timestamp'] } }
# Data conservation and redistribution
expect(totals.uniq).to eq([6])
expect(data_entries[0].map { |e| e['value'] }).to eq([1, 5])
expect(data_entries[1].map { |e| e['value'] }).to eq([3, 3])
expect(data_entries[2].map { |e| e['value'] }).to eq([4, 2])
# Timestamp differences
expect(timestamps.uniq.size).to eq(3)
timestamps[0].zip(timestamps[1]).each { |utc, pst| expect(utc - pst).to eq(-28_800) }
end
end
@@ -1,165 +0,0 @@
require 'rails_helper'
RSpec.describe Github::CallbacksController, type: :controller do
let(:account) { create(:account) }
let(:signed_account_id) { account.to_signed_global_id(expires_in: 1.hour) }
before do
# Clear any existing GitHub hooks for the account
account.hooks.where(app_id: 'github').destroy_all
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('test_client_id')
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('test_client_secret')
end
describe 'route accessibility' do
it 'is accessible via the github callback route' do
expect(get: '/github/callback').to route_to(
controller: 'github/callbacks',
action: 'show'
)
end
end
describe 'helper methods' do
describe '#account' do
it 'resolves account from valid signed global id' do
controller.params = { state: signed_account_id }
expect(controller.send(:account)).to eq(account)
end
it 'raises error for missing state' do
controller.params = {}
expect { controller.send(:account) }.to raise_error(ActionController::BadRequest, 'Invalid account context')
end
it 'raises error for invalid state' do
controller.params = { state: 'invalid_state' }
expect { controller.send(:account) }.to raise_error(ActionController::BadRequest, 'Invalid account context')
end
end
describe '#oauth_client' do
it 'creates OAuth2 client with correct configuration' do
oauth_client = controller.send(:oauth_client)
expect(oauth_client).to be_a(OAuth2::Client)
expect(oauth_client.id).to eq('test_client_id')
expect(oauth_client.secret).to eq('test_client_secret')
expect(oauth_client.site).to eq('https://github.com')
expect(oauth_client.options[:token_url]).to eq('/login/oauth/access_token')
expect(oauth_client.options[:authorize_url]).to eq('/login/oauth/authorize')
end
end
describe '#github_redirect_uri' do
before do
allow(controller).to receive(:account).and_return(account)
end
it 'generates correct GitHub redirect URI' do
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
uri = controller.send(:github_redirect_uri)
expect(uri).to eq("http://localhost:3000/app/accounts/#{account.id}/settings/integrations/github")
end
end
end
describe '#fallback_redirect_uri' do
it 'generates fallback redirect URI when account is unavailable' do
allow(controller).to receive(:account).and_raise(StandardError)
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
uri = controller.send(:fallback_redirect_uri)
expect(uri).to eq('http://localhost:3000/app/settings/integrations')
end
end
end
describe '#build_hook_settings' do
let(:mock_parsed_body) do
{
'access_token' => 'test_token',
'token_type' => 'bearer',
'scope' => 'repo,read:org'
}
end
before do
allow(controller).to receive(:parsed_body).and_return(mock_parsed_body)
end
it 'builds hook settings with installation_id' do
settings = controller.send(:build_hook_settings, '12345')
expect(settings).to include(
token_type: 'bearer',
scope: 'repo,read:org',
installation_id: '12345'
)
end
it 'builds hook settings without installation_id when nil' do
settings = controller.send(:build_hook_settings, nil)
expect(settings).to include(
token_type: 'bearer',
scope: 'repo,read:org'
)
expect(settings).not_to have_key(:installation_id)
end
it 'removes nil installation_id values' do
settings = controller.send(:build_hook_settings, nil)
expect(settings.keys).not_to include(:installation_id)
end
end
describe '#create_integration_hook' do
let(:settings) do
{
token_type: 'bearer',
scope: 'repo,read:org',
installation_id: '12345'
}
end
before do
allow(controller).to receive(:account).and_return(account)
allow(controller).to receive(:parsed_body).and_return({
'access_token' => 'test_token'
})
end
it 'creates integration hook with correct attributes' do
hook = controller.send(:create_integration_hook, settings)
expect(hook.access_token).to eq('test_token')
expect(hook.status).to eq('enabled')
expect(hook.app_id).to eq('github')
expect(hook.settings).to eq(settings.stringify_keys)
expect(hook.account).to eq(account)
end
end
describe '#build_oauth_url' do
it 'builds OAuth URL with installation context' do
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
url = controller.send(:build_oauth_url, '12345')
expect(url).to include('github?setup_action=install&installation_id=12345')
expect(controller.session[:github_installation_id]).to eq('12345')
end
end
end
describe '#base_url' do
it 'returns FRONTEND_URL when set' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(controller.send(:base_url)).to eq('https://app.chatwoot.com')
end
end
it 'returns default localhost when FRONTEND_URL not set' do
with_modified_env FRONTEND_URL: nil do
expect(controller.send(:base_url)).to eq('http://localhost:3000')
end
end
end
end
end
-4
View File
@@ -13,9 +13,5 @@ FactoryBot.define do
sequence(:phone_number) { |n| "+123456789#{n}1" }
messaging_service_sid { nil }
end
trait :whatsapp do
medium { :whatsapp }
end
end
end
-47
View File
@@ -38,25 +38,6 @@ RSpec.describe Integrations::App do
end
end
end
context 'when the app is github' do
let(:app_name) { 'github' }
it 'returns the GitHub App installation URL with state parameter' do
with_modified_env GITHUB_CLIENT_ID: 'dummy_client_id', GITHUB_APP_NAME: 'test-app' do
expect(app.action).to include('https://github.com/apps/test-app/installations/new')
expect(app.action).to include('state=')
end
end
it 'uses default app name when GITHUB_APP_NAME is not set' do
with_modified_env GITHUB_CLIENT_ID: 'dummy_client_id' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('dummy_client_id')
allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_NAME', 'chatwoot-qa').and_return('chatwoot-qa')
expect(app.action).to include('https://github.com/apps/chatwoot-qa/installations/new')
end
end
end
end
describe '#active?' do
@@ -106,34 +87,6 @@ RSpec.describe Integrations::App do
end
end
context 'when the app is github' do
let(:app_name) { 'github' }
it 'returns false if github credentials are not present' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return(nil)
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return(nil)
expect(app.active?(account)).to be false
end
it 'returns false if only client_id is present' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('client_id')
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return(nil)
expect(app.active?(account)).to be false
end
it 'returns false if only client_secret is present' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return(nil)
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('client_secret')
expect(app.active?(account)).to be false
end
it 'returns true if both client_id and client_secret are present' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('client_id')
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('client_secret')
expect(app.active?(account)).to be true
end
end
context 'when other apps are queried' do
let(:app_name) { 'webhook' }
+148
View File
@@ -694,6 +694,154 @@ RSpec.describe Message do
end
end
describe '#plain_text_content' do
let(:email_channel) { create(:channel_email) }
let(:email_inbox) { create(:inbox, channel: email_channel) }
let(:conversation) { create(:conversation, inbox: email_inbox) }
context 'when message is not an email' do
let(:message) { create(:message, conversation: conversation, content: 'Regular text message') }
it 'returns the content as-is' do
expect(message.plain_text_content).to eq('Regular text message')
end
end
context 'when message is an incoming email' do
context 'with text content' do
let(:message) do
create(
:message,
conversation: conversation,
message_type: :incoming,
content_type: 'incoming_email',
content: 'Email body',
content_attributes: {
email: {
text_content: {
reply: 'This is the reply text',
full: 'This is the full text'
}
}
}
)
end
it 'returns the reply text content' do
expect(message.plain_text_content).to eq('This is the reply text')
end
end
context 'with text content full only' do
let(:message) do
create(
:message,
conversation: conversation,
message_type: :incoming,
content_type: 'incoming_email',
content: 'Email body',
content_attributes: {
email: {
text_content: {
full: 'This is the full text only'
}
}
}
)
end
it 'returns the full text content' do
expect(message.plain_text_content).to eq('This is the full text only')
end
end
context 'with HTML content only' do
let(:message) do
create(
:message,
conversation: conversation,
message_type: :incoming,
content_type: 'incoming_email',
content: 'Email body',
content_attributes: {
email: {
html_content: {
reply: '<p>This is <strong>HTML</strong> content</p>',
full: '<div>Full HTML</div>'
}
}
}
)
end
it 'strips HTML tags and returns plain text' do
expect(message.plain_text_content).to eq('This is HTML content')
end
end
context 'with HTML content full only' do
let(:message) do
create(
:message,
conversation: conversation,
message_type: :incoming,
content_type: 'incoming_email',
content: 'Email body',
content_attributes: {
email: {
html_content: {
full: '<div><p>Full <em>HTML</em> only</p></div>'
}
}
}
)
end
it 'strips HTML tags from full content' do
expect(message.plain_text_content).to eq('Full HTML only')
end
end
context 'with no email content attributes' do
let(:message) do
create(
:message,
conversation: conversation,
message_type: :incoming,
content_type: 'incoming_email',
content: 'Fallback content'
)
end
it 'returns the regular content' do
expect(message.plain_text_content).to eq('Fallback content')
end
end
context 'with empty email content' do
let(:message) do
create(
:message,
conversation: conversation,
message_type: :incoming,
content_type: 'incoming_email',
content: 'Fallback content',
content_attributes: {
email: {
text_content: { reply: '', full: '' },
html_content: { reply: '', full: '' }
}
}
)
end
it 'returns the regular content as fallback' do
expect(message.plain_text_content).to eq('Fallback content')
end
end
end
end
describe '#reindex_for_search callback' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
@@ -141,7 +141,8 @@ RSpec.describe Crm::Leadsquared::Api::ActivityClient do
body: {
'ActivityEventName' => activity_params[:name],
'Score' => activity_params[:score],
'Direction' => activity_params[:direction]
'Direction' => activity_params[:direction],
'AllowAttachments' => 1
}.to_json,
headers: headers
)
@@ -173,7 +174,8 @@ RSpec.describe Crm::Leadsquared::Api::ActivityClient do
body: {
'ActivityEventName' => activity_params[:name],
'Score' => activity_params[:score],
'Direction' => activity_params[:direction]
'Direction' => activity_params[:direction],
'AllowAttachments' => 1
}.to_json,
headers: headers
)
@@ -197,7 +199,8 @@ RSpec.describe Crm::Leadsquared::Api::ActivityClient do
body: {
'ActivityEventName' => activity_params[:name],
'Score' => activity_params[:score],
'Direction' => activity_params[:direction]
'Direction' => activity_params[:direction],
'AllowAttachments' => 1
}.to_json,
headers: headers
)
@@ -214,4 +217,149 @@ RSpec.describe Crm::Leadsquared::Api::ActivityClient do
end
end
end
describe '#upload_file' do
let(:activity_event_code) { 1001 }
let(:file_name) { 'transcript.txt' }
let(:file_content) { 'Full conversation transcript content here' }
let(:files_url) { 'https://files.leadsquared.com/File/Upload' }
context 'with missing required parameters' do
it 'raises ArgumentError when activity_event_code is missing' do
expect { client.upload_file(nil, file_name, file_content) }
.to raise_error(ArgumentError, 'Activity event code is required')
end
it 'raises ArgumentError when file_name is missing' do
expect { client.upload_file(activity_event_code, nil, file_content) }
.to raise_error(ArgumentError, 'File name is required')
end
it 'raises ArgumentError when file_content is missing' do
expect { client.upload_file(activity_event_code, file_name, nil) }
.to raise_error(ArgumentError, 'File content is required')
end
end
context 'when request is successful' do
let(:s3_file_path) { 'https://landingpagecontentv2.s3.amazonaws.com/test/transcript.txt' }
let(:success_response) do
{
'Status' => 'Success',
'uploadedFile' => file_name,
'relativeFilePath' => 'https://f2.leadsquaredcdn.com/test/transcript.txt',
's3FilePath' => s3_file_path,
'description' => nil
}
end
before do
stub_request(:post, files_url)
.to_return(
status: 200,
body: success_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'uploads the file and returns the response' do
response = client.upload_file(activity_event_code, file_name, file_content)
expect(response['s3FilePath']).to eq(s3_file_path)
expect(response['uploadedFile']).to eq(file_name)
end
end
context 'when API request fails' do
before do
stub_request(:post, files_url)
.to_return(
status: 500,
body: 'Internal Server Error',
headers: { 'Content-Type' => 'text/plain' }
)
end
it 'raises ApiError when the request fails' do
expect { client.upload_file(activity_event_code, file_name, file_content) }
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
end
end
end
describe '#attach_file_to_activity' do
let(:path) { 'ProspectActivity.svc/Attachment/Add' }
let(:full_url) { URI.join(credentials[:endpoint_url], path).to_s }
let(:activity_id) { SecureRandom.uuid }
let(:file_url) { 'https://landingpagecontentv2.s3.amazonaws.com/test/transcript.txt' }
let(:file_name) { 'transcript.txt' }
let(:description) { 'Full conversation transcript' }
context 'with missing required parameters' do
it 'raises ArgumentError when activity_id is missing' do
expect { client.attach_file_to_activity(nil, file_url, file_name) }
.to raise_error(ArgumentError, 'Activity ID is required')
end
it 'raises ArgumentError when file_url is missing' do
expect { client.attach_file_to_activity(activity_id, nil, file_name) }
.to raise_error(ArgumentError, 'File URL is required')
end
it 'raises ArgumentError when file_name is missing' do
expect { client.attach_file_to_activity(activity_id, file_url, nil) }
.to raise_error(ArgumentError, 'File name is required')
end
end
context 'when request is successful' do
let(:attachment_id) { SecureRandom.uuid }
let(:success_response) do
{
'Status' => 'Success',
'Message' => {
'Id' => attachment_id
}
}
end
before do
stub_request(:post, full_url)
.with(
body: {
'ProspectActivityId' => activity_id,
'AttachmentName' => file_name,
'Description' => description,
'AttachmentFile' => file_url
}.to_json,
headers: headers
)
.to_return(
status: 200,
body: success_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'attaches the file to the activity and returns attachment ID' do
response = client.attach_file_to_activity(activity_id, file_url, file_name, description)
expect(response).to eq(attachment_id)
end
end
context 'when API request fails' do
before do
stub_request(:post, full_url)
.to_return(
status: 500,
body: 'Internal Server Error',
headers: { 'Content-Type' => 'text/plain' }
)
end
it 'raises ApiError when the request fails' do
expect { client.attach_file_to_activity(activity_id, file_url, file_name, description) }
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
end
end
end
end
@@ -251,4 +251,95 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
end
end
end
describe '#full_transcript_text' do
let(:mapper) { described_class.new(hook, conversation) }
let(:user) { create(:user, name: 'Agent Smith') }
let(:contact) { create(:contact, name: 'Customer Jones') }
before do
create(:message,
conversation: conversation,
sender: user,
content: 'First message',
message_type: :outgoing,
created_at: Time.zone.parse('2024-01-01 10:00:00'))
create(:message,
conversation: conversation,
sender: contact,
content: 'Second message',
message_type: :incoming,
created_at: Time.zone.parse('2024-01-01 10:01:00'))
end
it 'generates a complete transcript without truncation' do
result = mapper.send(:full_transcript_text)
expect(result).to include('Conversation Transcript from TestBrand')
expect(result).to include('Channel: Test Inbox')
expect(result).to include("Conversation ID: #{conversation.display_id}")
expect(result).to include('View in TestBrand:')
expect(result).to include('Transcript:')
expect(result).to include('=' * 50)
expect(result).to include('[2024-01-01 10:01] Customer Jones: Second message')
expect(result).to include('[2024-01-01 10:00] Agent Smith: First message')
end
it 'includes all messages without length restriction' do
# Create many messages
20.times do |i|
create(:message,
conversation: conversation,
sender: user,
content: "Message number #{i}" * 50, # Long content
message_type: :outgoing,
created_at: Time.zone.parse('2024-01-01 10:00:00') + i.hours)
end
result = mapper.send(:full_transcript_text)
# Verify all messages are included (no truncation)
20.times do |i|
expect(result).to include("Message number #{i}")
end
# The full transcript can exceed ACTIVITY_NOTE_MAX_SIZE
expect(result.length).to be > described_class::ACTIVITY_NOTE_MAX_SIZE
end
context 'with email messages' do
let(:email_channel) { create(:channel_email) }
let(:email_inbox) { create(:inbox, channel: email_channel, account: account, name: 'Email Inbox') }
let(:email_conversation) { create(:conversation, account: account, inbox: email_inbox) }
let(:email_mapper) { described_class.new(hook, email_conversation) }
it 'uses plain_text_content for email messages' do
email_message = create(
:message,
conversation: email_conversation,
sender: contact,
message_type: :incoming,
content_type: 'incoming_email',
content: 'Email body',
content_attributes: {
email: {
html_content: {
reply: '<p>This is <strong>HTML</strong> content</p>'
}
}
},
created_at: Time.zone.parse('2024-01-01 10:00:00')
)
# Mock plain_text_content to return stripped content
allow(email_message).to receive(:plain_text_content).and_return('This is HTML content')
allow(email_conversation.messages).to receive(:chat).and_return([email_message])
result = email_mapper.send(:full_transcript_text)
expect(result).to include('This is HTML content')
expect(result).not_to include('<strong>')
end
end
end
end
@@ -177,11 +177,13 @@ RSpec.describe Crm::Leadsquared::ProcessorService do
describe '#handle_conversation_resolved' do
let(:activity_note) { 'Conversation transcript' }
let(:full_transcript) { 'Full conversation transcript text' }
let(:mapper) { instance_double(Crm::Leadsquared::Mappers::ConversationMapper) }
before do
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_transcript_activity)
.with(hook, conversation)
.and_return(activity_note)
# Stub the mapper class methods
allow_any_instance_of(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:full_transcript_text).and_return(full_transcript)
allow_any_instance_of(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:transcript_activity).and_return(activity_note)
end
context 'when transcript activities are enabled and conversation is resolved' do
@@ -198,9 +200,88 @@ RSpec.describe Crm::Leadsquared::ProcessorService do
.and_return('test_activity_id')
end
it 'creates the transcript activity and stores metadata' do
service.handle_conversation_resolved(conversation)
expect(conversation.reload.additional_attributes['leadsquared']['transcript_activity_id']).to eq('test_activity_id')
context 'when transcript is within the size limit' do
it 'creates the transcript activity and stores metadata' do
service.handle_conversation_resolved(conversation)
expect(conversation.reload.additional_attributes['leadsquared']['transcript_activity_id']).to eq('test_activity_id')
end
it 'does not upload file' do
expect(activity_client).not_to receive(:upload_file)
service.handle_conversation_resolved(conversation)
end
end
context 'when transcript exceeds the size limit' do
let(:long_transcript) { 'A' * 2000 } # Exceeds ACTIVITY_NOTE_MAX_SIZE (1800)
let(:s3_file_path) { 'https://s3.amazonaws.com/test/transcript.txt' }
before do
allow_any_instance_of(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:full_transcript_text).and_return(long_transcript)
allow(activity_client).to receive(:upload_file)
.with(1002, "conversation_#{conversation.display_id}_transcript.txt", long_transcript)
.and_return({ 's3FilePath' => s3_file_path })
allow(activity_client).to receive(:attach_file_to_activity)
.with('test_activity_id', s3_file_path, "conversation_#{conversation.display_id}_transcript.txt",
"Full transcript for conversation ##{conversation.display_id}")
.and_return('attachment_id')
allow(activity_client).to receive(:post_activity)
.with('test_lead_id', 1002, /Full transcript attached as file/)
.and_return('test_activity_id')
end
it 'uploads the full transcript as a file' do
service.handle_conversation_resolved(conversation)
expect(activity_client).to have_received(:upload_file)
.with(1002, "conversation_#{conversation.display_id}_transcript.txt", long_transcript)
end
it 'attaches the file to the activity' do
service.handle_conversation_resolved(conversation)
expect(activity_client).to have_received(:attach_file_to_activity)
.with('test_activity_id', s3_file_path, anything, anything)
end
it 'creates activity with attachment info note' do
service.handle_conversation_resolved(conversation)
expect(activity_client).to have_received(:post_activity)
.with('test_lead_id', 1002, /Full transcript attached as file/)
end
it 'stores the activity metadata' do
service.handle_conversation_resolved(conversation)
expect(conversation.reload.additional_attributes['leadsquared']['transcript_activity_id']).to eq('test_activity_id')
end
end
context 'when file upload fails' do
let(:long_transcript) { 'A' * 2000 }
before do
allow_any_instance_of(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:full_transcript_text).and_return(long_transcript)
# Stub the post_activity call that happens before upload fails
allow(activity_client).to receive(:post_activity)
.with('test_lead_id', 1002, /Full transcript attached as file/)
.and_return('test_activity_id')
allow(activity_client).to receive(:upload_file)
.and_raise(Crm::Leadsquared::Api::BaseClient::ApiError.new('Upload error'))
allow(Rails.logger).to receive(:error)
end
it 'logs the error' do
service.handle_conversation_resolved(conversation)
expect(Rails.logger).to have_received(:error).with(/LeadSquared API error uploading transcript/)
end
it 'does not raise an error' do
expect { service.handle_conversation_resolved(conversation) }.not_to raise_error
end
end
end
@@ -402,230 +402,6 @@ describe Twilio::IncomingMessageService do
existing_contact.reload
expect(existing_contact.name).to eq('Alice Johnson')
end
describe 'When the incoming number is a Brazilian number in new format with 9 included' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'creates appropriate conversations, message and contacts if contact does not exist' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+5541988887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Brazil',
ProfileName: 'João Silva'
}
described_class.new(params: params).perform
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('João Silva')
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
end
it 'appends to existing contact if contact inbox exists' do
# Create existing contact with same format
normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+5541988887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Another message from Brazil',
ProfileName: 'João Silva'
}
described_class.new(params: params).perform
# No new conversation should be created
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the last conversation
expect(last_conversation.messages.last.content).to eq('Another message from Brazil')
end
end
describe 'When incoming number is a Brazilian number in old format without the 9 included' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'appends to existing contact when contact inbox exists in old format' do
# Create existing contact with old format (12 digits)
old_contact = create(:contact, account: account, phone_number: '+554188887777')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+554188887777', contact: old_contact, inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+554188887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Brazil old format',
ProfileName: 'Maria Silva'
}
described_class.new(params: params).perform
# No new conversation should be created
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the last conversation
expect(last_conversation.messages.last.content).to eq('Test message from Brazil old format')
end
it 'appends to existing contact when contact inbox exists in new format' do
# Create existing contact with new format (13 digits)
normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
# Incoming message with old format (12 digits)
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+554188887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Brazil',
ProfileName: 'João Silva'
}
described_class.new(params: params).perform
# Should find and use existing contact, not create duplicate
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the existing conversation
expect(last_conversation.messages.last.content).to eq('Test message from Brazil')
# Should use the existing contact's source_id (normalized format)
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
end
it 'creates contact inbox with incoming number when no existing contact' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+554188887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Brazil',
ProfileName: 'Carlos Silva'
}
described_class.new(params: params).perform
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Silva')
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+554188887777')
end
end
describe 'When the incoming number is an Argentine number with 9 after country code' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'creates appropriate conversations, message and contacts if contact does not exist' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+5491123456789',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Argentina',
ProfileName: 'Carlos Mendoza'
}
described_class.new(params: params).perform
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Mendoza')
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5491123456789')
end
it 'appends to existing contact if contact inbox exists with normalized format' do
# Create existing contact with normalized format (without 9 after country code)
normalized_contact = create(:contact, account: account, phone_number: '+541123456789')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: normalized_contact,
inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
# Incoming message with 9 after country code
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+5491123456789',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Argentina',
ProfileName: 'Carlos Mendoza'
}
described_class.new(params: params).perform
# Should find and use existing contact, not create duplicate
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the existing conversation
expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
# Should use the normalized source_id from existing contact
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
end
end
describe 'When incoming number is an Argentine number without 9 after country code' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'appends to existing contact when contact inbox exists with same format' do
# Create existing contact with same format (without 9)
contact = create(:contact, account: account, phone_number: '+541123456789')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: contact, inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+541123456789',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Argentina',
ProfileName: 'Ana García'
}
described_class.new(params: params).perform
# No new conversation should be created
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the last conversation
expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
end
it 'creates contact inbox with incoming number when no existing contact' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+541123456789',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Argentina',
ProfileName: 'Diego López'
}
described_class.new(params: params).perform
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Diego López')
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
end
end
end
end
end