DEV: properly namespace chat (#20690)

This commit main goal was to comply with Zeitwerk and properly rely on autoloading. To achieve this, most resources have been namespaced under the `Chat` module.

- Given all models are now namespaced with `Chat::` and would change the stored types in DB when using polymorphism or STI (single table inheritance), this commit uses various Rails methods to ensure proper class is loaded and the stored name in DB is unchanged, eg: `Chat::Message` model will be stored as `"ChatMessage"`, and `"ChatMessage"` will correctly load `Chat::Message` model.
- Jobs are now using constants only, eg: `Jobs::Chat::Foo` and should only be enqueued this way

Notes:
- This commit also used this opportunity to limit the number of registered css files in plugin.rb
- `discourse_dev` support has been removed within this commit and will be reintroduced later

<!-- NOTE: All pull requests should have tests (rspec in Ruby, qunit in JavaScript). If your code does not include test coverage, please include an explanation of why it was omitted. -->
This commit is contained in:
Joffrey JAFFEUX 2023-03-17 14:24:38 +01:00 committed by GitHub
parent 74349e17c9
commit 12a18d4d55
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
343 changed files with 9077 additions and 8745 deletions

View File

@ -129,7 +129,7 @@ class Reviewable < ActiveRecord::Base
update_args = {
status: statuses[:pending],
id: target.id,
type: target.class.name,
type: target.class.sti_name,
potential_spam: potential_spam == true ? true : nil,
}

View File

@ -1,60 +0,0 @@
# frozen_string_literal: true
class Chat::AdminIncomingChatWebhooksController < Admin::AdminController
requires_plugin Chat::PLUGIN_NAME
def index
render_serialized(
{
chat_channels: ChatChannel.public_channels,
incoming_chat_webhooks: IncomingChatWebhook.includes(:chat_channel).all,
},
AdminChatIndexSerializer,
root: false,
)
end
def create
params.require(%i[name chat_channel_id])
chat_channel = ChatChannel.find_by(id: params[:chat_channel_id])
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
webhook = IncomingChatWebhook.new(name: params[:name], chat_channel: chat_channel)
if webhook.save
render_serialized(webhook, IncomingChatWebhookSerializer, root: false)
else
render_json_error(webhook)
end
end
def update
params.require(%i[incoming_chat_webhook_id name chat_channel_id])
webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id])
raise Discourse::NotFound unless webhook
chat_channel = ChatChannel.find_by(id: params[:chat_channel_id])
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
if webhook.update(
name: params[:name],
description: params[:description],
emoji: params[:emoji],
username: params[:username],
chat_channel: chat_channel,
)
render json: success_json
else
render_json_error(webhook)
end
end
def destroy
params.require(:incoming_chat_webhook_id)
webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id])
webhook.destroy if webhook
render json: success_json
end
end

View File

@ -1,11 +0,0 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelsStatusController < Chat::Api::ChatChannelsController
def update
with_service(Chat::Service::UpdateChannelStatus) do
on_success { render_serialized(result.channel, ChatChannelSerializer, root: "channel") }
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess }
end
end
end

View File

@ -1,8 +0,0 @@
# frozen_string_literal: true
class Chat::Api::ChatCurrentUserChannelsController < Chat::Api
def index
structured = Chat::ChatChannelFetcher.structured(guardian)
render_serialized(structured, ChatChannelIndexSerializer, root: false)
end
end

View File

@ -1,29 +0,0 @@
# frozen_string_literal: true
class Chat::Api < Chat::ChatBaseController
before_action :ensure_logged_in
before_action :ensure_can_chat
include Chat::WithServiceHelper
private
def ensure_can_chat
raise Discourse::NotFound unless SiteSetting.chat_enabled
guardian.ensure_can_chat!
end
def default_actions_for_service
proc do
on_success { render(json: success_json) }
on_failure { render(json: failed_json, status: 422) }
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
on_failed_contract do
render(
json: failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages),
status: 400,
)
end
end
end
end

View File

@ -0,0 +1,64 @@
# frozen_string_literal: true
module Chat
module Admin
class IncomingWebhooksController < ::Admin::AdminController
requires_plugin Chat::PLUGIN_NAME
def index
render_serialized(
{
chat_channels: Chat::Channel.public_channels,
incoming_chat_webhooks: Chat::IncomingWebhook.includes(:chat_channel).all,
},
Chat::AdminChatIndexSerializer,
root: false,
)
end
def create
params.require(%i[name chat_channel_id])
chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id])
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
webhook = Chat::IncomingWebhook.new(name: params[:name], chat_channel: chat_channel)
if webhook.save
render_serialized(webhook, Chat::IncomingWebhookSerializer, root: false)
else
render_json_error(webhook)
end
end
def update
params.require(%i[incoming_chat_webhook_id name chat_channel_id])
webhook = Chat::IncomingWebhook.find_by(id: params[:incoming_chat_webhook_id])
raise Discourse::NotFound unless webhook
chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id])
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
if webhook.update(
name: params[:name],
description: params[:description],
emoji: params[:emoji],
username: params[:username],
chat_channel: chat_channel,
)
render json: success_json
else
render_json_error(webhook)
end
end
def destroy
params.require(:incoming_chat_webhook_id)
webhook = Chat::IncomingWebhook.find_by(id: params[:incoming_chat_webhook_id])
webhook.destroy if webhook
render json: success_json
end
end
end
end

View File

@ -1,9 +1,9 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelThreadsController < Chat::Api
class Chat::Api::ChannelThreadsController < Chat::ApiController
def show
with_service(Chat::Service::LookupThread) do
on_success { render_serialized(result.thread, ChatThreadSerializer, root: "thread") }
with_service(::Chat::LookupThread) do
on_success { render_serialized(result.thread, ::Chat::ThreadSerializer, root: "thread") }
on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound }
on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
on_model_not_found(:thread) { raise Discourse::NotFound }

View File

