Merge branch 'release/3.5.2'

This commit is contained in:
Sojan 2024-01-19 15:15:27 +04:00
commit 608592db0c
34 changed files with 346 additions and 102 deletions

View File

@ -46,6 +46,14 @@ class V2::ReportBuilder
} }
end end
def short_summary
{
conversations_count: conversations.count,
avg_first_response_time: avg_first_response_time_summary,
avg_resolution_time: avg_resolution_time_summary
}
end
def conversation_metrics def conversation_metrics
if params[:type].equal?(:account) if params[:type].equal?(:account)
live_conversations live_conversations

View File

@ -9,7 +9,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
def index def index
@unread_count = notification_finder.unread_count @unread_count = notification_finder.unread_count
@notifications = notification_finder.perform @notifications = notification_finder.perform
@count = @notifications.count @count = notification_finder.count
end end
def read_all def read_all

View File

@ -14,7 +14,14 @@ module AccessTokenAuthHelper
render_unauthorized('Invalid Access Token') && return if @access_token.blank? render_unauthorized('Invalid Access Token') && return if @access_token.blank?
@resource = @access_token.owner @resource = @access_token.owner
Current.user = @resource if [User, AgentBot].include?(@resource.class) Current.user = @resource if allowed_current_user_type?(@resource)
end
def allowed_current_user_type?(resource)
return true if resource.is_a?(User)
return true if resource.is_a?(AgentBot)
false
end end
def validate_bot_access_token! def validate_bot_access_token!

View File

@ -1,6 +1,7 @@
class Public::Api::V1::Portals::BaseController < PublicController class Public::Api::V1::Portals::BaseController < PublicController
before_action :show_plain_layout before_action :show_plain_layout
before_action :set_color_scheme before_action :set_color_scheme
before_action :set_global_config
around_action :set_locale around_action :set_locale
after_action :allow_iframe_requests after_action :allow_iframe_requests
@ -60,4 +61,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
portal portal
render 'public/api/v1/portals/error/404', status: :not_found render 'public/api/v1/portals/error/404', status: :not_found
end end
def set_global_config
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL')
end
end end

View File

@ -53,7 +53,7 @@ module Api::V1::InboxesHelper
rescue StandardError => e rescue StandardError => e
raise StandardError, e.message raise StandardError, e.message
ensure ensure
ChatwootExceptionTracker.new(e).capture_exception if e.present? Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present?
end end
def check_smtp_connection(channel_data, smtp) def check_smtp_connection(channel_data, smtp)

View File

@ -37,7 +37,7 @@ module Api::V2::Accounts::ReportsHelper
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
} }
) )
).summary ).short_summary
end end
private private

View File

@ -519,6 +519,9 @@ export default {
}, },
}, },
watch: { watch: {
teamId() {
this.updateVirtualListProps('teamId', this.teamId);
},
activeTeam() { activeTeam() {
this.resetAndFetchData(); this.resetAndFetchData();
}, },

View File

@ -126,6 +126,6 @@
"NO_CONTENT": "Содержимое отсутствует", "NO_CONTENT": "Содержимое отсутствует",
"HIDE_QUOTED_TEXT": "Скрыть цитируемый текст", "HIDE_QUOTED_TEXT": "Скрыть цитируемый текст",
"SHOW_QUOTED_TEXT": "Показать цитируемый текст", "SHOW_QUOTED_TEXT": "Показать цитируемый текст",
"MESSAGE_READ": "Читать" "MESSAGE_READ": "Прочитано"
} }
} }

View File

@ -56,7 +56,7 @@
<span <span
class="mt-1 text-slate-500 dark:text-slate-400 text-xxs font-semibold flex" class="mt-1 text-slate-500 dark:text-slate-400 text-xxs font-semibold flex"
> >
{{ dynamicTime(notificationItem.created_at) }} {{ dynamicTime(notificationItem.last_activity_at) }}
</span> </span>
</div> </div>
</div> </div>

View File

