mirror of
https://github.com/chatwoot/chatwoot.git
synced 2026-06-04 21:02:35 +08:00
Chatwoot now lets external apps know when an inbox loses its connection and needs re-authentication. When a channel's authorization expires (for example, an email inbox disconnects), Chatwoot fires an `inbox_updated` webhook reflecting the new `reauthorization_required` status, and fires it again once the inbox is re-authenticated. Integrators can keep their own view of which inboxes are healthy without polling the API. This is gated behind the `ENABLE_INBOX_EVENTS` installation flag — the **Inbox updated** webhook subscription only appears in the dashboard when that flag is enabled, so no event is offered that the backend wouldn't dispatch. Fixes https://linear.app/chatwoot/issue/CW-7148/emit-inbox-webhook-when-an-inbox-is-disconnected ## How to test 1. Set `ENABLE_INBOX_EVENTS=true` and restart the app. 2. In **Settings → Integrations → Webhooks**, add a webhook and subscribe to **Inbox updated**. 3. Disconnect an inbox — let an email/Instagram channel hit its auth-error threshold, or run `inbox.channel.prompt_reauthorization!` in a console. 4. The endpoint receives an `inbox_updated` event whose `changed_attributes` shows `reauthorization_required` flipping to `true`. 5. Re-authenticate the inbox (or run `inbox.channel.reauthorized!`) — the endpoint receives the `true → false` transition. 6. Confirm the **Inbox updated** option is hidden when `ENABLE_INBOX_EVENTS` is unset. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
272 lines
8.1 KiB
Ruby
272 lines
8.1 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: inboxes
|
|
#
|
|
# id :integer not null, primary key
|
|
# allow_messages_after_resolved :boolean default(TRUE)
|
|
# auto_assignment_config :jsonb
|
|
# business_name :string
|
|
# channel_type :string
|
|
# csat_config :jsonb not null
|
|
# csat_survey_enabled :boolean default(FALSE)
|
|
# email_address :string
|
|
# enable_auto_assignment :boolean default(TRUE)
|
|
# enable_email_collect :boolean default(TRUE)
|
|
# greeting_enabled :boolean default(FALSE)
|
|
# greeting_message :string
|
|
# lock_to_single_conversation :boolean default(FALSE), not null
|
|
# name :string not null
|
|
# out_of_office_message :string
|
|
# sender_name_type :integer default("friendly"), not null
|
|
# timezone :string default("UTC")
|
|
# working_hours_enabled :boolean default(FALSE)
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# account_id :integer not null
|
|
# channel_id :integer not null
|
|
# portal_id :bigint
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_inboxes_on_account_id (account_id)
|
|
# index_inboxes_on_channel_id_and_channel_type (channel_id,channel_type)
|
|
# index_inboxes_on_portal_id (portal_id)
|
|
#
|
|
# Foreign Keys
|
|
#
|
|
# fk_rails_... (portal_id => portals.id)
|
|
#
|
|
|
|
class Inbox < ApplicationRecord
|
|
include Reportable
|
|
include Avatarable
|
|
include OutOfOffisable
|
|
include AccountCacheRevalidator
|
|
include InboxAgentAvailability
|
|
|
|
# Not allowing characters:
|
|
validates :name, presence: true
|
|
validates :account_id, presence: true
|
|
validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers }
|
|
validates :out_of_office_message, length: { maximum: Limits::OUT_OF_OFFICE_MESSAGE_MAX_LENGTH }
|
|
validates :greeting_message, length: { maximum: Limits::GREETING_MESSAGE_MAX_LENGTH }
|
|
validate :ensure_valid_max_assignment_limit
|
|
|
|
belongs_to :account
|
|
belongs_to :portal, optional: true
|
|
|
|
belongs_to :channel, polymorphic: true, dependent: :destroy
|
|
|
|
has_many :campaigns, dependent: :destroy_async
|
|
has_many :contact_inboxes, dependent: :destroy_async
|
|
has_many :contacts, through: :contact_inboxes
|
|
|
|
has_many :inbox_members, dependent: :destroy_async
|
|
has_many :members, through: :inbox_members, source: :user
|
|
has_many :conversations, dependent: :destroy_async
|
|
has_many :messages, dependent: :destroy_async
|
|
|
|
has_one :inbox_assignment_policy, dependent: :destroy
|
|
has_one :assignment_policy, through: :inbox_assignment_policy
|
|
has_one :agent_bot_inbox, dependent: :destroy_async
|
|
has_one :agent_bot, through: :agent_bot_inbox
|
|
has_many :webhooks, dependent: :destroy_async
|
|
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
|
|
|
|
enum sender_name_type: { friendly: 0, professional: 1 }
|
|
|
|
after_destroy :delete_round_robin_agents
|
|
|
|
after_create_commit :dispatch_create_event
|
|
after_update_commit :dispatch_update_event
|
|
|
|
scope :order_by_name, -> { order('lower(name) ASC') }
|
|
|
|
# Adds multiple members to the inbox
|
|
# @param user_ids [Array<Integer>] Array of user IDs to add as members
|
|
# @return [void]
|
|
def add_members(user_ids)
|
|
inbox_members.create!(user_ids.map { |user_id| { user_id: user_id } })
|
|
update_account_cache
|
|
end
|
|
|
|
# Removes multiple members from the inbox
|
|
# @param user_ids [Array<Integer>] Array of user IDs to remove
|
|
# @return [void]
|
|
def remove_members(user_ids)
|
|
inbox_members.where(user_id: user_ids).destroy_all
|
|
update_account_cache
|
|
end
|
|
|
|
# Sanitizes inbox name for balanced email provider compatibility
|
|
# ALLOWS: /'._- and Unicode letters/numbers/emojis
|
|
# REMOVES: Forbidden chars (\<>@"()) + spam-trigger symbols (!#$%&*+=?^`{|}~)
|
|
def sanitized_name
|
|
return default_name_for_blank_name if name.blank?
|
|
|
|
sanitized = apply_sanitization_rules(name)
|
|
sanitized.blank? && email? ? display_name_from_email : sanitized
|
|
end
|
|
|
|
def sanitized_business_name
|
|
sanitize_raw_name(business_name) || sanitized_name
|
|
end
|
|
|
|
def sms?
|
|
channel_type == 'Channel::Sms'
|
|
end
|
|
|
|
def facebook?
|
|
channel_type == 'Channel::FacebookPage'
|
|
end
|
|
|
|
def instagram?
|
|
(facebook? || instagram_direct?) && channel.instagram_id.present?
|
|
end
|
|
|
|
def instagram_direct?
|
|
channel_type == 'Channel::Instagram'
|
|
end
|
|
|
|
def tiktok?
|
|
channel_type == 'Channel::Tiktok'
|
|
end
|
|
|
|
def web_widget?
|
|
channel_type == 'Channel::WebWidget'
|
|
end
|
|
|
|
def api?
|
|
channel_type == 'Channel::Api'
|
|
end
|
|
|
|
def email?
|
|
channel_type == 'Channel::Email'
|
|
end
|
|
|
|
def twilio?
|
|
channel_type == 'Channel::TwilioSms'
|
|
end
|
|
|
|
def twitter?
|
|
channel_type == 'Channel::TwitterProfile'
|
|
end
|
|
|
|
def telegram?
|
|
channel_type == 'Channel::Telegram'
|
|
end
|
|
|
|
def whatsapp?
|
|
channel_type == 'Channel::Whatsapp'
|
|
end
|
|
|
|
def twilio_whatsapp?
|
|
channel_type == 'Channel::TwilioSms' && channel.medium == 'whatsapp'
|
|
end
|
|
|
|
def assignable_agents
|
|
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
|
|
end
|
|
|
|
def active_bot?
|
|
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
|
|
status: 'enabled').count.positive?
|
|
end
|
|
|
|
def inbox_type
|
|
channel.name
|
|
end
|
|
|
|
def webhook_data
|
|
{
|
|
id: id,
|
|
name: name
|
|
}
|
|
end
|
|
|
|
def callback_webhook_url
|
|
case channel_type
|
|
when 'Channel::TwilioSms'
|
|
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
|
|
when 'Channel::Sms'
|
|
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
|
|
when 'Channel::Line'
|
|
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
|
|
when 'Channel::Whatsapp'
|
|
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
|
|
end
|
|
end
|
|
|
|
def member_ids_with_assignment_capacity
|
|
members.ids
|
|
end
|
|
|
|
def auto_assignment_v2_enabled?
|
|
account.feature_enabled?('assignment_v2')
|
|
end
|
|
|
|
# Callers (Reauthorizable) only invoke this on a real transition, so the previous
|
|
# value is always the inverse of the new boolean value.
|
|
def dispatch_reauthorization_event(reauthorization_required)
|
|
return if ENV['ENABLE_INBOX_EVENTS'].blank?
|
|
|
|
changed_attributes = { reauthorization_required: [!reauthorization_required, reauthorization_required] }
|
|
Rails.configuration.dispatcher.dispatch(INBOX_UPDATED, Time.zone.now, inbox: self, changed_attributes: changed_attributes)
|
|
end
|
|
|
|
private
|
|
|
|
def default_name_for_blank_name
|
|
email? ? display_name_from_email : ''
|
|
end
|
|
|
|
def sanitize_raw_name(raw)
|
|
return nil if raw.blank?
|
|
|
|
result = apply_sanitization_rules(raw)
|
|
result.presence
|
|
end
|
|
|
|
def apply_sanitization_rules(name)
|
|
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;()]/, '') # Remove forbidden chars
|
|
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
|
|
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
|
|
.gsub(/\s+/, ' ') # Normalize spaces
|
|
.strip
|
|
end
|
|
|
|
def display_name_from_email
|
|
channel.email.split('@').first.parameterize.titleize
|
|
end
|
|
|
|
def dispatch_create_event
|
|
return if ENV['ENABLE_INBOX_EVENTS'].blank?
|
|
|
|
Rails.configuration.dispatcher.dispatch(INBOX_CREATED, Time.zone.now, inbox: self)
|
|
end
|
|
|
|
def dispatch_update_event
|
|
return if ENV['ENABLE_INBOX_EVENTS'].blank?
|
|
|
|
Rails.configuration.dispatcher.dispatch(INBOX_UPDATED, Time.zone.now, inbox: self, changed_attributes: previous_changes)
|
|
end
|
|
|
|
def ensure_valid_max_assignment_limit
|
|
# overridden in enterprise/app/models/enterprise/inbox.rb
|
|
end
|
|
|
|
def delete_round_robin_agents
|
|
::AutoAssignment::InboxRoundRobinService.new(inbox: self).clear_queue
|
|
end
|
|
|
|
def check_channel_type?
|
|
['Channel::Email', 'Channel::Api', 'Channel::WebWidget'].include?(channel_type)
|
|
end
|
|
end
|
|
|
|
Inbox.prepend_mod_with('Inbox')
|
|
Inbox.include_mod_with('Audit::Inbox')
|
|
Inbox.include_mod_with('Concerns::Inbox')
|