@ -1,13 +1,13 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelsArchivesController < Chat::Api::ChatChannelsController
class Chat::Api::ChannelsArchivesController < Chat::Api::ChannelsController
def create
existing_archive = channel_from_params.chat_channel_archive
if existing_archive.present?
guardian.ensure_can_change_channel_status!(channel_from_params, :archived)
raise Discourse::InvalidAccess if !existing_archive.failed?
Chat::ChatChannelArchiveService.retry_archive_process(chat_channel: channel_from_params)
Chat::ChannelArchiveService.retry_archive_process(chat_channel: channel_from_params)
return render json: success_json
end
@ -20,12 +20,12 @@ class Chat::Api::ChatChannelsArchivesController < Chat::Api::ChatChannelsControl
end
begin
Chat::ChatChannelArchiveService.create_archive_process(
Chat::ChannelArchiveService.create_archive_process(
chat_channel: channel_from_params,
acting_user: current_user,
topic_params: topic_params,
)
rescue Chat::ChatChannelArchiveService::ArchiveValidationError => err
rescue Chat::ChannelArchiveService::ArchiveValidationError => err
return render json: failed_json.merge(errors: err.errors), status: 400
end

View File

@ -3,19 +3,19 @@
CHANNEL_EDITABLE_PARAMS = %i[name description slug]
CATEGORY_CHANNEL_EDITABLE_PARAMS = %i[auto_join_users allow_channel_wide_mentions]
class Chat::Api::ChatChannelsController < Chat::Api
class Chat::Api::ChannelsController < Chat::ApiController
def index
permitted = params.permit(:filter, :limit, :offset, :status)
options = { filter: permitted[:filter], limit: (permitted[:limit] || 25).to_i }
options[:offset] = permitted[:offset].to_i
options[:status] = ChatChannel.statuses[permitted[:status]] ? permitted[:status] : nil
options[:status] = Chat::Channel.statuses[permitted[:status]] ? permitted[:status] : nil
memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user)
channels = Chat::ChatChannelFetcher.secured_public_channels(guardian, memberships, options)
memberships = Chat::ChannelMembershipManager.all_for_user(current_user)
channels = Chat::ChannelFetcher.secured_public_channels(guardian, memberships, options)
serialized_channels =
channels.map do |channel|
ChatChannelSerializer.new(
Chat::ChannelSerializer.new(
channel,
scope: Guardian.new(current_user),
membership: memberships.find { |membership| membership.chat_channel_id == channel.id },
@ -29,7 +29,7 @@ class Chat::Api::ChatChannelsController < Chat::Api
end
def destroy
with_service Chat::Service::TrashChannel do
with_service Chat::TrashChannel do
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
end
end
@ -43,7 +43,7 @@ class Chat::Api::ChatChannelsController < Chat::Api
raise Discourse::InvalidParameters.new(:name)
end
if ChatChannel.exists?(
if Chat::Channel.exists?(
chatable_type: "Category",
chatable_id: channel_params[:chatable_id],
name: channel_params[:name],
@ -69,12 +69,12 @@ class Chat::Api::ChatChannelsController < Chat::Api
channel.user_chat_channel_memberships.create!(user: current_user, following: true)
if channel.auto_join_users
Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
end
render_serialized(
channel,
ChatChannelSerializer,
Chat::ChannelSerializer,
membership: channel.membership_for(current_user),
root: "channel",
)
@ -83,7 +83,7 @@ class Chat::Api::ChatChannelsController < Chat::Api
def show
render_serialized(
channel_from_params,
ChatChannelSerializer,
Chat::ChannelSerializer,
membership: channel_from_params.membership_for(current_user),
root: "channel",
)
@ -96,11 +96,11 @@ class Chat::Api::ChatChannelsController < Chat::Api
auto_join_limiter(channel_from_params).performed!
end
with_service(Chat::Service::UpdateChannel, **params_to_edit) do
with_service(Chat::UpdateChannel, **params_to_edit) do
on_success do
render_serialized(
result.channel,
ChatChannelSerializer,
Chat::ChannelSerializer,
root: "channel",
membership: result.channel.membership_for(current_user),
)
@ -116,7 +116,7 @@ class Chat::Api::ChatChannelsController < Chat::Api
def channel_from_params
@channel ||=
begin
channel = ChatChannel.find(params.require(:channel_id))
channel = Chat::Channel.find(params.require(:channel_id))
guardian.ensure_can_preview_chat_channel!(channel)
channel
end
@ -126,7 +126,7 @@ class Chat::Api::ChatChannelsController < Chat::Api
@membership ||=
begin
membership =
Chat::ChatChannelMembershipManager.new(channel_from_params).find_for_user(current_user)
Chat::ChannelMembershipManager.new(channel_from_params).find_for_user(current_user)
raise Discourse::NotFound if membership.blank?
membership
end

View File

@ -1,12 +1,12 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatChannelsController
class Chat::Api::ChannelsCurrentUserMembershipController < Chat::Api::ChannelsController
def create
guardian.ensure_can_join_chat_channel!(channel_from_params)
render_serialized(
channel_from_params.add(current_user),
UserChatChannelMembershipSerializer,
Chat::UserChannelMembershipSerializer,
root: "membership",
)
end
@ -14,7 +14,7 @@ class Chat::Api::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatCh
def destroy
render_serialized(
channel_from_params.remove(current_user),
UserChatChannelMembershipSerializer,
Chat::UserChannelMembershipSerializer,
root: "membership",
)
end

View File

@ -2,13 +2,13 @@
MEMBERSHIP_EDITABLE_PARAMS = %i[muted desktop_notification_level mobile_notification_level]
class Chat::Api::ChatChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChatChannelsController
class Chat::Api::ChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChannelsController
def update
settings_params = params.require(:notifications_settings).permit(MEMBERSHIP_EDITABLE_PARAMS)
membership_from_params.update!(settings_params.to_h)
render_serialized(
membership_from_params,
UserChatChannelMembershipSerializer,
Chat::UserChannelMembershipSerializer,
root: "membership",
)
end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsController
class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController
def index
params.permit(:username, :offset, :limit)
@ -8,7 +8,7 @@ class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsCont
limit = (params[:limit] || 50).to_i.clamp(1, 50)
memberships =
ChatChannelMembershipsQuery.call(
Chat::ChannelMembershipsQuery.call(
channel: channel_from_params,
offset: offset,
limit: limit,
@ -17,7 +17,7 @@ class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsCont
render_serialized(
memberships,
UserChatChannelMembershipSerializer,
Chat::UserChannelMembershipSerializer,
root: "memberships",
meta: {
total_rows: channel_from_params.user_count,

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsController
class Chat::Api::ChannelsMessagesMovesController < Chat::Api::ChannelsController
def create
move_params = params.require(:move)
move_params.require(:message_ids)
@ -8,10 +8,7 @@ class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsCo
raise Discourse::InvalidAccess if !guardian.can_move_chat_messages?(channel_from_params)
destination_channel =
Chat::ChatChannelFetcher.find_with_access_check(
move_params[:destination_channel_id],
guardian,
)
Chat::ChannelFetcher.find_with_access_check(move_params[:destination_channel_id], guardian)
begin
message_ids = move_params[:message_ids].map(&:to_i)

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Chat::Api::ChannelsStatusController < Chat::Api::ChannelsController
def update
with_service(Chat::UpdateChannelStatus) do
on_success { render_serialized(result.channel, Chat::ChannelSerializer, root: "channel") }
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess }
end
end
end

View File

@ -1,13 +1,14 @@
# frozen_string_literal: true
class Chat::Api::ChatChatablesController < Chat::Api
class Chat::Api::ChatablesController < Chat::ApiController
def index
params.require(:filter)
filter = params[:filter].downcase
memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user)
memberships = Chat::ChannelMembershipManager.all_for_user(current_user)
public_channels =
Chat::ChatChannelFetcher.secured_public_channels(
Chat::ChannelFetcher.secured_public_channels(
guardian,
memberships,
filter: filter,
@ -41,7 +42,7 @@ class Chat::Api::ChatChatablesController < Chat::Api
direct_message_channels =
if users.count > 0
# FIXME: investigate the cost of this query
ChatChannel
Chat::Channel
.includes(chatable: :users)
.joins(direct_message: :direct_message_users)
.group(1)
@ -75,7 +76,7 @@ class Chat::Api::ChatChatablesController < Chat::Api
users: users_without_channel,
memberships: memberships,
},
ChatChannelSearchSerializer,
Chat::ChannelSearchSerializer,
root: false,
)
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Chat::Api::CurrentUserChannelsController < Chat::ApiController
def index
structured = Chat::ChannelFetcher.structured(guardian)
render_serialized(structured, Chat::ChannelIndexSerializer, root: false)
end
end

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
module Chat
class ApiController < ::Chat::BaseController
before_action :ensure_logged_in
before_action :ensure_can_chat
include Chat::WithServiceHelper
private
def ensure_can_chat
raise Discourse::NotFound unless SiteSetting.chat_enabled
guardian.ensure_can_chat!
end
def default_actions_for_service
proc do
on_success { render(json: success_json) }
on_failure { render(json: failed_json, status: 422) }
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
on_failed_contract do
render(
json:
failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages),
status: 400,
)
end
end
end
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
module Chat
class BaseController < ::ApplicationController
before_action :ensure_logged_in
before_action :ensure_can_chat
private
def ensure_can_chat
raise Discourse::NotFound unless SiteSetting.chat_enabled
guardian.ensure_can_chat!
end
def set_channel_and_chatable_with_access_check(chat_channel_id: nil)
params.require(:chat_channel_id) if chat_channel_id.blank?
id_or_name = chat_channel_id || params[:chat_channel_id]
@chat_channel = Chat::ChannelFetcher.find_with_access_check(id_or_name, guardian)
@chatable = @chat_channel.chatable
end
end
end

View File

@ -0,0 +1,481 @@
# frozen_string_literal: true
module Chat
class ChatController < ::Chat::BaseController
PAST_MESSAGE_LIMIT = 40
FUTURE_MESSAGE_LIMIT = 40
PAST = "past"
FUTURE = "future"
CHAT_DIRECTIONS = [PAST, FUTURE]
# Other endpoints use set_channel_and_chatable_with_access_check, but
# these endpoints require a standalone find because they need to be
# able to get deleted channels and recover them.
before_action :find_chatable, only: %i[enable_chat disable_chat]
before_action :find_chat_message,
only: %i[delete restore lookup_message edit_message rebake message_link]
before_action :set_channel_and_chatable_with_access_check,
except: %i[
respond
enable_chat
disable_chat
message_link
lookup_message
set_user_chat_status
dismiss_retention_reminder
flag
]
def respond
render
end
def enable_chat
chat_channel = Chat::Channel.with_deleted.find_by(chatable_id: @chatable)
guardian.ensure_can_join_chat_channel!(chat_channel) if chat_channel
if chat_channel && chat_channel.trashed?
chat_channel.recover!
elsif chat_channel
return render_json_error I18n.t("chat.already_enabled")
else
chat_channel = @chatable.chat_channel
guardian.ensure_can_join_chat_channel!(chat_channel)
end
success = chat_channel.save
if success && chat_channel.chatable_has_custom_fields?
@chatable.custom_fields[Chat::HAS_CHAT_ENABLED] = true
@chatable.save!
end
if success
membership = Chat::ChannelMembershipManager.new(channel).follow(user)
render_serialized(chat_channel, Chat::ChannelSerializer, membership: membership)
else
render_json_error(chat_channel)
end
Chat::ChannelMembershipManager.new(channel).follow(user)
end
def disable_chat
chat_channel = Chat::Channel.with_deleted.find_by(chatable_id: @chatable)
guardian.ensure_can_join_chat_channel!(chat_channel)
return render json: success_json if chat_channel.trashed?
chat_channel.trash!(current_user)
success = chat_channel.save
if success
if chat_channel.chatable_has_custom_fields?
@chatable.custom_fields.delete(Chat::HAS_CHAT_ENABLED)
@chatable.save!
end
render json: success_json
else
render_json_error(chat_channel)
end
end
def create_message
raise Discourse::InvalidAccess if current_user.silenced?
Chat::MessageRateLimiter.run!(current_user)
@user_chat_channel_membership =
Chat::ChannelMembershipManager.new(@chat_channel).find_for_user(
current_user,
following: true,
)
raise Discourse::InvalidAccess unless @user_chat_channel_membership
reply_to_msg_id = params[:in_reply_to_id]
if reply_to_msg_id
rm = Chat::Message.find(reply_to_msg_id)
raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id
end
content = params[:message]
chat_message_creator =
Chat::MessageCreator.create(
chat_channel: @chat_channel,
user: current_user,
in_reply_to_id: reply_to_msg_id,
content: content,
staged_id: params[:staged_id],
upload_ids: params[:upload_ids],
)
return render_json_error(chat_message_creator.error) if chat_message_creator.failed?
@user_chat_channel_membership.update!(
last_read_message_id: chat_message_creator.chat_message.id,
)
if @chat_channel.direct_message_channel?
# If any of the channel users is ignoring, muting, or preventing DMs from
# the current user then we shold not auto-follow the channel once again or
# publish the new channel.
user_ids_allowing_communication =
UserCommScreener.new(
acting_user: current_user,
target_user_ids: @chat_channel.user_chat_channel_memberships.pluck(:user_id),
).allowing_actor_communication
if user_ids_allowing_communication.any?
Chat::Publisher.publish_new_channel(
@chat_channel,
@chat_channel.chatable.users.where(id: user_ids_allowing_communication),
)
@chat_channel
.user_chat_channel_memberships
.where(user_id: user_ids_allowing_communication)
.update_all(following: true)
end
end
Chat::Publisher.publish_user_tracking_state(
current_user,
@chat_channel.id,
chat_message_creator.chat_message.id,
)
render json: success_json
end
def edit_message
chat_message_updater =
Chat::MessageUpdater.update(
guardian: guardian,
chat_message: @message,
new_content: params[:new_message],
upload_ids: params[:upload_ids] || [],
)
return render_json_error(chat_message_updater.error) if chat_message_updater.failed?
render json: success_json
end
def update_user_last_read
membership =
Chat::ChannelMembershipManager.new(@chat_channel).find_for_user(
current_user,
following: true,
)
raise Discourse::NotFound if membership.nil?
if membership.last_read_message_id &&
params[:message_id].to_i < membership.last_read_message_id
raise Discourse::InvalidParameters.new(:message_id)
end
unless Chat::Message.with_deleted.exists?(
chat_channel_id: @chat_channel.id,
id: params[:message_id],
)
raise Discourse::NotFound
end
membership.update!(last_read_message_id: params[:message_id])
Notification
.where(notification_type: Notification.types[:chat_mention])
.where(user: current_user)
.where(read: false)
.joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id")
.joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id")
.where("chat_messages.id <= ?", params[:message_id].to_i)
.where("chat_messages.chat_channel_id = ?", @chat_channel.id)
.update_all(read: true)
Chat::Publisher.publish_user_tracking_state(
current_user,
@chat_channel.id,
params[:message_id],
)
render json: success_json
end
def messages
page_size = params[:page_size]&.to_i || 1000
direction = params[:direction].to_s
message_id = params[:message_id]
if page_size > 50 ||
(
message_id.blank? ^ direction.blank? &&
(direction.present? && !CHAT_DIRECTIONS.include?(direction))
)
raise Discourse::InvalidParameters
end
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
if message_id.present?
condition = direction == PAST ? "<" : ">"
messages = messages.where("id #{condition} ?", message_id.to_i)
end
# NOTE: This order is reversed when we return the Chat::View below if the direction
# is not FUTURE.
order = direction == FUTURE ? "ASC" : "DESC"
messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a
can_load_more_past = nil
can_load_more_future = nil
if direction == FUTURE
can_load_more_future = messages.size == page_size
elsif direction == PAST
can_load_more_past = messages.size == page_size
else
# When direction is blank, we'll return the latest messages.
can_load_more_future = false
can_load_more_past = messages.size == page_size
end
chat_view =
Chat::View.new(
chat_channel: @chat_channel,
chat_messages: direction == FUTURE ? messages : messages.reverse,
user: current_user,
can_load_more_past: can_load_more_past,
can_load_more_future: can_load_more_future,
)
render_serialized(chat_view, Chat::ViewSerializer, root: false)
end
def react
params.require(%i[message_id emoji react_action])
guardian.ensure_can_react!
Chat::MessageReactor.new(current_user, @chat_channel).react!(
message_id: params[:message_id],
react_action: params[:react_action].to_sym,
emoji: params[:emoji],
)
render json: success_json
end
def delete
guardian.ensure_can_delete_chat!(@message, @chatable)
Chat::MessageDestroyer.new.trash_message(@message, current_user)
head :ok
end
def restore
chat_channel = @message.chat_channel
guardian.ensure_can_restore_chat!(@message, chat_channel.chatable)
updated = @message.recover!
if updated
Chat::Publisher.publish_restore!(chat_channel, @message)
render json: success_json
else
render_json_error(@message)
end
end
def rebake
guardian.ensure_can_rebake_chat_message!(@message)
@message.rebake!(invalidate_oneboxes: true)
render json: success_json
end
def message_link
raise Discourse::NotFound if @message.blank? || @message.deleted_at.present?
raise Discourse::NotFound if @message.chat_channel.blank?
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
render json:
success_json.merge(
chat_channel_id: @chat_channel.id,
chat_channel_title: @chat_channel.title(current_user),
)
end
def lookup_message
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
past_messages =
messages
.where("created_at < ?", @message.created_at)
.order(created_at: :desc)
.limit(PAST_MESSAGE_LIMIT)
future_messages =
messages
.where("created_at > ?", @message.created_at)
.order(created_at: :asc)
.limit(FUTURE_MESSAGE_LIMIT)
can_load_more_past = past_messages.count == PAST_MESSAGE_LIMIT
can_load_more_future = future_messages.count == FUTURE_MESSAGE_LIMIT
messages = [past_messages.reverse, [@message], future_messages].reduce([], :concat)
chat_view =
Chat::View.new(
chat_channel: @chat_channel,
chat_messages: messages,
user: current_user,
can_load_more_past: can_load_more_past,
can_load_more_future: can_load_more_future,
)
render_serialized(chat_view, Chat::ViewSerializer, root: false)
end
def set_user_chat_status
params.require(:chat_enabled)
current_user.user_option.update(chat_enabled: params[:chat_enabled])
render json: { chat_enabled: current_user.user_option.chat_enabled }
end
def invite_users
params.require(:user_ids)
users =
User
.includes(:groups)
.joins(:user_option)
.where(user_options: { chat_enabled: true })
.not_suspended
.where(id: params[:user_ids])
users.each do |user|
guardian = Guardian.new(user)
if guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel)
data = {
message: "chat.invitation_notification",
chat_channel_id: @chat_channel.id,
chat_channel_title: @chat_channel.title(user),
chat_channel_slug: @chat_channel.slug,
invited_by_username: current_user.username,
}
data[:chat_message_id] = params[:chat_message_id] if params[:chat_message_id]
user.notifications.create(
notification_type: Notification.types[:chat_invitation],
high_priority: true,
data: data.to_json,
)
end
end
render json: success_json
end
def dismiss_retention_reminder
params.require(:chatable_type)
guardian.ensure_can_chat!
unless Chat::Channel.chatable_types.include?(params[:chatable_type])
raise Discourse::InvalidParameters
end
field =
(
if Chat::Channel.public_channel_chatable_types.include?(params[:chatable_type])
:dismissed_channel_retention_reminder
else
:dismissed_dm_retention_reminder
end
)
current_user.user_option.update(field => true)
render json: success_json
end
def quote_messages
params.require(:message_ids)
message_ids = params[:message_ids].map(&:to_i)
markdown =
Chat::TranscriptService.new(
@chat_channel,
current_user,
messages_or_ids: message_ids,
).generate_markdown
render json: success_json.merge(markdown: markdown)
end
def flag
RateLimiter.new(current_user, "flag_chat_message", 4, 1.minutes).performed!
permitted_params =
params.permit(
%i[chat_message_id flag_type_id message is_warning take_action queue_for_review],
)
chat_message =
Chat::Message.includes(:chat_channel, :revisions).find(permitted_params[:chat_message_id])
flag_type_id = permitted_params[:flag_type_id].to_i
if !ReviewableScore.types.values.include?(flag_type_id)
raise Discourse::InvalidParameters.new(:flag_type_id)
end
set_channel_and_chatable_with_access_check(chat_channel_id: chat_message.chat_channel_id)
result =
Chat::ReviewQueue.new.flag_message(chat_message, guardian, flag_type_id, permitted_params)
if result[:success]
render json: success_json
else
render_json_error(result[:errors])
end
end
def set_draft
if params[:data].present?
Chat::Draft.find_or_initialize_by(
user: current_user,
chat_channel_id: @chat_channel.id,
).update!(data: params[:data])
else
Chat::Draft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all
end
render json: success_json
end
private
def preloaded_chat_message_query
query =
Chat::Message
.includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]])
.includes(:revisions)
.includes(user: :primary_group)
.includes(chat_webhook_event: :incoming_chat_webhook)
.includes(reactions: :user)
.includes(:bookmarks)
.includes(:uploads)
.includes(chat_channel: :chatable)
query = query.includes(user: :user_status) if SiteSetting.enable_user_status
query
end
def find_chatable
@chatable = Category.find_by(id: params[:chatable_id])
guardian.ensure_can_moderate_chat!(@chatable)
end
def find_chat_message
@message = preloaded_chat_message_query.with_deleted
@message = @message.where(chat_channel_id: params[:chat_channel_id]) if params[
:chat_channel_id
]
@message = @message.find_by(id: params[:message_id])
raise Discourse::NotFound unless @message
end
end
end

View File

@ -0,0 +1,57 @@
# frozen_string_literal: true
module Chat
class DirectMessagesController < ::Chat::BaseController
# NOTE: For V1 of chat channel archiving and deleting we are not doing
# anything for DM channels, their behaviour will stay as is.
def create
guardian.ensure_can_chat!
users = users_from_usernames(current_user, params)
begin
chat_channel =
Chat::DirectMessageChannelCreator.create!(acting_user: current_user, target_users: users)
render_serialized(
chat_channel,
Chat::ChannelSerializer,
root: "channel",
membership: chat_channel.membership_for(current_user),
)
rescue Chat::DirectMessageChannelCreator::NotAllowed => err
render_json_error(err.message)
end
end
def index
guardian.ensure_can_chat!
users = users_from_usernames(current_user, params)
direct_message = Chat::DirectMessage.for_user_ids(users.map(&:id).uniq)
if direct_message
chat_channel = Chat::Channel.find_by(chatable_id: direct_message)
render_serialized(
chat_channel,
Chat::ChannelSerializer,
root: "channel",
membership: chat_channel.membership_for(current_user),
)
else
render body: nil, status: 404
end
end
private
def users_from_usernames(current_user, params)
params.require(:usernames)
usernames =
(params[:usernames].is_a?(String) ? params[:usernames].split(",") : params[:usernames])
users = [current_user]
other_usernames = usernames - [current_user.username]
users.concat(User.where(username: other_usernames).to_a) if other_usernames.any?
users
end
end
end

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
module Chat
class EmojisController < ::Chat::BaseController
def index
emojis = Emoji.all.group_by(&:group)
render json: MultiJson.dump(emojis)
end
end
end

View File

@ -0,0 +1,113 @@
# frozen_string_literal: true
module Chat
class IncomingWebhooksController < ::ApplicationController
WEBHOOK_MESSAGES_PER_MINUTE_LIMIT = 10
skip_before_action :verify_authenticity_token, :redirect_to_login_if_required
before_action :validate_payload
def create_message
debug_payload
process_webhook_payload(text: params[:text], key: params[:key])
end
# See https://api.slack.com/reference/messaging/payload for the
# slack message payload format. For now we only support the
# text param, which we preprocess lightly to remove the slack-isms
# in the formatting.
def create_message_slack_compatible
debug_payload
# See note in validate_payload on why this is needed
attachments =
if params[:payload].present?
payload = params[:payload]
if String === payload
payload = JSON.parse(payload)
payload.deep_symbolize_keys!
end
payload[:attachments]
else
params[:attachments]
end
if params[:text].present?
text = Chat::SlackCompatibility.process_text(params[:text])
else
text = Chat::SlackCompatibility.process_legacy_attachments(attachments)
end
process_webhook_payload(text: text, key: params[:key])
rescue JSON::ParserError
raise Discourse::InvalidParameters
end
private
def process_webhook_payload(text:, key:)
validate_message_length(text)
webhook = find_and_rate_limit_webhook(key)
chat_message_creator =
Chat::MessageCreator.create(
chat_channel: webhook.chat_channel,
user: Discourse.system_user,
content: text,
incoming_chat_webhook: webhook,
)
if chat_message_creator.failed?
render_json_error(chat_message_creator.error)
else
render json: success_json
end
end
def find_and_rate_limit_webhook(key)
webhook = Chat::IncomingWebhook.includes(:chat_channel).find_by(key: key)
raise Discourse::NotFound unless webhook
# Rate limit to 10 messages per-minute. We can move to a site setting in the future if needed.
RateLimiter.new(
nil,
"incoming_chat_webhook_#{webhook.id}",
WEBHOOK_MESSAGES_PER_MINUTE_LIMIT,
1.minute,
).performed!
webhook
end
def validate_message_length(message)
return if message.length <= SiteSetting.chat_maximum_message_length
raise Discourse::InvalidParameters.new(
"Body cannot be over #{SiteSetting.chat_maximum_message_length} characters",
)
end
# The webhook POST body can be in 3 different formats:
#
# * { text: "message text" }, which is the most basic method, and also mirrors Slack payloads
# * { attachments: [ text: "message text" ] }, which is a variant of Slack payloads using legacy attachments
# * { payload: "<JSON STRING>", attachments: null, text: null }, where JSON STRING can look
# like the `attachments` example above (along with other attributes), which is fired by OpsGenie
def validate_payload
params.require(:key)
if !params[:text] && !params[:payload] && !params[:attachments]
raise Discourse::InvalidParameters
end
end
def debug_payload
return if !SiteSetting.chat_debug_webhook_payloads
Rails.logger.warn(
"Debugging chat webhook payload for endpoint #{params[:key]}: " +
JSON.dump(
{ payload: params[:payload], attachments: params[:attachments], text: params[:text] },
),
)
end
end
end

View File

@ -1,20 +0,0 @@
# frozen_string_literal: true
class Chat::ChatBaseController < ::ApplicationController
before_action :ensure_logged_in
before_action :ensure_can_chat
private
def ensure_can_chat
raise Discourse::NotFound unless SiteSetting.chat_enabled
guardian.ensure_can_chat!
end
def set_channel_and_chatable_with_access_check(chat_channel_id: nil)
params.require(:chat_channel_id) if chat_channel_id.blank?
id_or_name = chat_channel_id || params[:chat_channel_id]
@chat_channel = Chat::ChatChannelFetcher.find_with_access_check(id_or_name, guardian)
@chatable = @chat_channel.chatable
end
end

View File

@ -1,472 +0,0 @@
# frozen_string_literal: true
class Chat::ChatController < Chat::ChatBaseController
PAST_MESSAGE_LIMIT = 40
FUTURE_MESSAGE_LIMIT = 40
PAST = "past"
FUTURE = "future"
CHAT_DIRECTIONS = [PAST, FUTURE]
# Other endpoints use set_channel_and_chatable_with_access_check, but
# these endpoints require a standalone find because they need to be
# able to get deleted channels and recover them.
before_action :find_chatable, only: %i[enable_chat disable_chat]
before_action :find_chat_message,
only: %i[delete restore lookup_message edit_message rebake message_link]
before_action :set_channel_and_chatable_with_access_check,
except: %i[
respond
enable_chat
disable_chat
message_link
lookup_message
set_user_chat_status
dismiss_retention_reminder
flag
]
def respond
render
end
def enable_chat
chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable)
guardian.ensure_can_join_chat_channel!(chat_channel) if chat_channel
if chat_channel && chat_channel.trashed?
chat_channel.recover!
elsif chat_channel
return render_json_error I18n.t("chat.already_enabled")
else
chat_channel = @chatable.chat_channel
guardian.ensure_can_join_chat_channel!(chat_channel)
end
success = chat_channel.save
if success && chat_channel.chatable_has_custom_fields?
@chatable.custom_fields[Chat::HAS_CHAT_ENABLED] = true
@chatable.save!
end
if success
membership = Chat::ChatChannelMembershipManager.new(channel).follow(user)
render_serialized(chat_channel, ChatChannelSerializer, membership: membership)
else
render_json_error(chat_channel)
end
Chat::ChatChannelMembershipManager.new(channel).follow(user)
end
def disable_chat
chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable)
guardian.ensure_can_join_chat_channel!(chat_channel)
return render json: success_json if chat_channel.trashed?
chat_channel.trash!(current_user)
success = chat_channel.save
if success
if chat_channel.chatable_has_custom_fields?
@chatable.custom_fields.delete(Chat::HAS_CHAT_ENABLED)
@chatable.save!
end
render json: success_json
else
render_json_error(chat_channel)
end
end
def create_message
raise Discourse::InvalidAccess if current_user.silenced?
Chat::ChatMessageRateLimiter.run!(current_user)
@user_chat_channel_membership =
Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user(
current_user,
following: true,
)
raise Discourse::InvalidAccess unless @user_chat_channel_membership
reply_to_msg_id = params[:in_reply_to_id]
if reply_to_msg_id
rm = ChatMessage.find(reply_to_msg_id)
raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id
end
content = params[:message]
chat_message_creator =
Chat::ChatMessageCreator.create(
chat_channel: @chat_channel,
user: current_user,
in_reply_to_id: reply_to_msg_id,
content: content,
staged_id: params[:staged_id],
upload_ids: params[:upload_ids],
)
return render_json_error(chat_message_creator.error) if chat_message_creator.failed?
@user_chat_channel_membership.update!(
last_read_message_id: chat_message_creator.chat_message.id,
)
if @chat_channel.direct_message_channel?
# If any of the channel users is ignoring, muting, or preventing DMs from
# the current user then we shold not auto-follow the channel once again or
# publish the new channel.
user_ids_allowing_communication =
UserCommScreener.new(
acting_user: current_user,
target_user_ids: @chat_channel.user_chat_channel_memberships.pluck(:user_id),
).allowing_actor_communication
if user_ids_allowing_communication.any?
ChatPublisher.publish_new_channel(
@chat_channel,
@chat_channel.chatable.users.where(id: user_ids_allowing_communication),
)
@chat_channel
.user_chat_channel_memberships
.where(user_id: user_ids_allowing_communication)
.update_all(following: true)
end
end
ChatPublisher.publish_user_tracking_state(
current_user,
@chat_channel.id,
chat_message_creator.chat_message.id,
)
render json: success_json
end
def edit_message
chat_message_updater =
Chat::ChatMessageUpdater.update(
guardian: guardian,
chat_message: @message,
new_content: params[:new_message],
upload_ids: params[:upload_ids] || [],
)
return render_json_error(chat_message_updater.error) if chat_message_updater.failed?
render json: success_json
end
def update_user_last_read
membership =
Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user(
current_user,
following: true,
)
raise Discourse::NotFound if membership.nil?
if membership.last_read_message_id && params[:message_id].to_i < membership.last_read_message_id
raise Discourse::InvalidParameters.new(:message_id)
end
unless ChatMessage.with_deleted.exists?(
chat_channel_id: @chat_channel.id,
id: params[:message_id],
)
raise Discourse::NotFound
end
membership.update!(last_read_message_id: params[:message_id])
Notification
.where(notification_type: Notification.types[:chat_mention])
.where(user: current_user)
.where(read: false)
.joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id")
.joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id")
.where("chat_messages.id <= ?", params[:message_id].to_i)
.where("chat_messages.chat_channel_id = ?", @chat_channel.id)
.update_all(read: true)
ChatPublisher.publish_user_tracking_state(current_user, @chat_channel.id, params[:message_id])
render json: success_json
end
def messages
page_size = params[:page_size]&.to_i || 1000
direction = params[:direction].to_s
message_id = params[:message_id]
if page_size > 50 ||
(
message_id.blank? ^ direction.blank? &&
(direction.present? && !CHAT_DIRECTIONS.include?(direction))
)
raise Discourse::InvalidParameters
end
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
if message_id.present?
condition = direction == PAST ? "<" : ">"
messages = messages.where("id #{condition} ?", message_id.to_i)
end
# NOTE: This order is reversed when we return the ChatView below if the direction
# is not FUTURE.
order = direction == FUTURE ? "ASC" : "DESC"
messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a
can_load_more_past = nil
can_load_more_future = nil
if direction == FUTURE
can_load_more_future = messages.size == page_size
elsif direction == PAST
can_load_more_past = messages.size == page_size
else
# When direction is blank, we'll return the latest messages.
can_load_more_future = false
can_load_more_past = messages.size == page_size
end
chat_view =
ChatView.new(
chat_channel: @chat_channel,
chat_messages: direction == FUTURE ? messages : messages.reverse,
user: current_user,
can_load_more_past: can_load_more_past,
can_load_more_future: can_load_more_future,
)
render_serialized(chat_view, ChatViewSerializer, root: false)
end
def react
params.require(%i[message_id emoji react_action])
guardian.ensure_can_react!
Chat::ChatMessageReactor.new(current_user, @chat_channel).react!(
message_id: params[:message_id],
react_action: params[:react_action].to_sym,
emoji: params[:emoji],
)
render json: success_json
end
def delete
guardian.ensure_can_delete_chat!(@message, @chatable)
ChatMessageDestroyer.new.trash_message(@message, current_user)
head :ok
end
def restore
chat_channel = @message.chat_channel
guardian.ensure_can_restore_chat!(@message, chat_channel.chatable)
updated = @message.recover!
if updated
ChatPublisher.publish_restore!(chat_channel, @message)
render json: success_json
else
render_json_error(@message)
end
end
def rebake
guardian.ensure_can_rebake_chat_message!(@message)
@message.rebake!(invalidate_oneboxes: true)
render json: success_json
end
def message_link
raise Discourse::NotFound if @message.blank? || @message.deleted_at.present?
raise Discourse::NotFound if @message.chat_channel.blank?
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
render json:
success_json.merge(
chat_channel_id: @chat_channel.id,
chat_channel_title: @chat_channel.title(current_user),
)
end
def lookup_message
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
past_messages =
messages
.where("created_at < ?", @message.created_at)
.order(created_at: :desc)
.limit(PAST_MESSAGE_LIMIT)
future_messages =
messages
.where("created_at > ?", @message.created_at)
.order(created_at: :asc)
.limit(FUTURE_MESSAGE_LIMIT)
can_load_more_past = past_messages.count == PAST_MESSAGE_LIMIT
can_load_more_future = future_messages.count == FUTURE_MESSAGE_LIMIT
messages = [past_messages.reverse, [@message], future_messages].reduce([], :concat)
chat_view =
ChatView.new(
chat_channel: @chat_channel,
chat_messages: messages,
user: current_user,
can_load_more_past: can_load_more_past,
can_load_more_future: can_load_more_future,
)
render_serialized(chat_view, ChatViewSerializer, root: false)
end
def set_user_chat_status
params.require(:chat_enabled)
current_user.user_option.update(chat_enabled: params[:chat_enabled])
render json: { chat_enabled: current_user.user_option.chat_enabled }
end
def invite_users
params.require(:user_ids)
users =
User
.includes(:groups)
.joins(:user_option)
.where(user_options: { chat_enabled: true })
.not_suspended
.where(id: params[:user_ids])
users.each do |user|
guardian = Guardian.new(user)
if guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel)
data = {
message: "chat.invitation_notification",
chat_channel_id: @chat_channel.id,
chat_channel_title: @chat_channel.title(user),
chat_channel_slug: @chat_channel.slug,
invited_by_username: current_user.username,
}
data[:chat_message_id] = params[:chat_message_id] if params[:chat_message_id]
user.notifications.create(
notification_type: Notification.types[:chat_invitation],
high_priority: true,
data: data.to_json,
)
end
end
render json: success_json
end
def dismiss_retention_reminder
params.require(:chatable_type)
guardian.ensure_can_chat!
unless ChatChannel.chatable_types.include?(params[:chatable_type])
raise Discourse::InvalidParameters
end
field =
(
if ChatChannel.public_channel_chatable_types.include?(params[:chatable_type])
:dismissed_channel_retention_reminder
else
:dismissed_dm_retention_reminder
end
)
current_user.user_option.update(field => true)
render json: success_json
end
def quote_messages
params.require(:message_ids)
message_ids = params[:message_ids].map(&:to_i)
markdown =
ChatTranscriptService.new(
@chat_channel,
current_user,
messages_or_ids: message_ids,
).generate_markdown
render json: success_json.merge(markdown: markdown)
end
def flag
RateLimiter.new(current_user, "flag_chat_message", 4, 1.minutes).performed!
permitted_params =
params.permit(
%i[chat_message_id flag_type_id message is_warning take_action queue_for_review],
)
chat_message =
ChatMessage.includes(:chat_channel, :revisions).find(permitted_params[:chat_message_id])
flag_type_id = permitted_params[:flag_type_id].to_i
if !ReviewableScore.types.values.include?(flag_type_id)
raise Discourse::InvalidParameters.new(:flag_type_id)
end
set_channel_and_chatable_with_access_check(chat_channel_id: chat_message.chat_channel_id)
result =
Chat::ChatReviewQueue.new.flag_message(chat_message, guardian, flag_type_id, permitted_params)
if result[:success]
render json: success_json
else
render_json_error(result[:errors])
end
end
def set_draft
if params[:data].present?
ChatDraft.find_or_initialize_by(
user: current_user,
chat_channel_id: @chat_channel.id,
).update!(data: params[:data])
else
ChatDraft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all
end
render json: success_json
end
private
def preloaded_chat_message_query
query =
ChatMessage
.includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]])
.includes(:revisions)
.includes(user: :primary_group)
.includes(chat_webhook_event: :incoming_chat_webhook)
.includes(reactions: :user)
.includes(:bookmarks)
.includes(:uploads)
.includes(chat_channel: :chatable)
query = query.includes(user: :user_status) if SiteSetting.enable_user_status
query
end
def find_chatable
@chatable = Category.find_by(id: params[:chatable_id])
guardian.ensure_can_moderate_chat!(@chatable)
end
def find_chat_message
@message = preloaded_chat_message_query.with_deleted
@message = @message.where(chat_channel_id: params[:chat_channel_id]) if params[:chat_channel_id]
@message = @message.find_by(id: params[:message_id])
raise Discourse::NotFound unless @message
end
end