@ -58,7 +58,7 @@
<td> <td>
<div class="text-right timestamp--column"> <div class="text-right timestamp--column">
<span class="notification--created-at"> <span class="notification--created-at">
{{ dynamicTime(notificationItem.created_at) }} {{ dynamicTime(notificationItem.last_activity_at) }}
</span> </span>
</div> </div>
</td> </td>

View File

@ -11,6 +11,7 @@
<table-footer <table-footer
:current-page="Number(meta.currentPage)" :current-page="Number(meta.currentPage)"
:total-count="meta.count" :total-count="meta.count"
:page-size="15"
@page-change="onPageChange" @page-change="onPageChange"
/> />
</div> </div>

View File

@ -1,6 +1,8 @@
class ApplicationJob < ActiveJob::Base class ApplicationJob < ActiveJob::Base
# https://api.rubyonrails.org/v5.2.1/classes/ActiveJob/Exceptions/ClassMethods.html # https://api.rubyonrails.org/v5.2.1/classes/ActiveJob/Exceptions/ClassMethods.html
discard_on ActiveJob::DeserializationError do |_job, error| discard_on ActiveJob::DeserializationError do |job, error|
Rails.logger.error("Skipping job because of ActiveJob::DeserializationError (#{error.message})") Rails.logger.info("Skipping #{job.class} with #{
job.instance_variable_get(:@serialized_arguments)
} because of ActiveJob::DeserializationError (#{error.message})")
end end
end end

View File

@ -8,8 +8,12 @@ class DataImportJob < ApplicationJob
def perform(data_import) def perform(data_import)
@data_import = data_import @data_import = data_import
@contact_manager = DataImport::ContactManager.new(@data_import.account) @contact_manager = DataImport::ContactManager.new(@data_import.account)
process_import_file begin
send_import_notification_to_admin process_import_file
send_import_notification_to_admin
rescue CSV::MalformedCSVError => e
handle_csv_error(e)
end
end end
private private
@ -83,7 +87,16 @@ class DataImportJob < ApplicationJob
end end
end end
def handle_csv_error(error) # rubocop:disable Lint/UnusedMethodArgument
@data_import.update!(status: :failed)
send_import_failed_notification_to_admin
end
def send_import_notification_to_admin def send_import_notification_to_admin
AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later
end end
def send_import_failed_notification_to_admin
AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_failed.deliver_later
end
end end

View File

@ -1,17 +1,21 @@
require 'net/imap' require 'net/imap'
class Inboxes::FetchImapEmailsJob < ApplicationJob class Inboxes::FetchImapEmailsJob < MutexApplicationJob
queue_as :scheduled_jobs queue_as :scheduled_jobs
def perform(channel) def perform(channel)
return unless should_fetch_email?(channel) return unless should_fetch_email?(channel)
process_email_for_channel(channel) with_lock(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) do
process_email_for_channel(channel)
end
rescue *ExceptionList::IMAP_EXCEPTIONS => e rescue *ExceptionList::IMAP_EXCEPTIONS => e
Rails.logger.error e Rails.logger.error e
channel.authorization_error! channel.authorization_error!
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e
Rails.logger.error e Rails.logger.error e
rescue LockAcquisitionError
Rails.logger.error "Lock failed for #{channel.inbox.id}"
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end end
@ -23,7 +27,6 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
end end
def process_email_for_channel(channel) def process_email_for_channel(channel)
# fetching email for microsoft provider
if channel.microsoft? if channel.microsoft?
fetch_mail_for_ms_provider(channel) fetch_mail_for_ms_provider(channel)
else else
@ -34,32 +37,66 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
end end
def fetch_mail_for_channel(channel) def fetch_mail_for_channel(channel)
imap_inbox = authenticated_imap_inbox(channel, channel.imap_password, 'PLAIN') imap_client = build_imap_client(channel, channel.imap_password, 'PLAIN')
last_email_time = DateTime.parse(Net::IMAP.format_datetime(last_email_time(channel)))
received_mails(imap_inbox).each do |message_id| message_ids_with_seq = fetch_message_ids_with_sequence(imap_client, channel)
inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822'] message_ids_with_seq.each do |message_id_with_seq|
process_message_id(channel, imap_client, message_id_with_seq)
mail_info_logger(channel, inbound_mail, message_id)
next if email_already_present?(channel, inbound_mail, last_email_time)
process_mail(inbound_mail, channel)
end end
end end
def email_already_present?(channel, inbound_mail, _last_email_time) def process_message_id(channel, imap_client, message_id_with_seq)
channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present? seq_no, message_id = message_id_with_seq
return if email_already_present?(channel, message_id)
# Fetch the original mail content using the sequence no
mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
if mail_str.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>."
return
end
inbound_mail = build_mail_from_string(mail_str)
mail_info_logger(channel, inbound_mail, seq_no)
process_mail(inbound_mail, channel)
end end
def received_mails(imap_inbox) # Sends a FETCH command to retrieve data associated with a message in the mailbox.
imap_inbox.search(['BEFORE', tomorrow, 'SINCE', yesterday]) # You can send batches of message sequence number in `.fetch` method.
def fetch_message_ids_with_sequence(imap_client, channel)
seq_nums = fetch_available_mail_sequence_numbers(imap_client)
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}."
message_ids_with_seq = []
seq_nums.each_slice(10).each do |batch|
# Fetch only message-id only without mail body or contents.
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)]')
# .fetch returns an array of Net::IMAP::FetchData or nil
# (instead of an empty array) if there is no matching message.
# Check
if batch_message_ids.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
next
end
batch_message_ids.each do |data|
message_id = build_mail_from_string(data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]']).message_id
message_ids_with_seq.push([data.seqno, message_id])
end
end
message_ids_with_seq
end end
def processed_email?(current_email, last_email_time) # Sends a SEARCH command to search the mailbox for messages that were
return current_email.date < last_email_time if current_email.date.present? # created between yesterday and today and returns message sequence numbers.
# Return <message set>
false def fetch_available_mail_sequence_numbers(imap_client)
imap_client.search(['BEFORE', tomorrow, 'SINCE', yesterday])
end end
def fetch_mail_for_ms_provider(channel) def fetch_mail_for_ms_provider(channel)
@ -69,16 +106,15 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
return unless access_token return unless access_token
imap_inbox = authenticated_imap_inbox(channel, access_token, 'XOAUTH2') imap_client = build_imap_client(channel, access_token, 'XOAUTH2')
process_mails(imap_client, channel)
process_mails(imap_inbox, channel)
end end
def process_mails(imap_inbox, channel) def process_mails(imap_client, channel)
received_mails(imap_inbox).each do |message_id| fetch_available_mail_sequence_numbers(imap_client).each do |seq_no|
inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822'] inbound_mail = Mail.read_from_string imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
mail_info_logger(channel, inbound_mail, message_id) mail_info_logger(channel, inbound_mail, seq_no)
next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present? next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
@ -86,38 +122,26 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
end end
end end
def mail_info_logger(channel, inbound_mail, message_id) def mail_info_logger(channel, inbound_mail, uid)
return if Rails.env.test? return if Rails.env.test?
Rails.logger.info(" Rails.logger.info("
#{channel.provider} Email id: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}, message_id: #{message_id}") #{channel.provider} Email id: #{inbound_mail.from} - message_source_id: #{inbound_mail.message_id} - sequence id: #{uid}")
end end
def authenticated_imap_inbox(channel, access_token, auth_method) def build_imap_client(channel, access_token, auth_method)
imap = Net::IMAP.new(channel.imap_address, channel.imap_port, true) imap = Net::IMAP.new(channel.imap_address, channel.imap_port, true)
imap.authenticate(auth_method, channel.imap_login, access_token) imap.authenticate(auth_method, channel.imap_login, access_token)
imap.select('INBOX') imap.select('INBOX')
imap imap
end end
def last_email_time(channel) def email_already_present?(channel, message_id)
# we are only checking for emails in last 2 day channel.inbox.messages.find_by(source_id: message_id).present?
last_email_incoming_message = channel.inbox.messages.incoming.where('messages.created_at >= ?', 2.days.ago).last
if last_email_incoming_message.present?
time = last_email_incoming_message.content_attributes['email']['date']
time ||= last_email_incoming_message.created_at.to_s
end
time ||= 1.hour.ago.to_s
DateTime.parse(time)
end end
def yesterday def build_mail_from_string(raw_email_content)
(Time.zone.today - 1).strftime('%d-%b-%Y') Mail.read_from_string(raw_email_content)
end
def tomorrow
(Time.zone.today + 1).strftime('%d-%b-%Y')
end end
def process_mail(inbound_mail, channel) def process_mail(inbound_mail, channel)
@ -132,4 +156,12 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
def valid_access_token(channel) def valid_access_token(channel)
Microsoft::RefreshOauthTokenService.new(channel: channel).access_token Microsoft::RefreshOauthTokenService.new(channel: channel).access_token
end end
def yesterday
(Time.zone.today - 1).strftime('%d-%b-%Y')
end
def tomorrow
(Time.zone.today + 1).strftime('%d-%b-%Y')
end
end end

View File

@ -18,17 +18,15 @@ class MutexApplicationJob < ApplicationJob
lock_key = format(key_format, *args) lock_key = format(key_format, *args)
lock_manager = Redis::LockManager.new lock_manager = Redis::LockManager.new
if lock_manager.locked?(lock_key)
Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}"
raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}"
end
begin begin
lock_manager.lock(lock_key) if lock_manager.lock(lock_key)
Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}" Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}"
yield yield
else
Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}"
raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}"
end
ensure ensure
# Ensure that the lock is released even if there's an error in processing
lock_manager.unlock(lock_key) lock_manager.unlock(lock_key)
end end
end end

