Merge branch 'develop' into chore/update-rails

This commit is contained in:
Sojan Jose
2026-02-02 16:31:59 -08:00
committed by GitHub
31 changed files with 422 additions and 50 deletions
@@ -158,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
status: @outgoing_echo ? :delivered : :sent,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
@@ -166,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
}
}
params[:content_attributes][:external_echo] = true if @outgoing_echo
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
@@ -70,8 +70,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def transcript
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
@conversation.account.increment_email_sent_count
head :ok
end
@@ -35,12 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def transcript
if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation,
conversation.contact.email
)&.deliver_later
end
return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
send_transcript_email
head :ok
end
@@ -77,6 +74,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
private
def send_transcript_email
return if conversation.contact&.email.blank?
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation,
conversation.contact.email
)&.deliver_later
conversation.account.increment_email_sent_count
end
def trigger_typing_event(event)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
end
@@ -42,7 +42,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
@@ -3,12 +3,14 @@ import { onMounted, computed, ref, toRefs } from 'vue';
import { useTimeoutFn } from '@vueuse/core';
import { provideMessageContext } from './provider.js';
import { useTrack } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import { emitter } from 'shared/helpers/mitt';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { LocalStorage } from 'shared/helpers/localStorage';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
MESSAGE_TYPES,
@@ -139,6 +141,8 @@ const showBackgroundHighlight = ref(false);
const showContextMenu = ref(false);
const { t } = useI18n();
const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
/**
* Computes the message variant based on props
@@ -162,6 +166,10 @@ const variant = computed(() => {
if (props.contentAttributes?.isUnsupported)
return MESSAGE_VARIANTS.UNSUPPORTED;
if (props.contentAttributes?.externalEcho) {
return MESSAGE_VARIANTS.AGENT;
}
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
return MESSAGE_VARIANTS.BOT;
@@ -424,6 +432,18 @@ function handleReplyTo() {
}
const avatarInfo = computed(() => {
if (props.contentAttributes?.externalEcho) {
const { name, avatar_url, channel_type, medium } = inbox.value;
const iconName = avatar_url
? null
: getInboxIconByType(channel_type, medium);
return {
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
src: avatar_url || '',
iconName,
};
}
// If no sender, return bot info
if (!props.sender) {
return {
@@ -451,6 +471,9 @@ const avatarInfo = computed(() => {
});
const avatarTooltip = computed(() => {
if (props.contentAttributes?.externalEcho) {
return t('CONVERSATION.NATIVE_APP_ADVISORY');
}
if (avatarInfo.value.name === '') return '';
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
});
@@ -484,7 +507,7 @@ provideMessageContext({
<div
v-if="shouldRenderMessage"
:id="`message${props.id}`"
class="flex mb-2 w-full message-bubble-container"
class="flex w-full mb-2 message-bubble-container"
:data-message-id="props.id"
:class="[
flexOrientationClass,
@@ -127,6 +127,7 @@ const validateSingleAction = action => {
'resolve_conversation',
'remove_assigned_team',
'open_conversation',
'pending_conversation',
];
if (
@@ -150,7 +150,8 @@
"ADD_PRIVATE_NOTE": "Add a Private Note",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
"OPEN_CONVERSATION": "Open conversation",
"PENDING_CONVERSATION": "Mark conversation as pending"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
@@ -253,6 +253,8 @@
"MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:",
"BOT": "Bot",
"NATIVE_APP": "Native app",
"NATIVE_APP_ADVISORY": "This message was sent from the native app. Reply from Chatwoot to maintain the message window.",
"SEND_FAILED": "Couldn't send message! Try again",
"TRY_AGAIN": "retry",
"ASSIGNMENT": {
@@ -116,6 +116,10 @@ export const AUTOMATIONS = {
key: 'open_conversation',
name: 'OPEN_CONVERSATION',
},
{
key: 'pending_conversation',
name: 'PENDING_CONVERSATION',
},
{
key: 'resolve_conversation',
name: 'RESOLVE_CONVERSATION',
@@ -232,6 +236,10 @@ export const AUTOMATIONS = {
key: 'snooze_conversation',
name: 'SNOOZE_CONVERSATION',
},
{
key: 'pending_conversation',
name: 'PENDING_CONVERSATION',
},
{
key: 'resolve_conversation',
name: 'RESOLVE_CONVERSATION',
@@ -360,6 +368,10 @@ export const AUTOMATIONS = {
key: 'snooze_conversation',
name: 'SNOOZE_CONVERSATION',
},
{
key: 'pending_conversation',
name: 'PENDING_CONVERSATION',
},
{
key: 'resolve_conversation',
name: 'RESOLVE_CONVERSATION',
@@ -482,6 +494,10 @@ export const AUTOMATIONS = {
key: 'snooze_conversation',
name: 'SNOOZE_CONVERSATION',
},
{
key: 'pending_conversation',
name: 'PENDING_CONVERSATION',
},
{
key: 'send_webhook_event',
name: 'SEND_WEBHOOK_EVENT',
@@ -668,6 +684,11 @@ export const AUTOMATION_ACTION_TYPES = [
label: 'OPEN_CONVERSATION',
inputType: null,
},
{
key: 'pending_conversation',
label: 'PENDING_CONVERSATION',
inputType: null,
},
{
key: 'send_webhook_event',
label: 'SEND_WEBHOOK_EVENT',
+1
View File
@@ -3,6 +3,7 @@ class ConversationReplyEmailJob < ApplicationJob
def perform(conversation_id, last_queued_id)
conversation = Conversation.find(conversation_id)
return unless conversation.account.active?
if conversation.messages.incoming&.last&.content_type == 'incoming_email'
ConversationReplyMailer.with(account: conversation.account).reply_without_summary(conversation, last_queued_id).deliver_later
+1 -1
View File
@@ -54,7 +54,7 @@ class Webhooks::TiktokEventsJob < MutexApplicationJob
# Receive real-time notifications if you send a message to a user.
def im_send_msg
# This can be either an echo message or a message sent directly via tiktok application
::Tiktok::MessageService.new(channel: channel, content: content).perform
::Tiktok::MessageService.new(channel: channel, content: content, outgoing_echo: true).perform
end
# Receive real-time notifications if a user outside the European Economic Area (EEA), Switzerland, or the UK sends a message to you.
+50
View File
@@ -9,6 +9,56 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
return
end
if message_echo_event?(params)
handle_message_echo(channel, params)
else
handle_message_events(channel, params)
end
end
# Detects if the webhook is an SMB message echo event (message sent from WhatsApp Business app)
# This is part of WhatsApp coexistence feature where businesses can respond from both
# Chatwoot and the WhatsApp Business app, with messages synced to Chatwoot.
#
# Regular message payload (field: "messages"):
# {
# "entry": [{
# "changes": [{
# "field": "messages",
# "value": {
# "contacts": [{ "wa_id": "919745786257", "profile": { "name": "Customer" } }],
# "messages": [{ "from": "919745786257", "id": "wamid...", "text": { "body": "Hello" } }]
# }
# }]
# }]
# }
#
# Echo message payload (field: "smb_message_echoes"):
# {
# "entry": [{
# "changes": [{
# "field": "smb_message_echoes",
# "value": {
# "message_echoes": [{ "from": "971545296927", "to": "919745786257", "id": "wamid...", "text": { "body": "Hi" } }]
# }
# }]
# }]
# }
#
# Key differences:
# - field: "smb_message_echoes" instead of "messages"
# - message_echoes[] instead of messages[]
# - "from" is the business number, "to" is the contact (reversed from regular messages)
# - No "contacts" array in echo payload
def message_echo_event?(params)
params.dig(:entry, 0, :changes, 0, :field) == 'smb_message_echoes'
end
def handle_message_echo(channel, params)
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform
end
def handle_message_events(channel, params)
case channel.provider
when 'whatsapp_cloud'
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform
+1
View File
@@ -38,6 +38,7 @@ class ConversationReplyMailer < ApplicationMailer
return unless smtp_config_set_or_development?
init_conversation_attributes(message.conversation)
@message = message
prepare_mail(true)
end
+1
View File
@@ -29,6 +29,7 @@ class Account < ApplicationRecord
include Featurable
include CacheKeys
include CaptainFeaturable
include AccountEmailRateLimitable
SETTINGS_PARAMS_SCHEMA = {
'type': 'object',
+2 -2
View File
@@ -41,8 +41,8 @@ class AutomationRule < ApplicationRecord
def actions_attributes
%w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation
send_attachment change_status resolve_conversation open_conversation snooze_conversation change_priority send_email_transcript
add_private_note].freeze
send_attachment change_status resolve_conversation open_conversation pending_conversation snooze_conversation change_priority
send_email_transcript add_private_note].freeze
end
def file_base_data
@@ -0,0 +1,49 @@
module AccountEmailRateLimitable
extend ActiveSupport::Concern
OUTBOUND_EMAIL_TTL = 25.hours.to_i
EMAIL_LIMIT_CONFIG_KEY = 'ACCOUNT_EMAILS_LIMIT'.freeze
def email_rate_limit
account_limit || global_limit || default_limit
end
def emails_sent_today
Redis::Alfred.get(email_count_cache_key).to_i
end
def within_email_rate_limit?
return true if emails_sent_today < email_rate_limit
Rails.logger.warn("Account #{id} reached daily email rate limit of #{email_rate_limit}. Sent: #{emails_sent_today}")
false
end
def increment_email_sent_count
Redis::Alfred.incr(email_count_cache_key).tap do |count|
Redis::Alfred.expire(email_count_cache_key, OUTBOUND_EMAIL_TTL) if count == 1
end
end
private
def email_count_cache_key
@email_count_cache_key ||= format(
Redis::Alfred::ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY,
account_id: id,
date: Time.zone.today.to_s
)
end
def account_limit
self[:limits]&.dig('emails')&.to_i
end
def global_limit
GlobalConfig.get(EMAIL_LIMIT_CONFIG_KEY)[EMAIL_LIMIT_CONFIG_KEY]&.to_i
end
def default_limit
ChatwootApp.max_limit.to_i
end
end
+2 -1
View File
@@ -344,10 +344,11 @@ class Message < ApplicationRecord
# if the sender is not a user, it's not a human response
# if automation rule id is present, it's not a human response
# if campaign id is present, it's not a human response
# external echo messages are responses sent from the native app (WhatsApp Business, Instagram)
outgoing? &&
content_attributes['automation_rule_id'].blank? &&
additional_attributes['campaign_id'].blank? &&
sender.is_a?(User)
(sender.is_a?(User) || content_attributes['external_echo'].present?)
end
def bot_response?
+4
View File
@@ -22,6 +22,10 @@ class ActionService
@conversation.open!
end
def pending_conversation(_params)
@conversation.pending!
end
def change_status(status)
@conversation.update!(status: status[0])
end
@@ -13,6 +13,7 @@ class Messages::SendEmailNotificationService
return unless Redis::Alfred.set(conversation_mail_key, message.id, nx: true, ex: 1.hour.to_i)
ConversationReplyEmailJob.set(wait: 2.minutes).perform_later(conversation.id, message.id)
message.account.increment_email_sent_count
end
private
@@ -20,6 +21,7 @@ class Messages::SendEmailNotificationService
def should_send_email_notification?
return false unless message.email_notifiable_message?
return false if message.conversation.contact.email.blank?
return false unless message.account.within_email_rate_limit?
email_reply_enabled?
end
@@ -7,15 +7,22 @@ class Notification::EmailNotificationService
# don't send emails if user is not confirmed
return if notification.user.confirmed_at.nil?
return unless user_subscribed_to_notification?
return unless notification.account.within_email_rate_limit?
# TODO : Clean up whatever happening over here
# Segregate the mailers properly
AgentNotifications::ConversationNotificationsMailer.with(account: notification.account).public_send(notification
.notification_type.to_s, notification.primary_actor, notification.user, notification.secondary_actor).deliver_later
send_notification_email
notification.account.increment_email_sent_count
end
private
# TODO : Clean up whatever happening over here
# Segregate the mailers properly
def send_notification_email
AgentNotifications::ConversationNotificationsMailer.with(account: notification.account).public_send(
notification.notification_type.to_s, notification.primary_actor, notification.user, notification.secondary_actor
).deliver_later
end
def user_subscribed_to_notification?
notification_setting = notification.user.notification_settings.find_by(account_id: notification.account.id)
return true if notification_setting.public_send("email_#{notification.notification_type}?")
+3 -3
View File
@@ -1,11 +1,10 @@
class Tiktok::MessageService
include Tiktok::MessagingHelpers
pattr_initialize [:channel!, :content!]
pattr_initialize [:channel!, :content!, :outgoing_echo]
def perform
if outgoing_message?
# Skip processing echo messages
message = find_message(tt_conversation_id, tt_message_id)
return if message.present?
end
@@ -39,7 +38,7 @@ class Tiktok::MessageService
updated_at: tt_message_time
)
message.sender = contact_inbox.contact if incoming_message?
message.sender = contact_inbox.contact if incoming_message? && !outgoing_echo
message.status = :delivered if outgoing_message?
create_message_attachments(message)
@@ -91,6 +90,7 @@ class Tiktok::MessageService
attributes = {}
attributes[:in_reply_to_external_id] = tt_referenced_message_id if tt_referenced_message_id
attributes[:is_unsupported] = true unless supported_message?
attributes[:external_echo] = true if outgoing_echo
attributes
end
+22 -2
View File
@@ -61,16 +61,36 @@ class Whatsapp::FacebookApiClient
end
def subscribe_waba_webhook(waba_id, callback_url, verify_token)
# Step 1: Subscribe app to WABA first (required before override)
# Meta requires the app to be subscribed before using override_callback_uri
# See: https://github.com/chatwoot/chatwoot/issues/13097
subscribe_app_to_waba(waba_id)
# Step 2: Override callback URL for this specific WABA
override_waba_callback(waba_id, callback_url, verify_token)
end
def subscribe_app_to_waba(waba_id)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers
)
handle_response(response, 'App subscription to WABA failed')
end
def override_waba_callback(waba_id, callback_url, verify_token)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers,
body: {
override_callback_uri: callback_url,
verify_token: verify_token
verify_token: verify_token,
subscribed_fields: %w[messages smb_message_echoes]
}.to_json
)
handle_response(response, 'Webhook subscription failed')
handle_response(response, 'Webhook callback override failed')
end
def unsubscribe_waba_webhook(waba_id)
@@ -4,18 +4,23 @@
class Whatsapp::IncomingMessageBaseService
include ::Whatsapp::IncomingMessageServiceHelpers
pattr_initialize [:inbox!, :params!]
pattr_initialize [:inbox!, :params!, :outgoing_echo]
def perform
processed_params
if processed_params.try(:[], :statuses).present?
process_statuses
elsif processed_params.try(:[], :messages).present?
elsif messages_data.present?
process_messages
end
end
# Returns messages array for both regular messages and echo events
def messages_data
@processed_params&.dig(:messages) || @processed_params&.dig(:message_echoes)
end
private
def process_messages
@@ -26,7 +31,7 @@ class Whatsapp::IncomingMessageBaseService
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta
# business manager account. While we have not found the core reason yet, the following line ensure that
# there are no duplicate messages created.
return if find_message_by_source_id(@processed_params[:messages].first[:id]) || message_under_process?
return if find_message_by_source_id(messages_data.first[:id]) || message_under_process?
cache_message_source_id_in_redis
set_contact
@@ -57,7 +62,7 @@ class Whatsapp::IncomingMessageBaseService
end
def create_messages
message = @processed_params[:messages].first
message = messages_data.first
log_error(message) && return if error_webhook_event?(message)
process_in_reply_to(message)
@@ -67,20 +72,44 @@ class Whatsapp::IncomingMessageBaseService
def create_contact_messages(message)
message['contacts'].each do |contact|
create_message(contact)
# Pass source_id from parent message since contact objects don't have :id
create_message(contact, source_id: message[:id])
attach_contact(contact)
@message.save!
end
end
def create_regular_message(message)
create_message(message)
create_message(message, source_id: message[:id])
attach_files
attach_location if message_type == 'location'
@message.save!
end
def set_contact
if outgoing_echo
set_contact_from_echo
else
set_contact_from_message
end
end
def set_contact_from_echo
# For echo messages, contact phone is in the 'to' field
phone_number = messages_data.first[:to]
waid = processed_waid(phone_number)
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: waid,
inbox: inbox,
contact_attributes: { name: "+#{phone_number}", phone_number: "+#{phone_number}" }
).perform
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
end
def set_contact_from_message
contact_params = @processed_params[:contacts]&.first
return if contact_params.blank?
@@ -89,7 +118,7 @@ class Whatsapp::IncomingMessageBaseService
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: waid,
inbox: inbox,
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{@processed_params[:messages].first[:from]}" }
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{messages_data.first[:from]}" }
).perform
@contact_inbox = contact_inbox
@@ -115,7 +144,7 @@ class Whatsapp::IncomingMessageBaseService
def attach_files
return if %w[text button interactive location contacts].include?(message_type)
attachment_payload = @processed_params[:messages].first[message_type.to_sym]
attachment_payload = messages_data.first[message_type.to_sym]
@message.content ||= attachment_payload[:caption]
attachment_file = download_attachment_file(attachment_payload)
@@ -133,7 +162,7 @@ class Whatsapp::IncomingMessageBaseService
end
def attach_location
location = @processed_params[:messages].first['location']
location = messages_data.first['location']
location_name = location['name'] ? "#{location['name']}, #{location['address']}" : ''
@message.attachments.new(
account_id: @message.account_id,
@@ -145,14 +174,17 @@ class Whatsapp::IncomingMessageBaseService
)
end
def create_message(message)
def create_message(message, source_id: nil)
@message = @conversation.messages.build(
content: message_content(message),
account_id: @inbox.account_id,
inbox_id: @inbox.id,
message_type: :incoming,
sender: @contact,
source_id: message[:id].to_s,
message_type: outgoing_echo ? :outgoing : :incoming,
# Set status to :delivered for echo messages to prevent SendReplyJob from trying to send them
status: outgoing_echo ? :delivered : :sent,
sender: outgoing_echo ? nil : @contact,
source_id: (source_id || message[:id]).to_s,
content_attributes: outgoing_echo ? { external_echo: true } : {},
in_reply_to_external_id: @in_reply_to_external_id
)
end
@@ -189,7 +221,7 @@ class Whatsapp::IncomingMessageBaseService
end
def contact_name_matches_phone_number?
phone_number = "+#{@processed_params[:messages].first[:from]}"
phone_number = "+#{messages_data.first[:from]}"
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
@contact.name == phone_number || @contact.name == formatted_phone_number
end
@@ -21,7 +21,7 @@ module Whatsapp::IncomingMessageServiceHelpers
end
def message_type
@processed_params[:messages].first[:type]
messages_data.first[:type]
end
def message_content(message)
@@ -70,19 +70,19 @@ module Whatsapp::IncomingMessageServiceHelpers
end
def message_under_process?
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
Redis::Alfred.get(key)
end
def cache_message_source_id_in_redis
return if @processed_params.try(:[], :messages).blank?
return if messages_data.blank?
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
::Redis::Alfred.setex(key, true)
end
def clear_message_source_id_from_redis
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
::Redis::Alfred.delete(key)
end
end
+10
View File
@@ -107,6 +107,16 @@
value:
description: 'The support email address for your installation'
locked: false
- name: ACCOUNT_EMAILS_LIMIT
display_title: 'Account Email Sending Limit (Daily)'
description: 'Maximum number of non-channel emails an account can send per day'
value: 100
locked: false
- name: ACCOUNT_EMAILS_PLAN_LIMITS
display_title: 'Account Email Plan Limits (Daily)'
description: 'Per-plan daily email sending limits as JSON'
value:
type: code
# ------- End of Email Related Config ------- #
# ------- Facebook Channel Related Config ------- #
@@ -2,6 +2,6 @@ require 'administrate/field/base'
class AccountLimitsField < Administrate::Field::Base
def to_s
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil, emails: nil }.to_json
end
end
@@ -1,4 +1,4 @@
module Enterprise::Account::PlanUsageAndLimits
module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleLength
CAPTAIN_RESPONSES = 'captain_responses'.freeze
CAPTAIN_DOCUMENTS = 'captain_documents'.freeze
CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze
@@ -32,6 +32,10 @@ module Enterprise::Account::PlanUsageAndLimits
save
end
def email_rate_limit
account_limit || plan_email_limit || global_limit || default_limit
end
def subscribed_features
plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value
return [] if plan_features.blank?
@@ -68,6 +72,16 @@ module Enterprise::Account::PlanUsageAndLimits
}
end
def plan_email_limit
config = InstallationConfig.find_by(name: 'ACCOUNT_EMAILS_PLAN_LIMITS')&.value
return nil if config.blank? || plan_name.blank?
parsed = config.is_a?(String) ? JSON.parse(config) : config
parsed[plan_name.downcase]&.to_i
rescue StandardError
nil
end
def default_captain_limits
max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access
zero_limits = { documents: 0, responses: 0 }.with_indifferent_access
@@ -119,7 +133,8 @@ module Enterprise::Account::PlanUsageAndLimits
'inboxes' => { 'type': 'number' },
'agents' => { 'type': 'number' },
'captain_responses' => { 'type': 'number' },
'captain_documents' => { 'type': 'number' }
'captain_documents' => { 'type': 'number' },
'emails' => { 'type': 'number' }
},
'required' => [],
'additionalProperties' => false
+3
View File
@@ -49,4 +49,7 @@ module Redis::RedisKeys
# Track conversation assignments to agents for rate limiting
ASSIGNMENT_KEY = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::CONVERSATION::%<conversation_id>d'.freeze
ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::*'.freeze
## Account Email Rate Limiting
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
end
@@ -0,0 +1,63 @@
require 'rails_helper'
RSpec.describe AccountEmailRateLimitable do
let(:account) { create(:account) }
describe '#email_rate_limit' do
it 'returns account-level override when set' do
account.update!(limits: { 'emails' => 50 })
expect(account.email_rate_limit).to eq(50)
end
it 'returns global config when no account override' do
InstallationConfig.where(name: 'ACCOUNT_EMAILS_LIMIT').first_or_create(value: 200)
expect(account.email_rate_limit).to eq(200)
end
it 'returns account override over global config' do
InstallationConfig.where(name: 'ACCOUNT_EMAILS_LIMIT').first_or_create(value: 200)
account.update!(limits: { 'emails' => 50 })
expect(account.email_rate_limit).to eq(50)
end
end
describe '#within_email_rate_limit?' do
before do
account.update!(limits: { 'emails' => 2 })
end
it 'returns true when under limit' do
expect(account).to be_within_email_rate_limit
end
it 'returns false when at limit' do
2.times { account.increment_email_sent_count }
expect(account).not_to be_within_email_rate_limit
end
end
describe '#increment_email_sent_count' do
it 'increments the counter' do
expect { account.increment_email_sent_count }.to change(account, :emails_sent_today).by(1)
end
it 'sets TTL on first increment' do
key = format(Redis::Alfred::ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY, account_id: account.id, date: Time.zone.today.to_s)
allow(Redis::Alfred).to receive(:incr).and_return(1)
allow(Redis::Alfred).to receive(:expire)
account.increment_email_sent_count
expect(Redis::Alfred).to have_received(:expire).with(key, AccountEmailRateLimitable::OUTBOUND_EMAIL_TTL)
end
it 'does not reset TTL on subsequent increments' do
allow(Redis::Alfred).to receive(:incr).and_return(2)
allow(Redis::Alfred).to receive(:expire)
account.increment_email_sent_count
expect(Redis::Alfred).not_to have_received(:expire)
end
end
end
@@ -99,6 +99,20 @@ describe Messages::SendEmailNotificationService do
end
end
context 'when account email rate limit is exceeded' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: 'test@example.com')
allow_any_instance_of(Account).to receive(:within_email_rate_limit?).and_return(false) # rubocop:disable RSpec/AnyInstance
end
it 'does not enqueue job' do
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
end
end
context 'when channel does not support email notifications' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_sms, account: account)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
@@ -161,10 +161,23 @@ describe Whatsapp::FacebookApiClient do
context 'when successful' do
before do
# Step 1: Subscribe app to WABA (no body)
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }
)
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Step 2: Override callback URL (with body)
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
body: { override_callback_uri: callback_url, verify_token: verify_token,
subscribed_fields: %w[messages smb_message_echoes] }.to_json
)
.to_return(
status: 200,
@@ -179,18 +192,45 @@ describe Whatsapp::FacebookApiClient do
end
end
context 'when failed' do
context 'when app subscription fails' do
before do
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }
)
.to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_json)
.to_return(status: 400, body: { error: 'App subscription to WABA failed' }.to_json)
end
it 'raises an error' do
expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook subscription failed/)
expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/App subscription to WABA failed/)
end
end
context 'when callback override fails' do
before do
# Step 1 succeeds
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }
)
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Step 2 fails
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { override_callback_uri: callback_url, verify_token: verify_token,
subscribed_fields: %w[messages smb_message_echoes] }.to_json
)
.to_return(status: 400, body: { error: 'Webhook callback override failed' }.to_json)
end
it 'raises an error' do
expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook callback override failed/)
end
end
end