View File

@ -1,55 +0,0 @@
# frozen_string_literal: true
class Chat::DirectMessagesController < Chat::ChatBaseController
# NOTE: For V1 of chat channel archiving and deleting we are not doing
# anything for DM channels, their behaviour will stay as is.
def create
guardian.ensure_can_chat!
users = users_from_usernames(current_user, params)
begin
chat_channel =
Chat::DirectMessageChannelCreator.create!(acting_user: current_user, target_users: users)
render_serialized(
chat_channel,
ChatChannelSerializer,
root: "channel",
membership: chat_channel.membership_for(current_user),
)
rescue Chat::DirectMessageChannelCreator::NotAllowed => err
render_json_error(err.message)
end
end
def index
guardian.ensure_can_chat!
users = users_from_usernames(current_user, params)
direct_message = DirectMessage.for_user_ids(users.map(&:id).uniq)
if direct_message
chat_channel = ChatChannel.find_by(chatable: direct_message)
render_serialized(
chat_channel,
ChatChannelSerializer,
root: "channel",
membership: chat_channel.membership_for(current_user),
)
else
render body: nil, status: 404
end
end
private
def users_from_usernames(current_user, params)
params.require(:usernames)
usernames =
(params[:usernames].is_a?(String) ? params[:usernames].split(",") : params[:usernames])
users = [current_user]
other_usernames = usernames - [current_user.username]
users.concat(User.where(username: other_usernames).to_a) if other_usernames.any?
users
end
end

View File

@ -1,8 +0,0 @@
# frozen_string_literal: true
class Chat::EmojisController < Chat::ChatBaseController
def index
emojis = Emoji.all.group_by(&:group)
render json: MultiJson.dump(emojis)
end
end

View File

@ -1,111 +0,0 @@
# frozen_string_literal: true
class Chat::IncomingChatWebhooksController < ApplicationController
WEBHOOK_MESSAGES_PER_MINUTE_LIMIT = 10
skip_before_action :verify_authenticity_token, :redirect_to_login_if_required
before_action :validate_payload
def create_message
debug_payload
process_webhook_payload(text: params[:text], key: params[:key])
end
# See https://api.slack.com/reference/messaging/payload for the
# slack message payload format. For now we only support the
# text param, which we preprocess lightly to remove the slack-isms
# in the formatting.
def create_message_slack_compatible
debug_payload
# See note in validate_payload on why this is needed
attachments =
if params[:payload].present?
payload = params[:payload]
if String === payload
payload = JSON.parse(payload)
payload.deep_symbolize_keys!
end
payload[:attachments]
else
params[:attachments]
end
if params[:text].present?
text = Chat::SlackCompatibility.process_text(params[:text])
else
text = Chat::SlackCompatibility.process_legacy_attachments(attachments)
end
process_webhook_payload(text: text, key: params[:key])
rescue JSON::ParserError
raise Discourse::InvalidParameters
end
private
def process_webhook_payload(text:, key:)
validate_message_length(text)
webhook = find_and_rate_limit_webhook(key)
chat_message_creator =
Chat::ChatMessageCreator.create(
chat_channel: webhook.chat_channel,
user: Discourse.system_user,
content: text,
incoming_chat_webhook: webhook,
)
if chat_message_creator.failed?
render_json_error(chat_message_creator.error)
else
render json: success_json
end
end
def find_and_rate_limit_webhook(key)
webhook = IncomingChatWebhook.includes(:chat_channel).find_by(key: key)
raise Discourse::NotFound unless webhook
# Rate limit to 10 messages per-minute. We can move to a site setting in the future if needed.
RateLimiter.new(
nil,
"incoming_chat_webhook_#{webhook.id}",
WEBHOOK_MESSAGES_PER_MINUTE_LIMIT,
1.minute,
).performed!
webhook
end
def validate_message_length(message)
return if message.length <= SiteSetting.chat_maximum_message_length
raise Discourse::InvalidParameters.new(
"Body cannot be over #{SiteSetting.chat_maximum_message_length} characters",
)
end
# The webhook POST body can be in 3 different formats:
#
# * { text: "message text" }, which is the most basic method, and also mirrors Slack payloads
# * { attachments: [ text: "message text" ] }, which is a variant of Slack payloads using legacy attachments
# * { payload: "<JSON STRING>", attachments: null, text: null }, where JSON STRING can look
# like the `attachments` example above (along with other attributes), which is fired by OpsGenie
def validate_payload
params.require(:key)
if !params[:text] && !params[:payload] && !params[:attachments]
raise Discourse::InvalidParameters
end
end
def debug_payload
return if !SiteSetting.chat_debug_webhook_payloads
Rails.logger.warn(
"Debugging chat webhook payload for endpoint #{params[:key]}: " +
JSON.dump(
{ payload: params[:payload], attachments: params[:attachments], text: params[:text] },
),
)
end
end

View File

@ -1,15 +0,0 @@
# frozen_string_literal: true
DiscoursePluginRegistry.define_register(:chat_markdown_features, Set)
class Plugin::Instance
def chat
ChatPluginApiExtensions
end
module ChatPluginApiExtensions
def self.enable_markdown_feature(name)
DiscoursePluginRegistry.chat_markdown_features << name
end
end
end

View File

@ -12,7 +12,7 @@ module Chat
instance_exec(&object.method(:default_actions_for_service).call) if default_actions
instance_exec(&(block || proc {}))
end
Chat::ServiceRunner.call(service, object, **dependencies, &merged_block)
ServiceRunner.call(service, object, **dependencies, &merged_block)
end
def run_service(service, dependencies)

View File

@ -1,81 +0,0 @@
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
# frozen_string_literal: true
module Jobs
class AutoJoinChannelBatch < ::Jobs::Base
def execute(args)
return "starts_at or ends_at missing" if args[:starts_at].blank? || args[:ends_at].blank?
start_user_id = args[:starts_at].to_i
end_user_id = args[:ends_at].to_i
return "End is higher than start" if end_user_id < start_user_id
channel =
ChatChannel.find_by(
id: args[:chat_channel_id],
auto_join_users: true,
chatable_type: "Category",
)
return if !channel
category = channel.chatable
return if !category
query_args = {
chat_channel_id: channel.id,
start: start_user_id,
end: end_user_id,
suspended_until: Time.zone.now,
last_seen_at: 3.months.ago,
channel_category: channel.chatable_id,
mode: UserChatChannelMembership.join_modes[:automatic],
}
new_member_ids = DB.query_single(create_memberships_query(category), query_args)
# Only do this if we are running auto-join for a single user, if we
# are doing it for many then we should do it after all batches are
# complete for the channel in Jobs::AutoManageChannelMemberships
if start_user_id == end_user_id
Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count
end
ChatPublisher.publish_new_channel(channel.reload, User.where(id: new_member_ids))
end
private
def create_memberships_query(category)
query = <<~SQL
INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode)
SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode
FROM users
INNER JOIN user_options uo ON uo.user_id = users.id
LEFT OUTER JOIN user_chat_channel_memberships uccm ON
uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id
SQL
query += <<~SQL if category.read_restricted?
INNER JOIN group_users gu ON gu.user_id = users.id
LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id
SQL
query += <<~SQL
WHERE (users.id >= :start AND users.id <= :end) AND
users.staged IS FALSE AND users.active AND
NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND
(suspended_till IS NULL OR suspended_till <= :suspended_until) AND
(last_seen_at > :last_seen_at) AND
uo.chat_enabled AND
uccm.id IS NULL
SQL
query += <<~SQL if category.read_restricted?
AND cg.category_id = :channel_category
SQL
query += "RETURNING user_chat_channel_memberships.user_id"
end
end
end

View File

@ -1,79 +0,0 @@
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
# frozen_string_literal: true
module Jobs
class AutoManageChannelMemberships < ::Jobs::Base
def execute(args)
channel =
ChatChannel.includes(:chatable).find_by(
id: args[:chat_channel_id],
auto_join_users: true,
chatable_type: "Category",
)
return if !channel&.chatable
processed =
UserChatChannelMembership.where(
chat_channel: channel,
following: true,
join_mode: UserChatChannelMembership.join_modes[:automatic],
).count
auto_join_query(channel).find_in_batches do |batch|
break if processed >= SiteSetting.max_chat_auto_joined_users
starts_at = batch.first.query_user_id
ends_at = batch.last.query_user_id
Jobs.enqueue(
:auto_join_channel_batch,
chat_channel_id: channel.id,
starts_at: starts_at,
ends_at: ends_at,
)
processed += batch.size
end
# The Jobs::AutoJoinChannelBatch job will only do this recalculation
# if it's operating on one user, so we need to make sure we do it for
# the channel here once this job is complete.
Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count
end
private
def auto_join_query(channel)
category = channel.chatable
users =
User
.real
.activated
.not_suspended
.not_staged
.distinct
.select(:id, "users.id AS query_user_id")
.where("last_seen_at > ?", 3.months.ago)
.joins(:user_option)
.where(user_options: { chat_enabled: true })
.joins(<<~SQL)
LEFT OUTER JOIN user_chat_channel_memberships uccm
ON uccm.chat_channel_id = #{channel.id} AND
uccm.user_id = users.id
SQL
.where("uccm.id IS NULL")
if category.read_restricted?
users =
users
.joins(:group_users)
.joins("INNER JOIN category_groups cg ON cg.group_id = group_users.group_id")
.where("cg.category_id = ?", channel.chatable_id)
end
users
end
end
end

View File

@ -0,0 +1,83 @@
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
# frozen_string_literal: true
module Jobs
module Chat
class AutoJoinChannelBatch < ::Jobs::Base
def execute(args)
return "starts_at or ends_at missing" if args[:starts_at].blank? || args[:ends_at].blank?
start_user_id = args[:starts_at].to_i
end_user_id = args[:ends_at].to_i
return "End is higher than start" if end_user_id < start_user_id
channel =
::Chat::Channel.find_by(
id: args[:chat_channel_id],
auto_join_users: true,
chatable_type: "Category",
)
return if !channel
category = channel.chatable
return if !category
query_args = {
chat_channel_id: channel.id,
start: start_user_id,
end: end_user_id,
suspended_until: Time.zone.now,
last_seen_at: 3.months.ago,
channel_category: channel.chatable_id,
mode: ::Chat::UserChatChannelMembership.join_modes[:automatic],
}
new_member_ids = DB.query_single(create_memberships_query(category), query_args)
# Only do this if we are running auto-join for a single user, if we
# are doing it for many then we should do it after all batches are
# complete for the channel in Jobs::Chat::AutoManageChannelMemberships
if start_user_id == end_user_id
::Chat::ChannelMembershipManager.new(channel).recalculate_user_count
end
::Chat::Publisher.publish_new_channel(channel.reload, User.where(id: new_member_ids))
end
private
def create_memberships_query(category)
query = <<~SQL
INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode)
SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode
FROM users
INNER JOIN user_options uo ON uo.user_id = users.id
LEFT OUTER JOIN user_chat_channel_memberships uccm ON
uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id
SQL
query += <<~SQL if category.read_restricted?
INNER JOIN group_users gu ON gu.user_id = users.id
LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id
SQL
query += <<~SQL
WHERE (users.id >= :start AND users.id <= :end) AND
users.staged IS FALSE AND users.active AND
NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND
(suspended_till IS NULL OR suspended_till <= :suspended_until) AND
(last_seen_at > :last_seen_at) AND
uo.chat_enabled AND
uccm.id IS NULL
SQL
query += <<~SQL if category.read_restricted?
AND cg.category_id = :channel_category
SQL
query += "RETURNING user_chat_channel_memberships.user_id"
end
end
end
end