View File

@ -0,0 +1,8 @@
class Notification::RemoveOldNotificationJob < ApplicationJob
queue_as :low
def perform
Notification.where('created_at < ?', 1.month.ago)
.find_each(batch_size: 1000, &:delete)
end
end

View File

@ -19,5 +19,8 @@ class TriggerScheduledItemsJob < ApplicationJob
# Job to sync whatsapp templates # Job to sync whatsapp templates
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
# Job to clear notifications which are older than 1 month
Notification::RemoveOldNotificationJob.perform_later
end end
end end

View File

@ -51,6 +51,15 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
send_mail_with_liquid(to: admin_emails, subject: subject) and return send_mail_with_liquid(to: admin_emails, subject: subject) and return
end end
def contact_import_failed
return unless smtp_config_set_or_development?
subject = 'Contact Import Failed'
@meta = {}
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
def contact_export_complete(file_url) def contact_export_complete(file_url)
return unless smtp_config_set_or_development? return unless smtp_config_set_or_development?

View File

@ -149,24 +149,32 @@ class Channel::Telegram < ApplicationRecord
}) })
end end
def convert_markdown_to_telegram(text) def convert_markdown_to_telegram_html(text)
## supported characters : https://core.telegram.org/bots/api#markdown-style # ref: https://core.telegram.org/bots/api#html-style
## To implement MarkdownV2, we will need to do a lot of escaping
# Convert bold - double asterisks to single asterisk in Telegram # escape html tags in text. We are subbing \n to <br> since commonmark will strip exta '\n'
# Chatwoot uses double asterisks for bold, while telegram used single asterisk text = CGI.escapeHTML(text.gsub("\n", '<br>'))
text.gsub!(/\*\*(.*?)\*\*/, '*\1*')
text # convert markdown to html
html = CommonMarker.render_html(text).strip
# remove all html tags except b, strong, i, em, u, ins, s, strike, del, a, code, pre, blockquote
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html, tags: %w[b strong i em u ins s strike del a code pre blockquote],
attributes: %w[href])
# converted escaped br tags to \n
stripped_html.gsub('&lt;br&gt;', "\n")
end end
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil) def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil)
text_to_md = convert_markdown_to_telegram(text) text_payload = convert_markdown_to_telegram_html(text)
HTTParty.post("#{telegram_api_url}/sendMessage", HTTParty.post("#{telegram_api_url}/sendMessage",
body: { body: {
chat_id: chat_id, chat_id: chat_id,
text: text_to_md, text: text_payload,
reply_markup: reply_markup, reply_markup: reply_markup,
parse_mode: 'Markdown', parse_mode: 'HTML',
reply_to_message_id: reply_to_message_id reply_to_message_id: reply_to_message_id
}) })
end end

View File

@ -33,9 +33,12 @@ class DashboardApp < ApplicationRecord
'required' => %w[url type], 'required' => %w[url type],
'properties' => { 'properties' => {
'type' => { 'enum': ['frame'] }, 'type' => { 'enum': ['frame'] },
'url' => { :type => 'string', 'format' => 'uri' } 'url' => { '$ref' => '#/definitions/saneUrl' }
} }
}, },
'definitions' => {
'saneUrl' => { 'format' => 'uri', 'pattern' => '^https?://' }
},
'additionalProperties' => false, 'additionalProperties' => false,
'minItems' => 1 'minItems' => 1
} }

View File

@ -19,6 +19,7 @@ json.data do
json.secondary_actor notification.secondary_actor&.push_event_data json.secondary_actor notification.secondary_actor&.push_event_data
json.user notification.user.push_event_data json.user notification.user.push_event_data
json.created_at notification.created_at.to_i json.created_at notification.created_at.to_i
json.last_activity_at notification.last_activity_at.to_i
end end
end end
end end

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16" height="16" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>woot-log</title>
<desc>Created with Sketch.</desc>
<g id="Logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="woot-log" fill-rule="nonzero">
<circle id="Oval" fill="#47A7F6" cx="256" cy="256" r="256"></circle>
<path d="M362.807947,368.807947 L244.122956,368.807947 C178.699407,368.807947 125.456954,315.561812 125.456954,250.12177 C125.456954,184.703089 178.699407,131.456954 244.124143,131.456954 C309.565494,131.456954 362.807947,184.703089 362.807947,250.12177 L362.807947,368.807947 Z" id="Fill-1" stroke="#FFFFFF" stroke-width="6" fill="#FFFFFF"></path>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 846 B