View File

@ -0,0 +1,81 @@
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
# frozen_string_literal: true
module Jobs
module Chat
class AutoManageChannelMemberships < ::Jobs::Base
def execute(args)
channel =
::Chat::Channel.includes(:chatable).find_by(
id: args[:chat_channel_id],
auto_join_users: true,
chatable_type: "Category",
)
return if !channel&.chatable
processed =
::Chat::UserChatChannelMembership.where(
chat_channel: channel,
following: true,
join_mode: ::Chat::UserChatChannelMembership.join_modes[:automatic],
).count
auto_join_query(channel).find_in_batches do |batch|
break if processed >= ::SiteSetting.max_chat_auto_joined_users
starts_at = batch.first.query_user_id
ends_at = batch.last.query_user_id
::Jobs.enqueue(
::Jobs::Chat::AutoJoinChannelBatch,
chat_channel_id: channel.id,
starts_at: starts_at,
ends_at: ends_at,
)
processed += batch.size
end
# The Jobs::Chat::AutoJoinChannelBatch job will only do this recalculation
# if it's operating on one user, so we need to make sure we do it for
# the channel here once this job is complete.
::Chat::ChannelMembershipManager.new(channel).recalculate_user_count
end
private
def auto_join_query(channel)
category = channel.chatable
users =
::User
.real
.activated
.not_suspended
.not_staged
.distinct
.select(:id, "users.id AS query_user_id")
.where("last_seen_at > ?", 3.months.ago)
.joins(:user_option)
.where(user_options: { chat_enabled: true })
.joins(<<~SQL)
LEFT OUTER JOIN user_chat_channel_memberships uccm
ON uccm.chat_channel_id = #{channel.id} AND
uccm.user_id = users.id
SQL
.where("uccm.id IS NULL")
if category.read_restricted?
users =
users
.joins(:group_users)
.joins("INNER JOIN category_groups cg ON cg.group_id = group_users.group_id")
.where("cg.category_id = ?", channel.chatable_id)
end
users
end
end
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
module Jobs
module Chat
class ChannelArchive < ::Jobs::Base
sidekiq_options retry: false
def execute(args = {})
channel_archive = ::Chat::ChannelArchive.find_by(id: args[:chat_channel_archive_id])
# this should not really happen, but better to do this than throw an error
if channel_archive.blank?
::Rails.logger.warn(
"Chat channel archive #{args[:chat_channel_archive_id]} could not be found, aborting archive job.",
)
return
end
if channel_archive.complete?
channel_archive.chat_channel.update!(status: :archived)
::Chat::Publisher.publish_archive_status(
channel_archive.chat_channel,
archive_status: :success,
archived_messages: channel_archive.archived_messages,
archive_topic_id: channel_archive.destination_topic_id,
total_messages: channel_archive.total_messages,
)
return
end
::DistributedMutex.synchronize(
"archive_chat_channel_#{channel_archive.chat_channel_id}",
validity: 20.minutes,
) { ::Chat::ChannelArchiveService.new(channel_archive).execute }
end
end
end
end

View File

@ -0,0 +1,63 @@
# frozen_string_literal: true
module Jobs
module Chat
class ChannelDelete < ::Jobs::Base
def execute(args = {})
chat_channel = ::Chat::Channel.with_deleted.find_by(id: args[:chat_channel_id])
# this should not really happen, but better to do this than throw an error
if chat_channel.blank?
::Rails.logger.warn(
"Chat channel #{args[:chat_channel_id]} could not be found, aborting delete job.",
)
return
end
::DistributedMutex.synchronize("delete_chat_channel_#{chat_channel.id}") do
::Rails.logger.debug("Deleting webhooks and events for channel #{chat_channel.id}")
::Chat::Message.transaction do
webhooks = ::Chat::IncomingWebhook.where(chat_channel: chat_channel)
::Chat::WebhookEvent.where(incoming_chat_webhook_id: webhooks.select(:id)).delete_all
webhooks.delete_all
end
::Rails.logger.debug("Deleting drafts and memberships for channel #{chat_channel.id}")
::Chat::Draft.where(chat_channel: chat_channel).delete_all
::Chat::UserChatChannelMembership.where(chat_channel: chat_channel).delete_all
::Rails.logger.debug(
"Deleting chat messages, mentions, revisions, and uploads for channel #{chat_channel.id}",
)
chat_messages = ::Chat::Message.where(chat_channel: chat_channel)
delete_messages_and_related_records(chat_channel, chat_messages) if chat_messages.any?
end
end
def delete_messages_and_related_records(chat_channel, chat_messages)
message_ids = chat_messages.pluck(:id)
::Chat::Message.transaction do
::Chat::Mention.where(chat_message_id: message_ids).delete_all
::Chat::MessageRevision.where(chat_message_id: message_ids).delete_all
::Chat::MessageReaction.where(chat_message_id: message_ids).delete_all
# if the uploads are not used anywhere else they will be deleted
# by the CleanUpUploads job in core
::DB.exec("DELETE FROM chat_uploads WHERE chat_message_id IN (#{message_ids.join(",")})")
::UploadReference.where(
target_id: message_ids,
target_type: ::Chat::Message.sti_name,
).delete_all
# only the messages and the channel are Trashable, everything else gets
# permanently destroyed
chat_messages.update_all(
deleted_by_id: chat_channel.deleted_by_id,
deleted_at: Time.zone.now,
)
end
end
end
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
module Jobs
module Chat
class DeleteUserMessages < ::Jobs::Base
def execute(args)
return if args[:user_id].nil?
::Chat::MessageDestroyer.new.destroy_in_batches(
::Chat::Message.with_deleted.where(user_id: args[:user_id]),
)
end
end
end
end

View File

@ -0,0 +1,148 @@
# frozen_string_literal: true
module Jobs
module Chat
class NotifyMentioned < ::Jobs::Base
def execute(args = {})
@chat_message =
::Chat::Message.includes(:user, :revisions, chat_channel: :chatable).find_by(
id: args[:chat_message_id],
)
if @chat_message.nil? ||
@chat_message.revisions.where("created_at > ?", args[:timestamp]).any?
return
end
@creator = @chat_message.user
@chat_channel = @chat_message.chat_channel
@already_notified_user_ids = args[:already_notified_user_ids] || []
user_ids_to_notify = args[:to_notify_ids_map] || {}
user_ids_to_notify.each { |mention_type, ids| process_mentions(ids, mention_type.to_sym) }
end
private
def get_memberships(user_ids)
query =
::Chat::UserChatChannelMembership.includes(:user).where(
user_id: (user_ids - @already_notified_user_ids),
chat_channel_id: @chat_message.chat_channel_id,
)
query = query.where(following: true) if @chat_channel.public_channel?
query
end
def build_data_for(membership, identifier_type:)
data = {
chat_message_id: @chat_message.id,
chat_channel_id: @chat_channel.id,
mentioned_by_username: @creator.username,
is_direct_message_channel: @chat_channel.direct_message_channel?,
}
if !@is_direct_message_channel
data[:chat_channel_title] = @chat_channel.title(membership.user)
data[:chat_channel_slug] = @chat_channel.slug
end
return data if identifier_type == :direct_mentions
case identifier_type
when :here_mentions
data[:identifier] = "here"
when :global_mentions
data[:identifier] = "all"
else
data[:identifier] = identifier_type if identifier_type
data[:is_group_mention] = true
end
data
end
def build_payload_for(membership, identifier_type:)
payload = {
notification_type: ::Notification.types[:chat_mention],
username: @creator.username,
tag: ::Chat::Notifier.push_notification_tag(:mention, @chat_channel.id),
excerpt: @chat_message.push_notification_excerpt,
post_url: "#{@chat_channel.relative_url}/#{@chat_message.id}",
}
translation_prefix =
(
if @chat_channel.direct_message_channel?
"discourse_push_notifications.popup.direct_message_chat_mention"
else
"discourse_push_notifications.popup.chat_mention"
end
)
translation_suffix = identifier_type == :direct_mentions ? "direct" : "other_type"
identifier_text =
case identifier_type
when :here_mentions
"@here"
when :global_mentions
"@all"
when :direct_mentions
""
else
"@#{identifier_type}"
end
payload[:translated_title] = ::I18n.t(
"#{translation_prefix}.#{translation_suffix}",
username: @creator.username,
identifier: identifier_text,
channel: @chat_channel.title(membership.user),
)
payload
end
def create_notification!(membership, mention, mention_type)
notification_data = build_data_for(membership, identifier_type: mention_type)
is_read = ::Chat::Notifier.user_has_seen_message?(membership, @chat_message.id)
notification =
::Notification.create!(
notification_type: ::Notification.types[:chat_mention],
user_id: membership.user_id,
high_priority: true,
data: notification_data.to_json,
read: is_read,
)
mention.update!(notification: notification)
end
def send_notifications(membership, mention_type)
payload = build_payload_for(membership, identifier_type: mention_type)
if !membership.desktop_notifications_never? && !membership.muted?
::MessageBus.publish(
"/chat/notification-alert/#{membership.user_id}",
payload,
user_ids: [membership.user_id],
)
end
if !membership.mobile_notifications_never? && !membership.muted?
::PostAlerter.push_notification(membership.user, payload)
end
end
def process_mentions(user_ids, mention_type)
memberships = get_memberships(user_ids)
memberships.each do |membership|
mention = ::Chat::Mention.find_by(user: membership.user, chat_message: @chat_message)
if mention.present?
create_notification!(membership, mention, mention_type)
send_notifications(membership, mention_type)
end
end
end
end
end
end

View File

@ -0,0 +1,88 @@
# frozen_string_literal: true
module Jobs
module Chat
class NotifyWatching < ::Jobs::Base
def execute(args = {})
@chat_message =
::Chat::Message.includes(:user, chat_channel: :chatable).find_by(
id: args[:chat_message_id],
)
return if @chat_message.nil?
@creator = @chat_message.user
@chat_channel = @chat_message.chat_channel
@is_direct_message_channel = @chat_channel.direct_message_channel?
always_notification_level = ::Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always]
members =
::Chat::UserChatChannelMembership
.includes(user: :groups)
.joins(user: :user_option)
.where(user_option: { chat_enabled: true })
.where.not(user_id: args[:except_user_ids])
.where(chat_channel_id: @chat_channel.id)
.where(following: true)
.where(
"desktop_notification_level = ? OR mobile_notification_level = ?",
always_notification_level,
always_notification_level,
)
.merge(User.not_suspended)
if @is_direct_message_channel
::UserCommScreener
.new(acting_user: @creator, target_user_ids: members.map(&:user_id))
.allowing_actor_communication
.each do |user_id|
send_notifications(members.find { |member| member.user_id == user_id })
end
else
members.each { |member| send_notifications(member) }
end
end
def send_notifications(membership)
user = membership.user
guardian = ::Guardian.new(user)
return unless guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel)
return if ::Chat::Notifier.user_has_seen_message?(membership, @chat_message.id)
return if online_user_ids.include?(user.id)
translation_key =
(
if @is_direct_message_channel
"discourse_push_notifications.popup.new_direct_chat_message"
else
"discourse_push_notifications.popup.new_chat_message"
end
)
translation_args = { username: @creator.username }
translation_args[:channel] = @chat_channel.title(user) unless @is_direct_message_channel
payload = {
username: @creator.username,
notification_type: ::Notification.types[:chat_message],
post_url: @chat_channel.relative_url,
translated_title: ::I18n.t(translation_key, translation_args),
tag: ::Chat::Notifier.push_notification_tag(:message, @chat_channel.id),
excerpt: @chat_message.push_notification_excerpt,
}
if membership.desktop_notifications_always? && !membership.muted?
::MessageBus.publish("/chat/notification-alert/#{user.id}", payload, user_ids: [user.id])
end
if membership.mobile_notifications_always? && !membership.muted?
::PostAlerter.push_notification(user, payload)
end
end
def online_user_ids
@online_user_ids ||= ::PresenceChannel.new("/chat/online").user_ids
end
end
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
module Jobs
module Chat
class ProcessMessage < ::Jobs::Base
def execute(args = {})
::DistributedMutex.synchronize(
"jobs_chat_process_message_#{args[:chat_message_id]}",
validity: 10.minutes,
) do
chat_message = ::Chat::Message.find_by(id: args[:chat_message_id])
return if !chat_message
processor = ::Chat::MessageProcessor.new(chat_message)
processor.run!
if args[:is_dirty] || processor.dirty?
chat_message.update(
cooked: processor.html,
cooked_version: ::Chat::Message::BAKED_VERSION,
)
::Chat::Publisher.publish_processed!(chat_message)
end
end
end
end
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
module Jobs
module Chat
class SendMessageNotifications < ::Jobs::Base
def execute(args)
reason = args[:reason]
valid_reasons = %w[new edit]
return unless valid_reasons.include?(reason)
return if (timestamp = args[:timestamp]).blank?
return if (message = ::Chat::Message.find_by(id: args[:chat_message_id])).nil?
if reason == "new"
::Chat::Notifier.new(message, timestamp).notify_new
elsif reason == "edit"
::Chat::Notifier.new(message, timestamp).notify_edit
end
end
end
end
end

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
module Jobs
module Chat
class UpdateChannelUserCount < Jobs::Base
def execute(args = {})
channel = ::Chat::Channel.find_by(id: args[:chat_channel_id])
return if channel.blank?
return if !channel.user_count_stale
channel.update!(
user_count: ::Chat::ChannelMembershipsQuery.count(channel),
user_count_stale: false,
)
::Chat::Publisher.publish_chat_channel_metadata(channel)
end
end
end
end

View File

@ -1,38 +0,0 @@
# frozen_string_literal: true
module Jobs
class ChatChannelArchive < ::Jobs::Base
sidekiq_options retry: false
def execute(args = {})
channel_archive = ::ChatChannelArchive.find_by(id: args[:chat_channel_archive_id])
# this should not really happen, but better to do this than throw an error
if channel_archive.blank?
Rails.logger.warn(
"Chat channel archive #{args[:chat_channel_archive_id]} could not be found, aborting archive job.",
)
return
end
if channel_archive.complete?
channel_archive.chat_channel.update!(status: :archived)
ChatPublisher.publish_archive_status(
channel_archive.chat_channel,
archive_status: :success,
archived_messages: channel_archive.archived_messages,
archive_topic_id: channel_archive.destination_topic_id,
total_messages: channel_archive.total_messages,
)
return
end
DistributedMutex.synchronize(
"archive_chat_channel_#{channel_archive.chat_channel_id}",
validity: 20.minutes,
) { Chat::ChatChannelArchiveService.new(channel_archive).execute }
end
end
end

View File

@ -1,58 +0,0 @@
# frozen_string_literal: true
module Jobs
class ChatChannelDelete < ::Jobs::Base
def execute(args = {})
chat_channel = ::ChatChannel.with_deleted.find_by(id: args[:chat_channel_id])
# this should not really happen, but better to do this than throw an error
if chat_channel.blank?
Rails.logger.warn(
"Chat channel #{args[:chat_channel_id]} could not be found, aborting delete job.",
)
return
end
DistributedMutex.synchronize("delete_chat_channel_#{chat_channel.id}") do
Rails.logger.debug("Deleting webhooks and events for channel #{chat_channel.id}")
ChatMessage.transaction do
webhooks = IncomingChatWebhook.where(chat_channel: chat_channel)
ChatWebhookEvent.where(incoming_chat_webhook_id: webhooks.select(:id)).delete_all
webhooks.delete_all
end
Rails.logger.debug("Deleting drafts and memberships for channel #{chat_channel.id}")
ChatDraft.where(chat_channel: chat_channel).delete_all
UserChatChannelMembership.where(chat_channel: chat_channel).delete_all
Rails.logger.debug(
"Deleting chat messages, mentions, revisions, and uploads for channel #{chat_channel.id}",
)
chat_messages = ChatMessage.where(chat_channel: chat_channel)
delete_messages_and_related_records(chat_channel, chat_messages) if chat_messages.any?
end
end
def delete_messages_and_related_records(chat_channel, chat_messages)
message_ids = chat_messages.pluck(:id)
ChatMessage.transaction do
ChatMention.where(chat_message_id: message_ids).delete_all
ChatMessageRevision.where(chat_message_id: message_ids).delete_all
ChatMessageReaction.where(chat_message_id: message_ids).delete_all
# if the uploads are not used anywhere else they will be deleted
# by the CleanUpUploads job in core
DB.exec("DELETE FROM chat_uploads WHERE chat_message_id IN (#{message_ids.join(",")})")
UploadReference.where(target_id: message_ids, target_type: "ChatMessage").delete_all
# only the messages and the channel are Trashable, everything else gets
# permanently destroyed
chat_messages.update_all(
deleted_by_id: chat_channel.deleted_by_id,
deleted_at: Time.zone.now,
)
end
end
end
end

View File