View File

@ -0,0 +1,3 @@
<p>Hello,</p>
<p>Your contact import has failed. It appears that the CSV file you uploaded may not be valid. We kindly request that you review the file and ensure it complies with the required format.<p/>

View File

@ -2,9 +2,17 @@
<footer class="pt-16 pb-8 flex flex-col items-center justify-center"> <footer class="pt-16 pb-8 flex flex-col items-center justify-center">
<div class="mx-auto max-w-2xl text-center py-2"> <div class="mx-auto max-w-2xl text-center py-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<%= render partial: 'icons/chatwoot-logo' %>
<img
class="w-4 h-4"
alt="<%= @global_config['BRAND_NAME'] %>"
src="<%= @global_config['LOGO_THUMBNAIL'] %>"
/>
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center"> <p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
<%= I18n.t('public_portal.footer.made_with') %> <a class="hover:underline" href="https://www.chatwoot.com" target="_blank" rel="noopener noreferrer nofoll/ow">Chatwoot</a> <%= I18n.t('public_portal.footer.made_with') %>
<a class="hover:underline" href="<%= @global_config['BRAND_URL'] %>" target="_blank" rel="noopener noreferrer nofoll/ow"><%= @global_config['BRAND_NAME'] %></a>
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
shared: &shared shared: &shared
version: '3.5.1' version: '3.5.2'
development: development:
<<: *shared <<: *shared

View File

@ -38,4 +38,5 @@ module Redis::RedisKeys
FACEBOOK_MESSAGE_MUTEX = 'FB_MESSAGE_CREATE_LOCK::%<sender_id>s::%<recipient_id>s'.freeze FACEBOOK_MESSAGE_MUTEX = 'FB_MESSAGE_CREATE_LOCK::%<sender_id>s::%<recipient_id>s'.freeze
IG_MESSAGE_MUTEX = 'IG_MESSAGE_CREATE_LOCK::%<sender_id>s::%<ig_account_id>s'.freeze IG_MESSAGE_MUTEX = 'IG_MESSAGE_CREATE_LOCK::%<sender_id>s::%<ig_account_id>s'.freeze
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
end end