@ -1,146 +0,0 @@
# frozen_string_literal: true
module Jobs
class ChatNotifyMentioned < ::Jobs::Base
def execute(args = {})
@chat_message =
ChatMessage.includes(:user, :revisions, chat_channel: :chatable).find_by(
id: args[:chat_message_id],
)
if @chat_message.nil? ||
@chat_message.revisions.where("created_at > ?", args[:timestamp]).any?
return
end
@creator = @chat_message.user
@chat_channel = @chat_message.chat_channel
@already_notified_user_ids = args[:already_notified_user_ids] || []
user_ids_to_notify = args[:to_notify_ids_map] || {}
user_ids_to_notify.each { |mention_type, ids| process_mentions(ids, mention_type.to_sym) }
end
private
def get_memberships(user_ids)
query =
UserChatChannelMembership.includes(:user).where(
user_id: (user_ids - @already_notified_user_ids),
chat_channel_id: @chat_message.chat_channel_id,
)
query = query.where(following: true) if @chat_channel.public_channel?
query
end
def build_data_for(membership, identifier_type:)
data = {
chat_message_id: @chat_message.id,
chat_channel_id: @chat_channel.id,
mentioned_by_username: @creator.username,
is_direct_message_channel: @chat_channel.direct_message_channel?,
}
if !@is_direct_message_channel
data[:chat_channel_title] = @chat_channel.title(membership.user)
data[:chat_channel_slug] = @chat_channel.slug
end
return data if identifier_type == :direct_mentions
case identifier_type
when :here_mentions
data[:identifier] = "here"
when :global_mentions
data[:identifier] = "all"
else
data[:identifier] = identifier_type if identifier_type
data[:is_group_mention] = true
end
data
end
def build_payload_for(membership, identifier_type:)
payload = {
notification_type: Notification.types[:chat_mention],
username: @creator.username,
tag: Chat::ChatNotifier.push_notification_tag(:mention, @chat_channel.id),
excerpt: @chat_message.push_notification_excerpt,
post_url: "#{@chat_channel.relative_url}/#{@chat_message.id}",
}
translation_prefix =
(
if @chat_channel.direct_message_channel?
"discourse_push_notifications.popup.direct_message_chat_mention"
else
"discourse_push_notifications.popup.chat_mention"
end
)
translation_suffix = identifier_type == :direct_mentions ? "direct" : "other_type"
identifier_text =
case identifier_type
when :here_mentions
"@here"
when :global_mentions
"@all"
when :direct_mentions
""
else
"@#{identifier_type}"
end
payload[:translated_title] = I18n.t(
"#{translation_prefix}.#{translation_suffix}",
username: @creator.username,
identifier: identifier_text,
channel: @chat_channel.title(membership.user),
)
payload
end
def create_notification!(membership, mention, mention_type)
notification_data = build_data_for(membership, identifier_type: mention_type)
is_read = Chat::ChatNotifier.user_has_seen_message?(membership, @chat_message.id)
notification =
Notification.create!(
notification_type: Notification.types[:chat_mention],
user_id: membership.user_id,
high_priority: true,
data: notification_data.to_json,
read: is_read,
)
mention.update!(notification: notification)
end
def send_notifications(membership, mention_type)
payload = build_payload_for(membership, identifier_type: mention_type)
if !membership.desktop_notifications_never? && !membership.muted?
MessageBus.publish(
"/chat/notification-alert/#{membership.user_id}",
payload,
user_ids: [membership.user_id],
)
end
if !membership.mobile_notifications_never? && !membership.muted?
PostAlerter.push_notification(membership.user, payload)
end
end
def process_mentions(user_ids, mention_type)
memberships = get_memberships(user_ids)
memberships.each do |membership|
mention = ChatMention.find_by(user: membership.user, chat_message: @chat_message)
if mention.present?
create_notification!(membership, mention, mention_type)
send_notifications(membership, mention_type)
end
end
end
end
end

View File

@ -1,84 +0,0 @@
# frozen_string_literal: true
module Jobs
class ChatNotifyWatching < ::Jobs::Base
def execute(args = {})
@chat_message =
ChatMessage.includes(:user, chat_channel: :chatable).find_by(id: args[:chat_message_id])
return if @chat_message.nil?
@creator = @chat_message.user
@chat_channel = @chat_message.chat_channel
@is_direct_message_channel = @chat_channel.direct_message_channel?
always_notification_level = UserChatChannelMembership::NOTIFICATION_LEVELS[:always]
members =
UserChatChannelMembership
.includes(user: :groups)
.joins(user: :user_option)
.where(user_option: { chat_enabled: true })
.where.not(user_id: args[:except_user_ids])
.where(chat_channel_id: @chat_channel.id)
.where(following: true)
.where(
"desktop_notification_level = ? OR mobile_notification_level = ?",
always_notification_level,
always_notification_level,
)
.merge(User.not_suspended)
if @is_direct_message_channel
UserCommScreener
.new(acting_user: @creator, target_user_ids: members.map(&:user_id))
.allowing_actor_communication
.each do |user_id|
send_notifications(members.find { |member| member.user_id == user_id })
end
else
members.each { |member| send_notifications(member) }
end
end
def send_notifications(membership)
user = membership.user
guardian = Guardian.new(user)
return unless guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel)
return if Chat::ChatNotifier.user_has_seen_message?(membership, @chat_message.id)
return if online_user_ids.include?(user.id)
translation_key =
(
if @is_direct_message_channel
"discourse_push_notifications.popup.new_direct_chat_message"
else
"discourse_push_notifications.popup.new_chat_message"
end
)
translation_args = { username: @creator.username }
translation_args[:channel] = @chat_channel.title(user) unless @is_direct_message_channel
payload = {
username: @creator.username,
notification_type: Notification.types[:chat_message],
post_url: @chat_channel.relative_url,
translated_title: I18n.t(translation_key, translation_args),
tag: Chat::ChatNotifier.push_notification_tag(:message, @chat_channel.id),
excerpt: @chat_message.push_notification_excerpt,
}
if membership.desktop_notifications_always? && !membership.muted?
MessageBus.publish("/chat/notification-alert/#{user.id}", payload, user_ids: [user.id])
end
if membership.mobile_notifications_always? && !membership.muted?
PostAlerter.push_notification(user, payload)
end
end
def online_user_ids
@online_user_ids ||= PresenceChannel.new("/chat/online").user_ids
end
end
end

View File

@ -1,13 +0,0 @@
# frozen_string_literal: true
module Jobs
class DeleteUserMessages < ::Jobs::Base
def execute(args)
return if args[:user_id].nil?
ChatMessageDestroyer.new.destroy_in_batches(
ChatMessage.with_deleted.where(user_id: args[:user_id]),
)
end
end
end

View File

@ -1,22 +0,0 @@
# frozen_string_literal: true
module Jobs
class ProcessChatMessage < ::Jobs::Base
def execute(args = {})
DistributedMutex.synchronize(
"process_chat_message_#{args[:chat_message_id]}",
validity: 10.minutes,
) do
chat_message = ChatMessage.find_by(id: args[:chat_message_id])
return if !chat_message
processor = Chat::ChatMessageProcessor.new(chat_message)
processor.run!
if args[:is_dirty] || processor.dirty?
chat_message.update(cooked: processor.html, cooked_version: ChatMessage::BAKED_VERSION)
ChatPublisher.publish_processed!(chat_message)
end
end
end
end
end

View File

@ -1,21 +0,0 @@
# frozen_string_literal: true
module Jobs
class SendMessageNotifications < ::Jobs::Base
def execute(args)
reason = args[:reason]
valid_reasons = %w[new edit]
return unless valid_reasons.include?(reason)
return if (timestamp = args[:timestamp]).blank?
return if (message = ChatMessage.find_by(id: args[:chat_message_id])).nil?
if reason == "new"
Chat::ChatNotifier.new(message, timestamp).notify_new
elsif reason == "edit"
Chat::ChatNotifier.new(message, timestamp).notify_edit
end
end
end
end

View File

@ -1,18 +0,0 @@
# frozen_string_literal: true
module Jobs
class UpdateChannelUserCount < Jobs::Base
def execute(args = {})
channel = ChatChannel.find_by(id: args[:chat_channel_id])
return if channel.blank?
return if !channel.user_count_stale
channel.update!(
user_count: ChatChannelMembershipsQuery.count(channel),
user_count_stale: false,
)
ChatPublisher.publish_chat_channel_metadata(channel)
end
end
end

View File

@ -1,15 +0,0 @@
# frozen_string_literal: true
module Jobs
class AutoJoinUsers < ::Jobs::Scheduled
every 1.hour
def execute(_args)
ChatChannel
.where(auto_join_users: true)
.each do |channel|
Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
end
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Jobs
module Chat
class AutoJoinUsers < ::Jobs::Scheduled
every 1.hour
def execute(_args)
::Chat::Channel
.where(auto_join_users: true)
.each do |channel|
::Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
end
end
end
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
module Jobs
module Chat
class DeleteOldMessages < ::Jobs::Scheduled
daily at: 0.hours
def execute(args = {})
delete_public_channel_messages
delete_dm_channel_messages
end
private
def delete_public_channel_messages
return unless valid_day_value?(:chat_channel_retention_days)
::Chat::MessageDestroyer.new.destroy_in_batches(
::Chat::Message.in_public_channel.with_deleted.created_before(
::SiteSetting.chat_channel_retention_days.days.ago,
),
)
end
def delete_dm_channel_messages
return unless valid_day_value?(:chat_dm_retention_days)
::Chat::MessageDestroyer.new.destroy_in_batches(
::Chat::Message.in_dm_channel.with_deleted.created_before(
::SiteSetting.chat_dm_retention_days.days.ago,
),
)
end
def valid_day_value?(setting_name)
(::SiteSetting.public_send(setting_name) || 0).positive?
end
end
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
module Jobs
module Chat
class EmailNotifications < ::Jobs::Scheduled
every 5.minutes
def execute(args = {})
return unless ::SiteSetting.chat_enabled
::Chat::Mailer.send_unread_mentions_summary
end
end
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
module Jobs
module Chat
class PeriodicalUpdates < ::Jobs::Scheduled
every 15.minutes
def execute(args = nil)
# TODO: Add rebaking of old messages (baked_version <
# Chat::Message::BAKED_VERSION or baked_version IS NULL)
::Chat::Channel.ensure_consistency!
nil
end
end
end
end

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
module Jobs
# TODO (martin) Move into Chat::Channel.ensure_consistency! so it
# is run with Jobs::Chat::PeriodicalUpdates
module Chat
class UpdateUserCountsForChannels < ::Jobs::Scheduled
every 1.hour
# FIXME: This could become huge as the amount of channels grows, we
# need a different approach here. Perhaps we should only bother for
# channels updated or with new messages in the past N days? Perhaps
# we could update all the counts in a single query as well?
def execute(args = {})
::Chat::Channel
.where(status: %i[open closed])
.find_each { |chat_channel| set_user_count(chat_channel) }
end
def set_user_count(chat_channel)
current_count = chat_channel.user_count || 0
new_count = ::Chat::ChannelMembershipsQuery.count(chat_channel)
return if current_count == new_count
chat_channel.update(user_count: new_count, user_count_stale: false)
::Chat::Publisher.publish_chat_channel_metadata(chat_channel)
end
end
end
end

View File

@ -1,14 +0,0 @@
# frozen_string_literal: true
module Jobs
class ChatPeriodicalUpdates < ::Jobs::Scheduled
every 15.minutes
def execute(args = nil)
# TODO: Add rebaking of old messages (baked_version <
# ChatMessage::BAKED_VERSION or baked_version IS NULL)
ChatChannel.ensure_consistency!
nil
end
end
end

View File

@ -1,38 +0,0 @@
# frozen_string_literal: true
module Jobs
class DeleteOldChatMessages < ::Jobs::Scheduled
daily at: 0.hours
def execute(args = {})
delete_public_channel_messages
delete_dm_channel_messages
end
private
def delete_public_channel_messages
return unless valid_day_value?(:chat_channel_retention_days)
ChatMessageDestroyer.new.destroy_in_batches(
ChatMessage.in_public_channel.with_deleted.created_before(
SiteSetting.chat_channel_retention_days.days.ago,
),
)
end
def delete_dm_channel_messages
return unless valid_day_value?(:chat_dm_retention_days)
ChatMessageDestroyer.new.destroy_in_batches(
ChatMessage.in_dm_channel.with_deleted.created_before(
SiteSetting.chat_dm_retention_days.days.ago,
),
)
end
def valid_day_value?(setting_name)
(SiteSetting.public_send(setting_name) || 0).positive?
end
end
end

View File

@ -1,13 +0,0 @@
# frozen_string_literal: true
module Jobs
class EmailChatNotifications < ::Jobs::Scheduled
every 5.minutes
def execute(args = {})
return unless SiteSetting.chat_enabled
Chat::ChatMailer.send_unread_mentions_summary
end
end
end

View File

@ -1,28 +0,0 @@
# frozen_string_literal: true
module Jobs
# TODO (martin) Move into ChatChannel.ensure_consistency! so it
# is run with ChatPeriodicalUpdates
class UpdateUserCountsForChatChannels < ::Jobs::Scheduled
every 1.hour
# FIXME: This could become huge as the amount of channels grows, we
# need a different approach here. Perhaps we should only bother for
# channels updated or with new messages in the past N days? Perhaps
# we could update all the counts in a single query as well?
def execute(args = {})
ChatChannel
.where(status: %i[open closed])
.find_each { |chat_channel| set_user_count(chat_channel) }
end
def set_user_count(chat_channel)
current_count = chat_channel.user_count || 0
new_count = ChatChannelMembershipsQuery.count(chat_channel)
return if current_count == new_count
chat_channel.update(user_count: new_count, user_count_stale: false)
ChatPublisher.publish_chat_channel_metadata(chat_channel)
end
end
end

View File

@ -1,45 +0,0 @@
# frozen_string_literal: true
class CategoryChannel < ChatChannel
alias_attribute :category, :chatable
delegate :read_restricted?, to: :category
delegate :url, to: :chatable, prefix: true
%i[category_channel? public_channel? chatable_has_custom_fields?].each do |name|
define_method(name) { true }
end
def allowed_group_ids
return if !read_restricted?
staff_groups = Group::AUTO_GROUPS.slice(:staff, :moderators, :admins).values
category.secure_group_ids.to_a.concat(staff_groups)
end
def title(_ = nil)
name.presence || category.name
end
def generate_auto_slug
return if self.slug.present?
self.slug = Slug.for(self.title.strip, "")
self.slug = "" if duplicate_slug?
end
def ensure_slug_ok
if self.slug.present?
# if we don't unescape it first we strip the % from the encoded version
slug = SiteSetting.slug_generation_method == "encoded" ? CGI.unescape(self.slug) : self.slug
self.slug = Slug.for(slug, "", method: :encoded)
if self.slug.blank?
errors.add(:slug, :invalid)
elsif SiteSetting.slug_generation_method == "ascii" && !CGI.unescape(self.slug).ascii_only?
errors.add(:slug, I18n.t("chat.category_channel.errors.slug_contains_non_ascii_chars"))
elsif duplicate_slug?
errors.add(:slug, I18n.t("chat.category_channel.errors.is_already_in_use"))
end
end
end
end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
module Chat
class CategoryChannel < Channel
alias_attribute :category, :chatable
delegate :read_restricted?, to: :category
delegate :url, to: :chatable, prefix: true
def self.polymorphic_class_for(name)
Chat::Chatable.polymorphic_class_for(name) || super(name)
end
%i[category_channel? public_channel? chatable_has_custom_fields?].each do |name|
define_method(name) { true }
end
def allowed_group_ids
return if !read_restricted?
staff_groups = Group::AUTO_GROUPS.slice(:staff, :moderators, :admins).values
category.secure_group_ids.to_a.concat(staff_groups)
end
def title(_ = nil)
name.presence || category.name
end
def generate_auto_slug
return if self.slug.present?
self.slug = Slug.for(self.title.strip, "")
self.slug = "" if duplicate_slug?
end
def ensure_slug_ok
if self.slug.present?
# if we don't unescape it first we strip the % from the encoded version
slug = SiteSetting.slug_generation_method == "encoded" ? CGI.unescape(self.slug) : self.slug
self.slug = Slug.for(slug, "", method: :encoded)
if self.slug.blank?
errors.add(:slug, :invalid)
elsif SiteSetting.slug_generation_method == "ascii" && !CGI.unescape(self.slug).ascii_only?
errors.add(:slug, I18n.t("chat.category_channel.errors.slug_contains_non_ascii_chars"))
elsif duplicate_slug?
errors.add(:slug, I18n.t("chat.category_channel.errors.is_already_in_use"))
end
end
end
end
end

View File

@ -0,0 +1,196 @@
# frozen_string_literal: true
module Chat
class Channel < ActiveRecord::Base
include Trashable
self.table_name = "chat_channels"
belongs_to :chatable, polymorphic: true
def self.sti_class_for(type_name)
Chat::Chatable.sti_class_for(type_name) || super(type_name)
end
def self.sti_name
Chat::Chatable.sti_name_for(self) || super
end
belongs_to :direct_message,
class_name: "Chat::DirectMessage",
foreign_key: :chatable_id,
inverse_of: :direct_message_channel,
optional: true
has_many :chat_messages, class_name: "Chat::Message", foreign_key: :chat_channel_id
has_many :user_chat_channel_memberships,
class_name: "Chat::UserChatChannelMembership",
foreign_key: :chat_channel_id
has_one :chat_channel_archive, class_name: "Chat::ChannelArchive", foreign_key: :chat_channel_id
enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false
validates :name,
length: {
maximum: Proc.new { SiteSetting.max_topic_title_length },
},
presence: true,
allow_nil: true
validate :ensure_slug_ok, if: :slug_changed?
before_validation :generate_auto_slug
scope :public_channels,
-> {
where(chatable_type: public_channel_chatable_types).where(
"categories.id IS NOT NULL",
).joins(
"LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'",
)
}
delegate :empty?, to: :chat_messages, prefix: true
class << self
def editable_statuses
statuses.filter { |k, _| !%w[read_only archived].include?(k) }
end
def public_channel_chatable_types
%w[Category]
end
def direct_channel_chatable_types
%w[DirectMessage]
end
def chatable_types
public_channel_chatable_types + direct_channel_chatable_types
end
end
statuses.keys.each do |status|
define_method("#{status}!") { |acting_user| change_status(acting_user, status.to_sym) }
end
%i[
category_channel?
direct_message_channel?
public_channel?
chatable_has_custom_fields?
read_restricted?
].each { |name| define_method(name) { false } }
%i[allowed_user_ids allowed_group_ids chatable_url].each { |name| define_method(name) { nil } }
def membership_for(user)
user_chat_channel_memberships.find_by(user: user)
end
def add(user)
Chat::ChannelMembershipManager.new(self).follow(user)
end
def remove(user)
Chat::ChannelMembershipManager.new(self).unfollow(user)
end
def url
"#{Discourse.base_url}/chat/c/#{self.slug || "-"}/#{self.id}"
end
def relative_url
"#{Discourse.base_path}/chat/c/#{self.slug || "-"}/#{self.id}"
end
def self.ensure_consistency!
update_counts
end
# TODO (martin) Move Jobs::Chat::UpdateUserCountsForChannels into here
def self.update_counts
# NOTE: Chat::Channel#messages_count is not updated every time
# a message is created or deleted in a channel, so it should not
# be displayed in the UI. It is updated eventually via Jobs::Chat::PeriodicalUpdates
DB.exec <<~SQL
UPDATE chat_channels channels
SET messages_count = subquery.messages_count
FROM (
SELECT COUNT(*) AS messages_count, chat_channel_id
FROM chat_messages
WHERE chat_messages.deleted_at IS NULL
GROUP BY chat_channel_id
) subquery
WHERE channels.id = subquery.chat_channel_id
AND channels.deleted_at IS NULL
AND subquery.messages_count != channels.messages_count
SQL
end
private
def change_status(acting_user, target_status)
return if !Guardian.new(acting_user).can_change_channel_status?(self, target_status)
self.update!(status: target_status)
log_channel_status_change(acting_user: acting_user)
end
def log_channel_status_change(acting_user:)
DiscourseEvent.trigger(
:chat_channel_status_change,
channel: self,
old_status: status_previously_was,
new_status: status,
)
StaffActionLogger.new(acting_user).log_custom(
"chat_channel_status_change",
{
chat_channel_id: self.id,
chat_channel_name: self.name,
previous_value: status_previously_was,
new_value: status,
},
)
Chat::Publisher.publish_channel_status(self)
end
def duplicate_slug?
Chat::Channel.where(slug: self.slug).where.not(id: self.id).any?
end
end
end
# == Schema Information
#
# Table name: chat_channels
#
# id :bigint not null, primary key
# chatable_id :integer not null
# deleted_at :datetime
# deleted_by_id :integer
# featured_in_category_id :integer
# delete_after_seconds :integer
# chatable_type :string not null
# created_at :datetime not null
# updated_at :datetime not null
# name :string
# description :text
# status :integer default("open"), not null
# user_count :integer default(0), not null
# last_message_sent_at :datetime not null
# auto_join_users :boolean default(FALSE), not null
# allow_channel_wide_mentions :boolean default(TRUE), not null
# user_count_stale :boolean default(FALSE), not null
# slug :string
# type :string
# threading_enabled :boolean default(FALSE), not null
#
# Indexes
#
# index_chat_channels_on_messages_count (messages_count)
# index_chat_channels_on_chatable_id (chatable_id)
# index_chat_channels_on_chatable_id_and_chatable_type (chatable_id,chatable_type)
# index_chat_channels_on_slug (slug) UNIQUE
# index_chat_channels_on_status (status)
#

View File

@ -1,21 +1,24 @@
# frozen_string_literal: true
class ChatChannelArchive < ActiveRecord::Base
belongs_to :chat_channel
belongs_to :archived_by, class_name: "User"
module Chat
class ChannelArchive < ActiveRecord::Base
belongs_to :chat_channel, class_name: "Chat::Channel"
belongs_to :archived_by, class_name: "User"
belongs_to :destination_topic, class_name: "Topic"
belongs_to :destination_topic, class_name: "Topic"
self.table_name = "chat_channel_archives"
def complete?
self.archived_messages >= self.total_messages && self.chat_channel.chat_messages.count.zero?
end
def complete?
self.archived_messages >= self.total_messages && self.chat_channel.chat_messages.count.zero?
end
def failed?
!complete? && self.archive_error.present?
end
def failed?
!complete? && self.archive_error.present?
end
def new_topic?
self.destination_topic_title.present?
def new_topic?
self.destination_topic_title.present?
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Chat
class DeletedUser < User
def username
I18n.t("chat.deleted_chat_username")
end
def avatar_template
"/plugins/chat/images/deleted-chat-user-avatar.png"
end
def bot?
false
end
end
end

View File

@ -0,0 +1,72 @@
# frozen_string_literal: true
module Chat
class DirectMessage < ActiveRecord::Base
self.table_name = "direct_message_channels"
include Chatable
def self.polymorphic_name
Chat::Chatable.polymorphic_name_for(self) || super
end
has_many :direct_message_users,
class_name: "Chat::DirectMessageUser",
foreign_key: :direct_message_channel_id
has_many :users, through: :direct_message_users
has_one :direct_message_channel, as: :chatable, class_name: "Chat::DirectMessageChannel"
def self.for_user_ids(user_ids)
joins(:users)
.group("direct_message_channels.id")
.having("ARRAY[?] = ARRAY_AGG(users.id ORDER BY users.id)", user_ids.sort)
&.first
end
def user_can_access?(user)
users.include?(user)
end
def chat_channel_title_for_user(chat_channel, acting_user)
users =
(direct_message_users.map(&:user) - [acting_user]).map do |user|
user || Chat::DeletedUser.new
end
# direct message to self
if users.empty?
return I18n.t("chat.channel.dm_title.single_user", username: "@#{acting_user.username}")
end
# all users deleted
return chat_channel.id if !users.first
usernames_formatted = users.sort_by(&:username).map { |u| "@#{u.username}" }
if usernames_formatted.size > 5
return(
I18n.t(
"chat.channel.dm_title.multi_user_truncated",
comma_separated_usernames:
usernames_formatted[0..4].join(I18n.t("word_connector.comma")),
count: usernames_formatted.length - 5,
)
)
end
I18n.t(
"chat.channel.dm_title.multi_user",
comma_separated_usernames: usernames_formatted.join(I18n.t("word_connector.comma")),
)
end
end
end
# == Schema Information
#
# Table name: direct_message_channels
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
#

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
module Chat
class DirectMessageChannel < Channel
alias_attribute :direct_message, :chatable
def self.polymorphic_class_for(name)
Chat::Chatable.polymorphic_class_for(name) || super(name)
end
def direct_message_channel?
true
end
def allowed_user_ids
direct_message.user_ids
end
def read_restricted?
true
end
def title(user)
direct_message.chat_channel_title_for_user(self, user)
end
def ensure_slug_ok
true
end
def generate_auto_slug
self.slug = nil
end
end
end

View File

@ -1,8 +1,14 @@
# frozen_string_literal: true
class DirectMessageUser < ActiveRecord::Base
belongs_to :direct_message, foreign_key: :direct_message_channel_id
belongs_to :user
module Chat
class DirectMessageUser < ActiveRecord::Base
self.table_name = "direct_message_users"
belongs_to :direct_message,
class_name: "Chat::DirectMessage",
foreign_key: :direct_message_channel_id
belongs_to :user
end
end
# == Schema Information

View File

@ -1,13 +1,17 @@
# frozen_string_literal: true
class ChatDraft < ActiveRecord::Base
belongs_to :user
belongs_to :chat_channel
module Chat
class Draft < ActiveRecord::Base
belongs_to :user
belongs_to :chat_channel, class_name: "Chat::Channel"
validate :data_length
def data_length
if self.data && self.data.length > SiteSetting.max_chat_draft_length
self.errors.add(:base, I18n.t("chat.errors.draft_too_long"))
self.table_name = "chat_drafts"
validate :data_length
def data_length
if self.data && self.data.length > SiteSetting.max_chat_draft_length
self.errors.add(:base, I18n.t("chat.errors.draft_too_long"))
end
end
end
end

View File

@ -1,13 +1,17 @@
# frozen_string_literal: true
class IncomingChatWebhook < ActiveRecord::Base
belongs_to :chat_channel
has_many :chat_webhook_events
module Chat
class IncomingWebhook < ActiveRecord::Base
self.table_name = "incoming_chat_webhooks"
before_create { self.key = SecureRandom.hex(12) }
belongs_to :chat_channel, class_name: "Chat::Channel"
has_many :chat_webhook_events, class_name: "Chat::WebhookEvent"
def url
"#{Discourse.base_url}/chat/hooks/#{key}.json"
before_create { self.key = SecureRandom.hex(12) }
def url
"#{Discourse.base_url}/chat/hooks/#{key}.json"
end
end
end

View File

@ -1,9 +1,13 @@
# frozen_string_literal: true
class ChatMention < ActiveRecord::Base
belongs_to :user
belongs_to :chat_message
belongs_to :notification, dependent: :destroy
module Chat
class Mention < ActiveRecord::Base
self.table_name = "chat_mentions"
belongs_to :user
belongs_to :chat_message, class_name: "Chat::Message"
belongs_to :notification, dependent: :destroy
end
end
# == Schema Information

View File

@ -0,0 +1,360 @@
# frozen_string_literal: true
module Chat
class Message < ActiveRecord::Base
include Trashable
self.table_name = "chat_messages"
attribute :has_oneboxes, default: false
BAKED_VERSION = 2
belongs_to :chat_channel, class_name: "Chat::Channel"
belongs_to :user
belongs_to :in_reply_to, class_name: "Chat::Message"
belongs_to :last_editor, class_name: "User"
belongs_to :thread, class_name: "Chat::Thread"
has_many :replies,
class_name: "Chat::Message",
foreign_key: "in_reply_to_id",
dependent: :nullify
has_many :revisions,
class_name: "Chat::MessageRevision",
dependent: :destroy,
foreign_key: :chat_message_id
has_many :reactions,
class_name: "Chat::MessageReaction",
dependent: :destroy,
foreign_key: :chat_message_id
has_many :bookmarks,
-> {
unscope(where: :bookmarkable_type).where(bookmarkable_type: Chat::Message.sti_name)
},
as: :bookmarkable,
dependent: :destroy
has_many :upload_references,
-> { unscope(where: :target_type).where(target_type: Chat::Message.sti_name) },
dependent: :destroy,
foreign_key: :target_id
has_many :uploads, through: :upload_references, class_name: "::Upload"
CLASS_MAPPING = { "ChatMessage" => Chat::Message }
# the model used when loading type column
def self.sti_class_for(name)
CLASS_MAPPING[name] if CLASS_MAPPING.key?(name)
end
# the type column value
def self.sti_name
CLASS_MAPPING.invert.fetch(self)
end
# the model used when loading chatable_type column
def self.polymorphic_class_for(name)
CLASS_MAPPING[name] if CLASS_MAPPING.key?(name)
end
# the type stored in *_type column of polymorphic associations
def self.polymorphic_name
CLASS_MAPPING.invert.fetch(self) || super
end
# TODO (martin) Remove this when we drop the ChatUpload table
has_many :chat_uploads,
dependent: :destroy,
class_name: "Chat::Upload",
foreign_key: :chat_message_id
has_one :chat_webhook_event,
dependent: :destroy,
class_name: "Chat::WebhookEvent",
foreign_key: :chat_message_id
has_many :chat_mentions,
dependent: :destroy,
class_name: "Chat::Mention",
foreign_key: :chat_message_id
scope :in_public_channel,
-> {
joins(:chat_channel).where(
chat_channel: {
chatable_type: Chat::Channel.public_channel_chatable_types,
},
)
}
scope :in_dm_channel,
-> {
joins(:chat_channel).where(
chat_channel: {
chatable_type: Chat::Channel.direct_channel_chatable_types,
},
)
}
scope :created_before, ->(date) { where("chat_messages.created_at < ?", date) }
before_save { ensure_last_editor_id }
def validate_message(has_uploads:)
WatchedWordsValidator.new(attributes: [:message]).validate(self)
if self.new_record? || self.changed.include?("message")
Chat::DuplicateMessageValidator.new(self).validate
end
if !has_uploads && message_too_short?
self.errors.add(
:base,
I18n.t(
"chat.errors.minimum_length_not_met",
count: SiteSetting.chat_minimum_message_length,
),
)
end
if message_too_long?
self.errors.add(
:base,
I18n.t("chat.errors.message_too_long", count: SiteSetting.chat_maximum_message_length),
)
end
end
def attach_uploads(uploads)
return if uploads.blank? || self.new_record?
now = Time.now
ref_record_attrs =
uploads.map do |upload|
{
upload_id: upload.id,
target_id: self.id,
target_type: self.class.sti_name,
created_at: now,
updated_at: now,
}
end
UploadReference.insert_all!(ref_record_attrs)
end
def excerpt(max_length: 50)
# just show the URL if the whole message is a URL, because we cannot excerpt oneboxes
return message if UrlHelper.relaxed_parse(message).is_a?(URI)
# upload-only messages are better represented as the filename
return uploads.first.original_filename if cooked.blank? && uploads.present?
# this may return blank for some complex things like quotes, that is acceptable
PrettyText.excerpt(message, max_length, { text_entities: true })
end
def cooked_for_excerpt
(cooked.blank? && uploads.present?) ? "<p>#{uploads.first.original_filename}</p>" : cooked
end
def push_notification_excerpt
Emoji.gsub_emoji_to_unicode(message).truncate(400)
end
def to_markdown
upload_markdown =
self
.upload_references
.includes(:upload)
.order(:created_at)
.map(&:to_markdown)
.reject(&:empty?)
return self.message if upload_markdown.empty?
return ["#{self.message}\n"].concat(upload_markdown).join("\n") if self.message.present?
upload_markdown.join("\n")
end
def cook
ensure_last_editor_id
self.cooked = self.class.cook(self.message, user_id: self.last_editor_id)
self.cooked_version = BAKED_VERSION
end
def rebake!(invalidate_oneboxes: false, priority: nil)
ensure_last_editor_id
previous_cooked = self.cooked
new_cooked =
self.class.cook(
message,
invalidate_oneboxes: invalidate_oneboxes,
user_id: self.last_editor_id,
)
update_columns(cooked: new_cooked, cooked_version: BAKED_VERSION)
args = { chat_message_id: self.id }
args[:queue] = priority.to_s if priority && priority != :normal
args[:is_dirty] = true if previous_cooked != new_cooked
Jobs.enqueue(Jobs::Chat::ProcessMessage, args)
end
def self.uncooked
where("cooked_version <> ? or cooked_version IS NULL", BAKED_VERSION)
end
MARKDOWN_FEATURES = %w[
anchor
bbcode-block
bbcode-inline
code
category-hashtag
censored
chat-transcript
discourse-local-dates
emoji
emojiShortcuts
inlineEmoji
html-img
hashtag-autocomplete
mentions
unicodeUsernames
onebox
quotes
spoiler-alert
table
text-post-process
upload-protocol
watched-words
]
MARKDOWN_IT_RULES = %w[
autolink
list
backticks
newline
code
fence
image
table
linkify
link
strikethrough
blockquote
emphasis
]
def self.cook(message, opts = {})
# A rule in our Markdown pipeline may have Guardian checks that require a
# user to be present. The last editing user of the message will be more
# generally up to date than the creating user. For example, we use
# this when cooking #hashtags to determine whether we should render
# the found hashtag based on whether the user can access the channel it
# is referencing.
cooked =
PrettyText.cook(
message,
features_override:
MARKDOWN_FEATURES + DiscoursePluginRegistry.chat_markdown_features.to_a,
markdown_it_rules: MARKDOWN_IT_RULES,
force_quote_link: true,
user_id: opts[:user_id],
hashtag_context: "chat-composer",
)
result =
Oneboxer.apply(cooked) do |url|
if opts[:invalidate_oneboxes]
Oneboxer.invalidate(url)
InlineOneboxer.invalidate(url)
end
onebox = Oneboxer.cached_onebox(url)
onebox
end
cooked = result.to_html if result.changed?
cooked
end
def full_url
"#{Discourse.base_url}#{url}"
end
def url
"/chat/c/-/#{self.chat_channel_id}/#{self.id}"
end
def create_mentions(user_ids)
return if user_ids.empty?
now = Time.zone.now
mentions = []
User
.where(id: user_ids)
.find_each do |user|
mentions << {
chat_message_id: self.id,
user_id: user.id,
created_at: now,
updated_at: now,
}
end
Chat::Mention.insert_all(mentions)
end
def update_mentions(mentioned_user_ids)
old_mentions = chat_mentions.pluck(:user_id)
updated_mentions = mentioned_user_ids
mentioned_user_ids_to_drop = old_mentions - updated_mentions
mentioned_user_ids_to_add = updated_mentions - old_mentions
delete_mentions(mentioned_user_ids_to_drop)
create_mentions(mentioned_user_ids_to_add)
end
private
def delete_mentions(user_ids)
chat_mentions.where(user_id: user_ids).destroy_all
end
def message_too_short?
message.length < SiteSetting.chat_minimum_message_length
end
def message_too_long?
message.length > SiteSetting.chat_maximum_message_length
end
def ensure_last_editor_id
self.last_editor_id ||= self.user_id
end
end
end
# == Schema Information
#
# Table name: chat_messages
#
# id :bigint not null, primary key
# chat_channel_id :integer not null
# user_id :integer
# created_at :datetime not null
# updated_at :datetime not null
# deleted_at :datetime
# deleted_by_id :integer
# in_reply_to_id :integer
# message :text
# cooked :text
# cooked_version :integer
# last_editor_id :integer not null
# thread_id :integer
#
# Indexes
#
# idx_chat_messages_by_created_at_not_deleted (created_at) WHERE (deleted_at IS NULL)
# index_chat_messages_on_chat_channel_id_and_created_at (chat_channel_id,created_at)
# index_chat_messages_on_chat_channel_id_and_id (chat_channel_id,id) WHERE (deleted_at IS NULL)
# index_chat_messages_on_last_editor_id (last_editor_id)
# index_chat_messages_on_thread_id (thread_id)
#

View File

@ -1,8 +1,12 @@
# frozen_string_literal: true
class ChatMessageReaction < ActiveRecord::Base
belongs_to :chat_message
belongs_to :user
module Chat
class MessageReaction < ActiveRecord::Base
self.table_name = "chat_message_reactions"
belongs_to :chat_message, class_name: "Chat::Message"
belongs_to :user
end
end
# == Schema Information

View File

@ -1,8 +1,12 @@
# frozen_string_literal: true
class ChatMessageRevision < ActiveRecord::Base
belongs_to :chat_message
belongs_to :user
module Chat
class MessageRevision < ActiveRecord::Base
self.table_name = "chat_message_revisions"
belongs_to :chat_message, class_name: "Chat::Message"
belongs_to :user
end
end
# == Schema Information

View File