View File

@ -1,6 +1,6 @@
{ {
"name": "@chatwoot/chatwoot", "name": "@chatwoot/chatwoot",
"version": "3.5.1", "version": "3.5.2",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"eslint": "eslint app/**/*.{js,vue}", "eslint": "eslint app/**/*.{js,vue}",

View File

@ -29,6 +29,25 @@ RSpec.describe 'API Base', type: :request do
end end
end end
describe 'request with api_access_token for a super admin' do
before do
user.update!(type: 'SuperAdmin')
end
context 'when its a valid api_access_token' do
it 'returns current user information' do
get '/api/v1/profile',
headers: { api_access_token: user.access_token.token },
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['id']).to eq(user.id)
expect(json_response['email']).to eq(user.email)
end
end
end
describe 'request with api_access_token for bot' do describe 'request with api_access_token for bot' do
let!(:agent_bot) { create(:agent_bot) } let!(:agent_bot) { create(:agent_bot) }
let!(:inbox) { create(:inbox, account: account) } let!(:inbox) { create(:inbox, account: account) }

View File

@ -55,8 +55,12 @@ RSpec.describe 'DashboardAppsController', type: :request do
describe 'POST /api/v1/accounts/{account.id}/dashboard_apps' do describe 'POST /api/v1/accounts/{account.id}/dashboard_apps' do
let(:payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }] } } } let(:payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }] } } }
let(:no_ssl_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'http://link.com' }] } } }
let(:invalid_type_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'dda', url: 'https://link.com' }] } } } let(:invalid_type_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'dda', url: 'https://link.com' }] } } }
let(:invalid_url_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'com' }] } } } let(:invalid_url_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'com' }] } } }
let(:non_http_url_payload) do
{ dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'ftp://wontwork.chatwoot.com/hello-world' }] } }
end
context 'when it is an unauthenticated user' do context 'when it is an unauthenticated user' do
it 'returns unauthorized' do it 'returns unauthorized' do
@ -82,6 +86,19 @@ RSpec.describe 'DashboardAppsController', type: :request do
expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type] expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type]
end end
it 'creates the dashboard app even if the URL does not have SSL' do
expect do
post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token,
params: no_ssl_payload
end.to change(DashboardApp, :count).by(1)
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['title']).to eq 'CRM Dashboard'
expect(json_response['content'][0]['link']).to eq payload[:dashboard_app][:content][0][:link]
expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type]
end
it 'does not create the dashboard app if invalid URL' do it 'does not create the dashboard app if invalid URL' do
expect do expect do
post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token, post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token,
@ -93,6 +110,17 @@ RSpec.describe 'DashboardAppsController', type: :request do
expect(json_response['message']).to eq 'Content : Invalid data' expect(json_response['message']).to eq 'Content : Invalid data'
end end
it 'does not create the dashboard app if non HTTP URL' do
expect do
post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token,
params: non_http_url_payload
end.not_to change(DashboardApp, :count)
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['message']).to eq 'Content : Invalid data'
end
it 'does not create the dashboard app if invalid type' do it 'does not create the dashboard app if invalid type' do
expect do expect do
post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token, post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token,

View File

@ -151,5 +151,20 @@ RSpec.describe DataImportJob do
end end
end end
end end
context 'when the CSV file is invalid' do
let(:invalid_csv_content) do
"id,name,email,phone_number,company\n1,\"Clarice Uzzell,\"missing_quote,918080808080,Acmecorp\n2,Marieann Creegan,,+918080808081,Acmecorp"
end
before do
allow(data_import.import_file).to receive(:download).and_return(invalid_csv_content)
end
it 'does not import any data and handles the MalformedCSVError' do
expect { described_class.perform_now(data_import) }
.to change { data_import.reload.status }.from('pending').to('failed')
end
end
end end
end end