@ -0,0 +1,159 @@
# frozen_string_literal: true
module Chat
class ReviewableMessage < Reviewable
def serializer
Chat::ReviewableMessageSerializer
end
def self.action_aliases
{
agree_and_keep_hidden: :agree_and_delete,
agree_and_silence: :agree_and_delete,
agree_and_suspend: :agree_and_delete,
delete_and_agree: :agree_and_delete,
}
end
def self.score_to_silence_user
sensitivity_score(SiteSetting.chat_silence_user_sensitivity, scale: 0.6)
end
def chat_message
@chat_message ||= (target || Chat::Message.with_deleted.find_by(id: target_id))
end
def chat_message_creator
@chat_message_creator ||= chat_message.user
end
def flagged_by_user_ids
@flagged_by_user_ids ||= reviewable_scores.map(&:user_id)
end
def post
nil
end
def build_actions(actions, guardian, args)
return unless pending?
return if chat_message.blank?
agree =
actions.add_bundle(
"#{id}-agree",
icon: "thumbs-up",
label: "reviewables.actions.agree.title",
)
if chat_message.deleted_at?
build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree)
build_action(actions, :agree_and_keep_deleted, icon: "thumbs-up", bundle: agree)
build_action(actions, :disagree_and_restore, icon: "thumbs-down")
else
build_action(actions, :agree_and_delete, icon: "far-eye-slash", bundle: agree)
build_action(actions, :agree_and_keep_message, icon: "thumbs-up", bundle: agree)
build_action(actions, :disagree, icon: "thumbs-down")
end
if guardian.can_suspend?(chat_message_creator)
build_action(
actions,
:agree_and_suspend,
icon: "ban",
bundle: agree,
client_action: "suspend",
)
build_action(
actions,
:agree_and_silence,
icon: "microphone-slash",
bundle: agree,
client_action: "silence",
)
end
build_action(actions, :ignore, icon: "external-link-alt")
unless chat_message.deleted_at?
build_action(actions, :delete_and_agree, icon: "far-trash-alt")
end
end
def perform_agree_and_keep_message(performed_by, args)
agree
end
def perform_agree_and_restore(performed_by, args)
agree { chat_message.recover! }
end
def perform_agree_and_delete(performed_by, args)
agree { chat_message.trash!(performed_by) }
end
def perform_disagree_and_restore(performed_by, args)
disagree { chat_message.recover! }
end
def perform_disagree(performed_by, args)
disagree
end
def perform_ignore(performed_by, args)
ignore
end
def perform_delete_and_ignore(performed_by, args)
ignore { chat_message.trash!(performed_by) }
end
private
def agree
yield if block_given?
create_result(:success, :approved) do |result|
result.update_flag_stats = { status: :agreed, user_ids: flagged_by_user_ids }
result.recalculate_score = true
end
end
def disagree
yield if block_given?
UserSilencer.unsilence(chat_message_creator)
create_result(:success, :rejected) do |result|
result.update_flag_stats = { status: :disagreed, user_ids: flagged_by_user_ids }
result.recalculate_score = true
end
end
def ignore
yield if block_given?
create_result(:success, :ignored) do |result|
result.update_flag_stats = { status: :ignored, user_ids: flagged_by_user_ids }
end
end
def build_action(
actions,
id,
icon:,
button_class: nil,
bundle: nil,
client_action: nil,
confirm: false
)
actions.add(id, bundle: bundle) do |action|
prefix = "reviewables.actions.#{id}"
action.icon = icon
action.button_class = button_class
action.label = "chat.#{prefix}.title"
action.description = "chat.#{prefix}.description"
action.client_action = client_action
action.confirm_message = "#{prefix}.confirm" if confirm
end
end
end
end

View File

@ -1,29 +1,34 @@
# frozen_string_literal: true
class ChatThread < ActiveRecord::Base
EXCERPT_LENGTH = 150
module Chat
class Thread < ActiveRecord::Base
EXCERPT_LENGTH = 150
belongs_to :channel, foreign_key: "channel_id", class_name: "ChatChannel"
belongs_to :original_message_user, foreign_key: "original_message_user_id", class_name: "User"
belongs_to :original_message, foreign_key: "original_message_id", class_name: "ChatMessage"
self.table_name = "chat_threads"
has_many :chat_messages,
-> { order("chat_messages.created_at ASC, chat_messages.id ASC") },
foreign_key: :thread_id,
primary_key: :id
belongs_to :channel, foreign_key: "channel_id", class_name: "Chat::Channel"
belongs_to :original_message_user, foreign_key: "original_message_user_id", class_name: "User"
belongs_to :original_message, foreign_key: "original_message_id", class_name: "Chat::Message"
enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false
has_many :chat_messages,
-> { order("chat_messages.created_at ASC, chat_messages.id ASC") },
foreign_key: :thread_id,
primary_key: :id,
class_name: "Chat::Message"
def url
"#{channel.url}/t/#{self.id}"
end
enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false
def relative_url
"#{channel.relative_url}/t/#{self.id}"
end
def url
"#{channel.url}/t/#{self.id}"
end
def excerpt
original_message.excerpt(max_length: EXCERPT_LENGTH)
def relative_url
"#{channel.relative_url}/t/#{self.id}"
end
def excerpt
original_message.excerpt(max_length: EXCERPT_LENGTH)
end
end
end

View File

@ -5,11 +5,15 @@
#
# NOTE: Do not use this model anymore, chat messages are linked to uploads via
# the UploadReference table now, just like everything else.
class ChatUpload < ActiveRecord::Base
belongs_to :chat_message
belongs_to :upload
module Chat
class Upload < ActiveRecord::Base
self.table_name = "chat_uploads"
deprecate *public_instance_methods(false)
belongs_to :chat_message, class_name: "Chat::Message"
belongs_to :upload
deprecate *public_instance_methods(false)
end
end
# == Schema Information

View File

@ -1,18 +1,22 @@
# frozen_string_literal: true
class UserChatChannelMembership < ActiveRecord::Base
NOTIFICATION_LEVELS = { never: 0, mention: 1, always: 2 }
module Chat
class UserChatChannelMembership < ActiveRecord::Base
self.table_name = "user_chat_channel_memberships"
belongs_to :user
belongs_to :chat_channel
belongs_to :last_read_message, class_name: "ChatMessage", optional: true
NOTIFICATION_LEVELS = { never: 0, mention: 1, always: 2 }
enum :desktop_notification_level, NOTIFICATION_LEVELS, prefix: :desktop_notifications
enum :mobile_notification_level, NOTIFICATION_LEVELS, prefix: :mobile_notifications
enum :join_mode, { manual: 0, automatic: 1 }
belongs_to :user
belongs_to :last_read_message, class_name: "Chat::Message", optional: true
belongs_to :chat_channel, class_name: "Chat::Channel", foreign_key: :chat_channel_id
attribute :unread_count, default: 0
attribute :unread_mentions, default: 0
enum :desktop_notification_level, NOTIFICATION_LEVELS, prefix: :desktop_notifications
enum :mobile_notification_level, NOTIFICATION_LEVELS, prefix: :mobile_notifications
enum :join_mode, { manual: 0, automatic: 1 }
attribute :unread_count, default: 0
attribute :unread_mentions, default: 0
end
end
# == Schema Information

View File

@ -0,0 +1,95 @@
# frozen_string_literal: true
module Chat
class View
attr_reader :user, :chat_channel, :chat_messages, :can_load_more_past, :can_load_more_future
def initialize(
chat_channel:,
chat_messages:,
user:,
can_load_more_past: nil,
can_load_more_future: nil
)
@chat_channel = chat_channel
@chat_messages = chat_messages
@user = user
@can_load_more_past = can_load_more_past
@can_load_more_future = can_load_more_future
end
def reviewable_ids
return @reviewable_ids if defined?(@reviewable_ids)
@reviewable_ids = @user.staff? ? get_reviewable_ids : nil
end
def user_flag_statuses
return @user_flag_statuses if defined?(@user_flag_statuses)
@user_flag_statuses = get_user_flag_statuses
end
private
def get_reviewable_ids
sql = <<~SQL
SELECT
target_id,
MAX(r.id) reviewable_id
FROM
reviewables r
JOIN
reviewable_scores s ON reviewable_id = r.id
WHERE
r.target_id IN (:message_ids) AND
r.target_type = :target_type AND
s.status = :pending
GROUP BY
target_id
SQL
ids = {}
DB
.query(
sql,
pending: ReviewableScore.statuses[:pending],
message_ids: @chat_messages.map(&:id),
target_type: Chat::Message.sti_name,
)
.each { |row| ids[row.target_id] = row.reviewable_id }
ids
end
def get_user_flag_statuses
sql = <<~SQL
SELECT
target_id,
s.status
FROM
reviewables r
JOIN
reviewable_scores s ON reviewable_id = r.id
WHERE
s.user_id = :user_id AND
r.target_id IN (:message_ids) AND
r.target_type = :target_type
SQL
statuses = {}
DB
.query(
sql,
message_ids: @chat_messages.map(&:id),
user_id: @user.id,
target_type: Chat::Message.sti_name,
)
.each { |row| statuses[row.target_id] = row.status }
statuses
end
end
end

View File

@ -1,11 +1,15 @@
# frozen_string_literal: true
class ChatWebhookEvent < ActiveRecord::Base
belongs_to :chat_message
belongs_to :incoming_chat_webhook
module Chat
class WebhookEvent < ActiveRecord::Base
self.table_name = "chat_webhook_events"
delegate :username, to: :incoming_chat_webhook
delegate :emoji, to: :incoming_chat_webhook
belongs_to :chat_message, class_name: "Chat::Message"
belongs_to :incoming_chat_webhook, class_name: "Chat::IncomingWebhook"
delegate :username, to: :incoming_chat_webhook
delegate :emoji, to: :incoming_chat_webhook
end
end
# == Schema Information

View File

@ -1,176 +0,0 @@
# frozen_string_literal: true
class ChatChannel < ActiveRecord::Base
include Trashable
belongs_to :chatable, polymorphic: true
belongs_to :direct_message,
-> { where(chat_channels: { chatable_type: "DirectMessage" }) },
foreign_key: "chatable_id"
has_many :chat_messages
has_many :user_chat_channel_memberships
has_one :chat_channel_archive
enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false
validates :name,
length: {
maximum: Proc.new { SiteSetting.max_topic_title_length },
},
presence: true,
allow_nil: true
validate :ensure_slug_ok, if: :slug_changed?
before_validation :generate_auto_slug
scope :public_channels,
-> {
where(chatable_type: public_channel_chatable_types).where(
"categories.id IS NOT NULL",
).joins(
"LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'",
)
}
delegate :empty?, to: :chat_messages, prefix: true
class << self
def editable_statuses
statuses.filter { |k, _| !%w[read_only archived].include?(k) }
end
def public_channel_chatable_types
["Category"]
end
def chatable_types
public_channel_chatable_types << "DirectMessage"
end
end
statuses.keys.each do |status|
define_method("#{status}!") { |acting_user| change_status(acting_user, status.to_sym) }
end
%i[
category_channel?
direct_message_channel?
public_channel?
chatable_has_custom_fields?
read_restricted?
].each { |name| define_method(name) { false } }
%i[allowed_user_ids allowed_group_ids chatable_url].each { |name| define_method(name) { nil } }
def membership_for(user)
user_chat_channel_memberships.find_by(user: user)
end
def add(user)
Chat::ChatChannelMembershipManager.new(self).follow(user)
end
def remove(user)
Chat::ChatChannelMembershipManager.new(self).unfollow(user)
end
def url
"#{Discourse.base_url}/chat/c/#{self.slug || "-"}/#{self.id}"
end
def relative_url
"#{Discourse.base_path}/chat/c/#{self.slug || "-"}/#{self.id}"
end
def self.ensure_consistency!
update_counts
end
# TODO (martin) Move UpdateUserCountsForChatChannels into here
def self.update_counts
# NOTE: ChatChannel#messages_count is not updated every time
# a message is created or deleted in a channel, so it should not
# be displayed in the UI. It is updated eventually via Jobs::ChatPeriodicalUpdates
DB.exec <<~SQL
UPDATE chat_channels channels
SET messages_count = subquery.messages_count
FROM (
SELECT COUNT(*) AS messages_count, chat_channel_id
FROM chat_messages
WHERE chat_messages.deleted_at IS NULL
GROUP BY chat_channel_id
) subquery
WHERE channels.id = subquery.chat_channel_id
AND channels.deleted_at IS NULL
AND subquery.messages_count != channels.messages_count
SQL
end
private
def change_status(acting_user, target_status)
return if !Guardian.new(acting_user).can_change_channel_status?(self, target_status)
self.update!(status: target_status)
log_channel_status_change(acting_user: acting_user)
end
def log_channel_status_change(acting_user:)
DiscourseEvent.trigger(
:chat_channel_status_change,
channel: self,
old_status: status_previously_was,
new_status: status,
)
StaffActionLogger.new(acting_user).log_custom(
"chat_channel_status_change",
{
chat_channel_id: self.id,
chat_channel_name: self.name,
previous_value: status_previously_was,
new_value: status,
},
)
ChatPublisher.publish_channel_status(self)
end
def duplicate_slug?
ChatChannel.where(slug: self.slug).where.not(id: self.id).any?
end
end
# == Schema Information
#
# Table name: chat_channels
#
# id :bigint not null, primary key
# chatable_id :integer not null
# deleted_at :datetime
# deleted_by_id :integer
# featured_in_category_id :integer
# delete_after_seconds :integer
# chatable_type :string not null
# created_at :datetime not null
# updated_at :datetime not null
# name :string
# description :text
# status :integer default("open"), not null
# user_count :integer default(0), not null
# last_message_sent_at :datetime not null
# auto_join_users :boolean default(FALSE), not null
# allow_channel_wide_mentions :boolean default(TRUE), not null
# user_count_stale :boolean default(FALSE), not null
# slug :string
# type :string
# threading_enabled :boolean default(FALSE), not null
#
# Indexes
#
# index_chat_channels_on_messages_count (messages_count)
# index_chat_channels_on_chatable_id (chatable_id)
# index_chat_channels_on_chatable_id_and_chatable_type (chatable_id,chatable_type)
# index_chat_channels_on_slug (slug) UNIQUE
# index_chat_channels_on_status (status)
#

View File

@ -1,297 +0,0 @@
# frozen_string_literal: true
class ChatMessage < ActiveRecord::Base
include Trashable
attribute :has_oneboxes, default: false
BAKED_VERSION = 2
belongs_to :chat_channel
belongs_to :user
belongs_to :in_reply_to, class_name: "ChatMessage"
belongs_to :last_editor, class_name: "User"
belongs_to :thread, class_name: "ChatThread"
has_many :replies, class_name: "ChatMessage", foreign_key: "in_reply_to_id", dependent: :nullify
has_many :revisions, class_name: "ChatMessageRevision", dependent: :destroy
has_many :reactions, class_name: "ChatMessageReaction", dependent: :destroy
has_many :bookmarks, as: :bookmarkable, dependent: :destroy
has_many :upload_references, as: :target, dependent: :destroy
has_many :uploads, through: :upload_references
# TODO (martin) Remove this when we drop the ChatUpload table
has_many :chat_uploads, dependent: :destroy
has_one :chat_webhook_event, dependent: :destroy
has_many :chat_mentions, dependent: :destroy
scope :in_public_channel,
-> {
joins(:chat_channel).where(
chat_channel: {
chatable_type: ChatChannel.public_channel_chatable_types,
},
)
}
scope :in_dm_channel,
-> { joins(:chat_channel).where(chat_channel: { chatable_type: "DirectMessage" }) }
scope :created_before, ->(date) { where("chat_messages.created_at < ?", date) }
before_save { ensure_last_editor_id }
def validate_message(has_uploads:)
WatchedWordsValidator.new(attributes: [:message]).validate(self)
if self.new_record? || self.changed.include?("message")
Chat::DuplicateMessageValidator.new(self).validate
end
if !has_uploads && message_too_short?
self.errors.add(
:base,
I18n.t(
"chat.errors.minimum_length_not_met",
count: SiteSetting.chat_minimum_message_length,
),
)
end
if message_too_long?
self.errors.add(
:base,
I18n.t("chat.errors.message_too_long", count: SiteSetting.chat_maximum_message_length),
)
end
end
def attach_uploads(uploads)
return if uploads.blank? || self.new_record?
now = Time.now
ref_record_attrs =
uploads.map do |upload|
{
upload_id: upload.id,
target_id: self.id,
target_type: "ChatMessage",
created_at: now,
updated_at: now,
}
end
UploadReference.insert_all!(ref_record_attrs)
end
def excerpt(max_length: 50)
# just show the URL if the whole message is a URL, because we cannot excerpt oneboxes
return message if UrlHelper.relaxed_parse(message).is_a?(URI)
# upload-only messages are better represented as the filename
return uploads.first.original_filename if cooked.blank? && uploads.present?
# this may return blank for some complex things like quotes, that is acceptable
PrettyText.excerpt(message, max_length, { text_entities: true })
end
def cooked_for_excerpt
(cooked.blank? && uploads.present?) ? "<p>#{uploads.first.original_filename}</p>" : cooked
end
def push_notification_excerpt
Emoji.gsub_emoji_to_unicode(message).truncate(400)
end
def to_markdown
upload_markdown =
self
.upload_references
.includes(:upload)
.order(:created_at)
.map(&:to_markdown)
.reject(&:empty?)
return self.message if upload_markdown.empty?
return ["#{self.message}\n"].concat(upload_markdown).join("\n") if self.message.present?
upload_markdown.join("\n")
end
def cook
ensure_last_editor_id
self.cooked = self.class.cook(self.message, user_id: self.last_editor_id)
self.cooked_version = BAKED_VERSION
end
def rebake!(invalidate_oneboxes: false, priority: nil)
ensure_last_editor_id
previous_cooked = self.cooked
new_cooked =
self.class.cook(
message,
invalidate_oneboxes: invalidate_oneboxes,
user_id: self.last_editor_id,
)
update_columns(cooked: new_cooked, cooked_version: BAKED_VERSION)
args = { chat_message_id: self.id }
args[:queue] = priority.to_s if priority && priority != :normal
args[:is_dirty] = true if previous_cooked != new_cooked
Jobs.enqueue(:process_chat_message, args)
end
def self.uncooked
where("cooked_version <> ? or cooked_version IS NULL", BAKED_VERSION)
end
MARKDOWN_FEATURES = %w[
anchor
bbcode-block
bbcode-inline
code
category-hashtag
censored
chat-transcript
discourse-local-dates
emoji
emojiShortcuts
inlineEmoji
html-img
hashtag-autocomplete
mentions
unicodeUsernames
onebox
quotes
spoiler-alert
table
text-post-process
upload-protocol
watched-words
]
MARKDOWN_IT_RULES = %w[
autolink
list
backticks
newline
code
fence
image
table
linkify
link
strikethrough
blockquote
emphasis
]
def self.cook(message, opts = {})
# A rule in our Markdown pipeline may have Guardian checks that require a
# user to be present. The last editing user of the message will be more
# generally up to date than the creating user. For example, we use
# this when cooking #hashtags to determine whether we should render
# the found hashtag based on whether the user can access the channel it
# is referencing.
cooked =
PrettyText.cook(
message,
features_override: MARKDOWN_FEATURES + DiscoursePluginRegistry.chat_markdown_features.to_a,
markdown_it_rules: MARKDOWN_IT_RULES,
force_quote_link: true,
user_id: opts[:user_id],
hashtag_context: "chat-composer",
)
result =
Oneboxer.apply(cooked) do |url|
if opts[:invalidate_oneboxes]
Oneboxer.invalidate(url)
InlineOneboxer.invalidate(url)
end
onebox = Oneboxer.cached_onebox(url)
onebox
end
cooked = result.to_html if result.changed?
cooked
end
def full_url
"#{Discourse.base_url}#{url}"
end
def url
"/chat/c/-/#{self.chat_channel_id}/#{self.id}"
end
def create_mentions(user_ids)
return if user_ids.empty?
now = Time.zone.now
mentions = []
User
.where(id: user_ids)
.find_each do |user|
mentions << { chat_message_id: self.id, user_id: user.id, created_at: now, updated_at: now }
end
ChatMention.insert_all(mentions)
end
def update_mentions(mentioned_user_ids)
old_mentions = chat_mentions.pluck(:user_id)
updated_mentions = mentioned_user_ids
mentioned_user_ids_to_drop = old_mentions - updated_mentions
mentioned_user_ids_to_add = updated_mentions - old_mentions
delete_mentions(mentioned_user_ids_to_drop)
create_mentions(mentioned_user_ids_to_add)
end
private
def delete_mentions(user_ids)
chat_mentions.where(user_id: user_ids).destroy_all
end
def message_too_short?
message.length < SiteSetting.chat_minimum_message_length
end
def message_too_long?
message.length > SiteSetting.chat_maximum_message_length
end
def ensure_last_editor_id
self.last_editor_id ||= self.user_id
end
end
# == Schema Information
#
# Table name: chat_messages
#
# id :bigint not null, primary key
# chat_channel_id :integer not null
# user_id :integer
# created_at :datetime not null
# updated_at :datetime not null
# deleted_at :datetime
# deleted_by_id :integer
# in_reply_to_id :integer
# message :text
# cooked :text
# cooked_version :integer
# last_editor_id :integer not null
# thread_id :integer
#
# Indexes
#
# idx_chat_messages_by_created_at_not_deleted (created_at) WHERE (deleted_at IS NULL)
# index_chat_messages_on_chat_channel_id_and_created_at (chat_channel_id,created_at)
# index_chat_messages_on_chat_channel_id_and_id (chat_channel_id,id) WHERE (deleted_at IS NULL)
# index_chat_messages_on_last_editor_id (last_editor_id)
# index_chat_messages_on_thread_id (thread_id)
#