View File

@ -16,14 +16,12 @@ RSpec.describe MutexApplicationJob do
before do before do
allow(Redis::LockManager).to receive(:new).and_return(lock_manager) allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
allow(lock_manager).to receive(:locked?).and_return(false)
allow(lock_manager).to receive(:lock).and_return(true) allow(lock_manager).to receive(:lock).and_return(true)
allow(lock_manager).to receive(:unlock).and_return(true) allow(lock_manager).to receive(:unlock).and_return(true)
end end
describe '#with_lock' do describe '#with_lock' do
it 'acquires the lock and yields the block if lock is not acquired' do it 'acquires the lock and yields the block if lock is not acquired' do
expect(lock_manager).to receive(:locked?).with(lock_key).and_return(false)
expect(lock_manager).to receive(:lock).with(lock_key).and_return(true) expect(lock_manager).to receive(:lock).with(lock_key).and_return(true)
expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true) expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
@ -31,7 +29,7 @@ RSpec.describe MutexApplicationJob do
end end
it 'raises LockAcquisitionError if it cannot acquire the lock' do it 'raises LockAcquisitionError if it cannot acquire the lock' do
allow(lock_manager).to receive(:locked?).with(lock_key).and_return(true) allow(lock_manager).to receive(:lock).with(lock_key).and_return(false)
expect do expect do
described_class.new.send(:with_lock, lock_key) do described_class.new.send(:with_lock, lock_key) do
@ -41,7 +39,6 @@ RSpec.describe MutexApplicationJob do
end end
it 'ensures that the lock is released even if there is an error during block execution' do it 'ensures that the lock is released even if there is an error during block execution' do
expect(lock_manager).to receive(:locked?).with(lock_key).and_return(false)
expect(lock_manager).to receive(:lock).with(lock_key).and_return(true) expect(lock_manager).to receive(:lock).with(lock_key).and_return(true)
expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true) expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true)

View File

@ -0,0 +1,24 @@
require 'rails_helper'
RSpec.describe Notification::RemoveOldNotificationJob do
let(:user) { create(:user) }
let(:conversation) { create(:conversation) }
it 'enqueues the job' do
notification = create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation)
expect do
described_class.perform_later(notification)
end.to have_enqueued_job(described_class)
.on_queue('low')
end
it 'removes old notifications which are older than 1 month' do
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 2.months.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.month.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.day.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.hour.ago)
described_class.perform_now
expect(Notification.count).to eq(2)
end
end

View File

@ -40,5 +40,10 @@ RSpec.describe TriggerScheduledItemsJob do
expect(Channels::Whatsapp::TemplatesSyncSchedulerJob).to receive(:perform_later).once expect(Channels::Whatsapp::TemplatesSyncSchedulerJob).to receive(:perform_later).once
described_class.perform_now described_class.perform_now
end end
it 'triggers Notification::RemoveOldNotificationJob' do
expect(Notification::RemoveOldNotificationJob).to receive(:perform_later).once
described_class.perform_now
end
end end
end end

View File

@ -3,6 +3,58 @@ require 'rails_helper'
RSpec.describe Channel::Telegram do RSpec.describe Channel::Telegram do
let(:telegram_channel) { create(:channel_telegram) } let(:telegram_channel) { create(:channel_telegram) }
describe '#convert_markdown_to_telegram_html' do
subject { telegram_channel.send(:convert_markdown_to_telegram_html, text) }
context 'when text contains multiple newline characters' do
let(:text) { "Line one\nLine two\n\nLine four" }
it 'preserves multiple newline characters' do
expect(subject).to eq("Line one\nLine two\n\nLine four")
end
end
context 'when text contains broken markdown' do
let(:text) { 'This is a **broken markdown with <b>HTML</b> tags.' }
it 'does not break and properly converts to Telegram HTML format and escapes html tags' do
expect(subject).to eq('This is a **broken markdown with &lt;b&gt;HTML&lt;/b&gt; tags.')
end
end
context 'when text contains markdown and HTML elements' do
let(:text) { "Hello *world*! This is <b>bold</b> and this is <i>italic</i>.\nThis is a new line." }
it 'converts markdown to Telegram HTML format and escapes other html' do
expect(subject).to eq("Hello <em>world</em>! This is &lt;b&gt;bold&lt;/b&gt; and this is &lt;i&gt;italic&lt;/i&gt;.\nThis is a new line.")
end
end
context 'when text contains unsupported HTML tags' do
let(:text) { 'This is a <span>test</span> with unsupported tags.' }
it 'removes unsupported HTML tags' do
expect(subject).to eq('This is a &lt;span&gt;test&lt;/span&gt; with unsupported tags.')
end
end
context 'when text contains special characters' do
let(:text) { 'Special characters: & < >' }
it 'escapes special characters' do
expect(subject).to eq('Special characters: &amp; &lt; &gt;')
end
end
context 'when text contains markdown links' do
let(:text) { 'Check this [link](http://example.com) out!' }
it 'converts markdown links to Telegram HTML format' do
expect(subject).to eq('Check this <a href="http://example.com">link</a> out!')
end
end
end
context 'when a valid message and empty attachments' do context 'when a valid message and empty attachments' do
it 'send message' do it 'send message' do
message = create(:message, message_type: :outgoing, content: 'test', message = create(:message, message_type: :outgoing, content: 'test',
@ -10,7 +62,7 @@ RSpec.describe Channel::Telegram do
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage") stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with( .with(
body: 'chat_id=123&text=test&reply_markup=&parse_mode=Markdown&reply_to_message_id=' body: 'chat_id=123&text=test&reply_markup=&parse_mode=HTML&reply_to_message_id='
) )
.to_return( .to_return(
status: 200, status: 200,
@ -21,13 +73,15 @@ RSpec.describe Channel::Telegram do
expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123') expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123')
end end
it 'send message with markdown converted to telegram markdown' do it 'send message with markdown converted to telegram HTML' do
message = create(:message, message_type: :outgoing, content: '**test** *test* ~test~', message = create(:message, message_type: :outgoing, content: '**test** *test* ~test~',
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' })) conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' }))
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage") stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with( .with(
body: "chat_id=123&text=#{ERB::Util.url_encode('*test* *test* ~test~')}&reply_markup=&parse_mode=Markdown&reply_to_message_id=" body: "chat_id=123&text=#{
ERB::Util.url_encode('<strong>test</strong> <em>test</em> ~test~')
}&reply_markup=&parse_mode=HTML&reply_to_message_id="
) )
.to_return( .to_return(
status: 200, status: 200,
@ -49,7 +103,7 @@ RSpec.describe Channel::Telegram do
.with( .with(
body: 'chat_id=123&text=test' \ body: 'chat_id=123&text=test' \
'&reply_markup=%7B%22one_time_keyboard%22%3Atrue%2C%22inline_keyboard%22%3A%5B%5B%7B%22text%22%3A%22test%22%2C%22' \ '&reply_markup=%7B%22one_time_keyboard%22%3Atrue%2C%22inline_keyboard%22%3A%5B%5B%7B%22text%22%3A%22test%22%2C%22' \
'callback_data%22%3A%22test%22%7D%5D%5D%7D&parse_mode=Markdown&reply_to_message_id=' 'callback_data%22%3A%22test%22%7D%5D%5D%7D&parse_mode=HTML&reply_to_message_id='
) )
.to_return( .to_return(
status: 200, status: 200,
@ -66,7 +120,7 @@ RSpec.describe Channel::Telegram do
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage") stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with( .with(
body: 'chat_id=123&text=test&reply_markup=&parse_mode=Markdown&reply_to_message_id=' body: 'chat_id=123&text=test&reply_markup=&parse_mode=HTML&reply_to_message_id='
) )
.to_return( .to_return(
status: 403, status: 403,