View File

@ -1,87 +0,0 @@
# frozen_string_literal: true
class ChatView
attr_reader :user, :chat_channel, :chat_messages, :can_load_more_past, :can_load_more_future
def initialize(
chat_channel:,
chat_messages:,
user:,
can_load_more_past: nil,
can_load_more_future: nil
)
@chat_channel = chat_channel
@chat_messages = chat_messages
@user = user
@can_load_more_past = can_load_more_past
@can_load_more_future = can_load_more_future
end
def reviewable_ids
return @reviewable_ids if defined?(@reviewable_ids)
@reviewable_ids = @user.staff? ? get_reviewable_ids : nil
end
def user_flag_statuses
return @user_flag_statuses if defined?(@user_flag_statuses)
@user_flag_statuses = get_user_flag_statuses
end
private
def get_reviewable_ids
sql = <<~SQL
SELECT
target_id,
MAX(r.id) reviewable_id
FROM
reviewables r
JOIN
reviewable_scores s ON reviewable_id = r.id
WHERE
r.target_id IN (:message_ids) AND
r.target_type = 'ChatMessage' AND
s.status = :pending
GROUP BY
target_id
SQL
ids = {}
DB
.query(
sql,
pending: ReviewableScore.statuses[:pending],
message_ids: @chat_messages.map(&:id),
)
.each { |row| ids[row.target_id] = row.reviewable_id }
ids
end
def get_user_flag_statuses
sql = <<~SQL
SELECT
target_id,
s.status
FROM
reviewables r
JOIN
reviewable_scores s ON reviewable_id = r.id
WHERE
s.user_id = :user_id AND
r.target_id IN (:message_ids) AND
r.target_type = 'ChatMessage'
SQL
statuses = {}
DB
.query(sql, message_ids: @chat_messages.map(&:id), user_id: @user.id)
.each { |row| statuses[row.target_id] = row.status }
statuses
end
end

View File

@ -0,0 +1,55 @@
# frozen_string_literal: true
module Chat
module Chatable
extend ActiveSupport::Concern
STI_CLASS_MAPPING = {
"CategoryChannel" => Chat::CategoryChannel,
"DirectMessageChannel" => Chat::DirectMessageChannel,
}
# the model used when loading type column
def self.sti_class_for(name)
STI_CLASS_MAPPING[name] if STI_CLASS_MAPPING.key?(name)
end
# the type column value
def self.sti_name_for(klass)
STI_CLASS_MAPPING.invert.fetch(klass)
end
POLYMORPHIC_CLASS_MAPPING = { "DirectMessage" => Chat::DirectMessage }
# the model used when loading chatable_type column
def self.polymorphic_class_for(name)
POLYMORPHIC_CLASS_MAPPING[name] if POLYMORPHIC_CLASS_MAPPING.key?(name)
end
# the chatable_type column value
def self.polymorphic_name_for(klass)
POLYMORPHIC_CLASS_MAPPING.invert.fetch(klass)
end
def chat_channel
channel_class.new(chatable: self)
end
def create_chat_channel!(**args)
channel_class.create!(args.merge(chatable: self))
end
private
def channel_class
case self
when Chat::DirectMessage
Chat::DirectMessageChannel
when Category
Chat::CategoryChannel
else
raise("Unknown chatable #{self}")
end
end
end
end

View File

@ -1,19 +0,0 @@
# frozen_string_literal: true
module Chatable
extend ActiveSupport::Concern
def chat_channel
channel_class.new(chatable: self)
end
def create_chat_channel!(**args)
channel_class.create!(args.merge(chatable: self))
end
private
def channel_class
"#{self.class}Channel".safe_constantize || raise("Unknown chatable #{self}")
end
end

View File

@ -1,15 +0,0 @@
# frozen_string_literal: true
class DeletedChatUser < User
def username
I18n.t("chat.deleted_chat_username")
end
def avatar_template
"/plugins/chat/images/deleted-chat-user-avatar.png"
end
def bot?
false
end
end

View File

@ -1,59 +0,0 @@
# frozen_string_literal: true
class DirectMessage < ActiveRecord::Base
self.table_name = "direct_message_channels"
include Chatable
has_many :direct_message_users, foreign_key: :direct_message_channel_id
has_many :users, through: :direct_message_users
def self.for_user_ids(user_ids)
joins(:users)
.group("direct_message_channels.id")
.having("ARRAY[?] = ARRAY_AGG(users.id ORDER BY users.id)", user_ids.sort)
&.first
end
def user_can_access?(user)
users.include?(user)
end
def chat_channel_title_for_user(chat_channel, acting_user)
users =
(direct_message_users.map(&:user) - [acting_user]).map { |user| user || DeletedChatUser.new }
# direct message to self
if users.empty?
return I18n.t("chat.channel.dm_title.single_user", username: "@#{acting_user.username}")
end
# all users deleted
return chat_channel.id if !users.first
usernames_formatted = users.sort_by(&:username).map { |u| "@#{u.username}" }
if usernames_formatted.size > 5
return(
I18n.t(
"chat.channel.dm_title.multi_user_truncated",
comma_separated_usernames: usernames_formatted[0..4].join(I18n.t("word_connector.comma")),
count: usernames_formatted.length - 5,
)
)
end
I18n.t(
"chat.channel.dm_title.multi_user",
comma_separated_usernames: usernames_formatted.join(I18n.t("word_connector.comma")),
)
end
end
# == Schema Information
#
# Table name: direct_message_channels
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
#

View File

@ -1,29 +0,0 @@
# frozen_string_literal: true
class DirectMessageChannel < ChatChannel
alias_attribute :direct_message, :chatable
def direct_message_channel?
true
end
def allowed_user_ids
direct_message.user_ids
end
def read_restricted?
true
end
def title(user)
direct_message.chat_channel_title_for_user(self, user)
end
def ensure_slug_ok
true
end
def generate_auto_slug
self.slug = nil
end
end

View File

@ -1,149 +0,0 @@
# frozen_string_literal: true
require_dependency "reviewable"
class ReviewableChatMessage < Reviewable
def self.action_aliases
{
agree_and_keep_hidden: :agree_and_delete,
agree_and_silence: :agree_and_delete,
agree_and_suspend: :agree_and_delete,
delete_and_agree: :agree_and_delete,
}
end
def self.score_to_silence_user
sensitivity_score(SiteSetting.chat_silence_user_sensitivity, scale: 0.6)
end
def chat_message
@chat_message ||= (target || ChatMessage.with_deleted.find_by(id: target_id))
end
def chat_message_creator
@chat_message_creator ||= chat_message.user
end
def flagged_by_user_ids
@flagged_by_user_ids ||= reviewable_scores.map(&:user_id)
end
def post
nil
end
def build_actions(actions, guardian, args)
return unless pending?
return if chat_message.blank?
agree =
actions.add_bundle("#{id}-agree", icon: "thumbs-up", label: "reviewables.actions.agree.title")
if chat_message.deleted_at?
build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree)
build_action(actions, :agree_and_keep_deleted, icon: "thumbs-up", bundle: agree)
build_action(actions, :disagree_and_restore, icon: "thumbs-down")
else
build_action(actions, :agree_and_delete, icon: "far-eye-slash", bundle: agree)
build_action(actions, :agree_and_keep_message, icon: "thumbs-up", bundle: agree)
build_action(actions, :disagree, icon: "thumbs-down")
end
if guardian.can_suspend?(chat_message_creator)
build_action(
actions,
:agree_and_suspend,
icon: "ban",
bundle: agree,
client_action: "suspend",
)
build_action(
actions,
:agree_and_silence,
icon: "microphone-slash",
bundle: agree,
client_action: "silence",
)
end
build_action(actions, :ignore, icon: "external-link-alt")
build_action(actions, :delete_and_agree, icon: "far-trash-alt") unless chat_message.deleted_at?
end
def perform_agree_and_keep_message(performed_by, args)
agree
end
def perform_agree_and_restore(performed_by, args)
agree { chat_message.recover! }
end
def perform_agree_and_delete(performed_by, args)
agree { chat_message.trash!(performed_by) }
end
def perform_disagree_and_restore(performed_by, args)
disagree { chat_message.recover! }
end
def perform_disagree(performed_by, args)
disagree
end
def perform_ignore(performed_by, args)
ignore
end
def perform_delete_and_ignore(performed_by, args)
ignore { chat_message.trash!(performed_by) }
end
private
def agree
yield if block_given?
create_result(:success, :approved) do |result|
result.update_flag_stats = { status: :agreed, user_ids: flagged_by_user_ids }
result.recalculate_score = true
end
end
def disagree
yield if block_given?
UserSilencer.unsilence(chat_message_creator)
create_result(:success, :rejected) do |result|
result.update_flag_stats = { status: :disagreed, user_ids: flagged_by_user_ids }
result.recalculate_score = true
end
end
def ignore
yield if block_given?
create_result(:success, :ignored) do |result|
result.update_flag_stats = { status: :ignored, user_ids: flagged_by_user_ids }
end
end
def build_action(
actions,
id,
icon:,
button_class: nil,
bundle: nil,
client_action: nil,
confirm: false
)
actions.add(id, bundle: bundle) do |action|
prefix = "reviewables.actions.#{id}"
action.icon = icon
action.button_class = button_class
action.label = "chat.#{prefix}.title"
action.description = "chat.#{prefix}.description"
action.client_action = client_action
action.confirm_message = "#{prefix}.confirm" if confirm
end
end
end

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
module Chat
class ChannelMembershipsQuery
def self.call(channel:, limit: 50, offset: 0, username: nil, count_only: false)
query =
Chat::UserChatChannelMembership
.joins(:user)
.includes(:user)
.where(user: User.activated.not_suspended.not_staged)
.where(chat_channel: channel, following: true)
return query.count if count_only
if channel.category_channel? && channel.read_restricted? && channel.allowed_group_ids
query =
query.where(
"user_id IN (SELECT user_id FROM group_users WHERE group_id IN (?))",
channel.allowed_group_ids,
)
end
if username.present?
if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names
query = query.where("users.username_lower ILIKE ?", "%#{username}%")
else
query =
query.where(
"LOWER(users.name) ILIKE ? OR users.username_lower ILIKE ?",
"%#{username}%",
"%#{username}%",
)
end
end
if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names
query = query.order("users.username_lower ASC")
else
query = query.order("users.name ASC, users.username_lower ASC")
end
query.offset(offset).limit(limit)
end
def self.count(channel)
call(channel: channel, count_only: true)
end
end
end

View File

@ -1,8 +1,9 @@
# frozen_string_literal: true
class ChatChannelUnreadsQuery
def self.call(channel_id:, user_id:)
sql = <<~SQL
module Chat
class ChannelUnreadsQuery
def self.call(channel_id:, user_id:)
sql = <<~SQL
SELECT (
SELECT COUNT(*) AS unread_count
FROM chat_messages
@ -27,14 +28,15 @@ class ChatChannelUnreadsQuery
) AS mention_count;
SQL
DB
.query(
sql,
channel_id: channel_id,
user_id: user_id,
notification_type: Notification.types[:chat_mention],
)
.first
.to_h
DB
.query(
sql,
channel_id: channel_id,
user_id: user_id,
notification_type: Notification.types[:chat_mention],
)
.first
.to_h
end
end
end

View File

@ -1,47 +0,0 @@
# frozen_string_literal: true
class ChatChannelMembershipsQuery
def self.call(channel:, limit: 50, offset: 0, username: nil, count_only: false)
query =
UserChatChannelMembership
.joins(:user)
.includes(:user)
.where(user: User.activated.not_suspended.not_staged)
.where(chat_channel: channel, following: true)
return query.count if count_only
if channel.category_channel? && channel.read_restricted? && channel.allowed_group_ids
query =
query.where(
"user_id IN (SELECT user_id FROM group_users WHERE group_id IN (?))",
channel.allowed_group_ids,
)
end
if username.present?
if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names
query = query.where("users.username_lower ILIKE ?", "%#{username}%")
else
query =
query.where(
"LOWER(users.name) ILIKE ? OR users.username_lower ILIKE ?",
"%#{username}%",
"%#{username}%",
)
end
end
if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names
query = query.order("users.username_lower ASC")
else
query = query.order("users.name ASC, users.username_lower ASC")
end
query.offset(offset).limit(limit)
end
def self.count(channel)
call(channel: channel, count_only: true)
end
end

View File

@ -1,14 +0,0 @@
# frozen_string_literal: true
class AdminChatIndexSerializer < ApplicationSerializer
has_many :chat_channels, serializer: ChatChannelSerializer, embed: :objects
has_many :incoming_chat_webhooks, serializer: IncomingChatWebhookSerializer, embed: :objects
def chat_channels
object[:chat_channels]
end
def incoming_chat_webhooks
object[:incoming_chat_webhooks]
end
end

View File

@ -1,12 +0,0 @@
# frozen_string_literal: true
class BaseChatChannelMembershipSerializer < ApplicationSerializer
attributes :following,
:muted,
:desktop_notification_level,
:mobile_notification_level,
:chat_channel_id,
:last_read_message_id,
:unread_count,
:unread_mentions
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
module Chat
class AdminChatIndexSerializer < ApplicationSerializer
has_many :chat_channels, serializer: Chat::ChannelSerializer, embed: :objects
has_many :incoming_chat_webhooks, serializer: Chat::IncomingWebhookSerializer, embed: :objects
def chat_channels
object[:chat_channels]
end
def incoming_chat_webhooks
object[:incoming_chat_webhooks]
end
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
module Chat
class BaseChannelMembershipSerializer < ApplicationSerializer
attributes :following,
:muted,
:desktop_notification_level,
:mobile_notification_level,
:chat_channel_id,
:last_read_message_id,
:unread_count,
:unread_mentions
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
module Chat
class ChannelIndexSerializer < ::Chat::StructuredChannelSerializer
attributes :global_presence_channel_state
def global_presence_channel_state
PresenceChannelStateSerializer.new(PresenceChannel.new("/chat/online").state, root: nil)
end
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
module Chat
class ChannelSearchSerializer < ::Chat::StructuredChannelSerializer
has_many :users, serializer: BasicUserSerializer, embed: :objects
def users
object[:users]
end
end
end

View File

@ -0,0 +1,131 @@
# frozen_string_literal: true
module Chat
class ChannelSerializer < ApplicationSerializer
attributes :id,
:auto_join_users,
:allow_channel_wide_mentions,
:chatable,
:chatable_id,
:chatable_type,
:chatable_url,
:description,
:title,
:slug,
:last_message_sent_at,
:status,
:archive_failed,
:archive_completed,
:archived_messages,
:total_messages,
:archive_topic_id,
:memberships_count,
:current_user_membership,
:meta,
:threading_enabled
def threading_enabled
SiteSetting.enable_experimental_chat_threaded_discussions && object.threading_enabled
end
def initialize(object, opts)
super(object, opts)
@opts = opts
@current_user_membership = opts[:membership]
end
def include_description?
object.description.present?
end
def memberships_count
object.user_count
end
def chatable_url
object.chatable_url
end
def title
object.name || object.title(scope.user)
end
def chatable
case object.chatable_type
when "Category"
BasicCategorySerializer.new(object.chatable, root: false).as_json
when "DirectMessage"
Chat::DirectMessageSerializer.new(object.chatable, scope: scope, root: false).as_json
when "Site"
nil
end
end
def archive
object.chat_channel_archive
end
def include_archive_status?
!object.direct_message_channel? && scope.is_staff? && archive.present?
end
def archive_completed
archive.complete?
end
def archive_failed
archive.failed?
end
def archived_messages
archive.archived_messages
end
def total_messages
archive.total_messages
end
def archive_topic_id
archive.destination_topic_id
end
def include_auto_join_users?
scope.can_edit_chat_channel?
end
def include_current_user_membership?
@current_user_membership.present?
end
def current_user_membership
@current_user_membership.chat_channel = object
Chat::BaseChannelMembershipSerializer.new(
@current_user_membership,
scope: scope,
root: false,
).as_json
end
def meta
{
message_bus_last_ids: {
channel_message_bus_last_id: MessageBus.last_id("/chat/#{object.id}"),
new_messages:
@opts[:new_messages_message_bus_last_id] ||
MessageBus.last_id(Chat::Publisher.new_messages_message_bus_channel(object.id)),
new_mentions:
@opts[:new_mentions_message_bus_last_id] ||
MessageBus.last_id(Chat::Publisher.new_mentions_message_bus_channel(object.id)),
},
}
end
alias_method :include_archive_topic_id?, :include_archive_status?
alias_method :include_total_messages?, :include_archive_status?
alias_method :include_archived_messages?, :include_archive_status?
alias_method :include_archive_failed?, :include_archive_status?
alias_method :include_archive_completed?, :include_archive_status?
end
end

Some files were not shown because too many files have changed in this diff Show More