mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-24 00:52:52 +08:00
Mentioning an AI bot (e.g. `@Forum_Research_Assis`) in a topic the bot's
User record can't see — like a category restricted to a group the bot
isn't part of — surfaced the "this user cannot see this mention" warning
popup in the composer. The warning is misleading: AI bots reply via
`PostCreator.create!(... skip_guardian: true)` (`playground.rb`), so
they respond regardless of whether their User can `Guardian#can_see?`
the topic.
The previous case-insensitive fix (9a4cca29) exposed this latent
behavior. Pre-fix, mixed-case names hit a case-sensitive hash miss in
`ComposerController#mentions` and silently returned an empty
`user_reasons`. Post-fix, the lookup hits correctly and the reachability
check — which was always there — now fires for AI bot users that
genuinely can't see the topic.
Adds a `:composer_mention_user_reason` plugin modifier, applied after
the standard reason is computed in `user_reason`, so plugins can clear
or transform it. The AI plugin registers against the modifier and
returns `nil` for any user in
`DiscourseAi::AiBot::EntryPoint.all_bot_ids` (covering both AI agent
users and chat-bot-enabled LLM model users).
discobot and the system user are intentionally unaffected: discobot's
`PostCreator.create!` does not skip the guardian, so the reachability
warning remains accurate for it.
https://meta.discourse.org/t/401292
210 lines
6.2 KiB
Ruby
Vendored
210 lines
6.2 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
class ComposerController < ApplicationController
|
|
requires_login
|
|
|
|
def mentions
|
|
names = params.require(:names)
|
|
raise Discourse::InvalidParameters.new(:names) if !names.kind_of?(Array) || names.size > 20
|
|
@names = names.map { |n| n.to_s.downcase }
|
|
|
|
if params[:topic_id].present?
|
|
@topic = Topic.find_by(id: params[:topic_id])
|
|
guardian.ensure_can_see!(@topic)
|
|
end
|
|
|
|
# allowed_names is necessary just for new private messages.
|
|
@allowed_names =
|
|
if params[:allowed_names].present?
|
|
if !params[:allowed_names].is_a?(Array)
|
|
raise Discourse::InvalidParameters.new(:allowed_names)
|
|
end
|
|
(params[:allowed_names] + [current_user.username]).map(&:downcase)
|
|
else
|
|
[]
|
|
end
|
|
|
|
user_reasons = {}
|
|
group_reasons = {}
|
|
@names.each do |name|
|
|
if user = users[name]
|
|
reason = user_reason(user)
|
|
user_reasons[name] = reason if reason.present?
|
|
elsif group = groups[name]
|
|
reason = group_reason(group)
|
|
group_reasons[name] = reason if reason.present?
|
|
end
|
|
end
|
|
|
|
if @topic && @names.include?(SiteSetting.here_mention.downcase) && guardian.can_mention_here?
|
|
here_count =
|
|
PostAlerter.new.expand_here_mention(@topic.first_post, exclude_ids: [current_user.id]).size
|
|
end
|
|
|
|
serialized_groups =
|
|
groups
|
|
.values
|
|
.each_with_object({}) do |group, hash|
|
|
name = group.name.downcase
|
|
serialized_group = { user_count: group.user_count }
|
|
|
|
if group_reasons[name] == :not_allowed && members_visible_group_ids.include?(group.id) &&
|
|
(@topic&.private_message? || @allowed_names.present?)
|
|
notified_count = already_notified_member_count(group)
|
|
|
|
if notified_count > 0
|
|
if notified_count == group.user_count
|
|
group_reasons.delete(name)
|
|
else
|
|
group_reasons[name] = :some_not_allowed
|
|
serialized_group[:notified_count] = notified_count
|
|
end
|
|
end
|
|
end
|
|
|
|
hash[name] = serialized_group
|
|
end
|
|
|
|
render json: {
|
|
users: users.keys,
|
|
user_reasons: user_reasons,
|
|
groups: serialized_groups,
|
|
group_reasons: group_reasons,
|
|
here_count: here_count,
|
|
max_users_notified_per_group_mention: SiteSetting.max_users_notified_per_group_mention,
|
|
}
|
|
end
|
|
|
|
private
|
|
|
|
def user_reason(user)
|
|
reason =
|
|
if @topic && !user.guardian.can_see?(@topic)
|
|
@topic.private_message? ? :private : :category
|
|
elsif @allowed_names.present? &&
|
|
!is_user_allowed?(user, topic_allowed_user_ids, topic_allowed_group_ids)
|
|
# This would normally be handled by the previous if, but that does not work for new private messages.
|
|
:private
|
|
elsif topic_muted_by.include?(user.id)
|
|
:muted_topic
|
|
elsif @topic&.private_message? &&
|
|
!is_user_allowed?(user, topic_allowed_user_ids, topic_allowed_group_ids)
|
|
# Admins can see the topic, but they will not be mentioned if they were not invited.
|
|
:not_allowed
|
|
end
|
|
|
|
# Non-staff users can see only basic information why the users cannot see the topic.
|
|
reason = nil if !guardian.is_staff? && reason != :private && reason != :category
|
|
|
|
DiscoursePluginRegistry.apply_modifier(:composer_mention_user_reason, reason, user)
|
|
end
|
|
|
|
def group_reason(group)
|
|
if !mentionable_group_ids.include?(group.id)
|
|
:not_mentionable
|
|
elsif (@topic&.private_message? || @allowed_names.present?) &&
|
|
!topic_allowed_group_ids.include?(group.id)
|
|
:not_allowed
|
|
end
|
|
end
|
|
|
|
def already_notified_member_count(group)
|
|
GroupUser
|
|
.where(user_id: topic_allowed_user_ids)
|
|
.or(
|
|
GroupUser.where(
|
|
user_id: GroupUser.where(group_id: topic_allowed_group_ids).select(:user_id),
|
|
),
|
|
)
|
|
.where(group:)
|
|
.select(:user_id)
|
|
.distinct
|
|
.count
|
|
end
|
|
|
|
def is_user_allowed?(user, user_ids, group_ids)
|
|
user_ids.include?(user.id) ||
|
|
user.group_ids.any? do |group_id|
|
|
group_ids.include?(group_id) && visible_group_ids_for_allowed_check.include?(group_id)
|
|
end
|
|
end
|
|
|
|
def visible_group_ids_for_allowed_check
|
|
@visible_group_ids_for_allowed_check ||=
|
|
if @allowed_names.present?
|
|
Group
|
|
.members_visible_groups(current_user)
|
|
.where("LOWER(name) IN (?)", @allowed_names)
|
|
.pluck(:id)
|
|
.to_set
|
|
else
|
|
topic_allowed_group_ids || Set.new
|
|
end
|
|
end
|
|
|
|
def users
|
|
@users ||= User.not_staged.where(username_lower: @names).index_by(&:username_lower)
|
|
end
|
|
|
|
def groups
|
|
@groups ||=
|
|
Group
|
|
.visible_groups(current_user)
|
|
.where("LOWER(name) IN (?)", @names)
|
|
.index_by { |g| g.name.downcase }
|
|
end
|
|
|
|
def mentionable_group_ids
|
|
@mentionable_group_ids ||=
|
|
Group
|
|
.mentionable(current_user, include_public: false)
|
|
.where("LOWER(name) IN (?)", @names)
|
|
.pluck(:id)
|
|
.to_set
|
|
end
|
|
|
|
def members_visible_group_ids
|
|
@members_visible_group_ids ||=
|
|
Group
|
|
.members_visible_groups(current_user)
|
|
.where("LOWER(name) IN (?)", @names)
|
|
.pluck(:id)
|
|
.to_set
|
|
end
|
|
|
|
def topic_muted_by
|
|
@topic_muted_by ||=
|
|
if @topic.present?
|
|
TopicUser
|
|
.where(topic: @topic)
|
|
.where(user_id: users.values.map(&:id))
|
|
.where(notification_level: TopicUser.notification_levels[:muted])
|
|
.pluck(:user_id)
|
|
.to_set
|
|
else
|
|
Set.new
|
|
end
|
|
end
|
|
|
|
def topic_allowed_user_ids
|
|
@topic_allowed_user_ids ||=
|
|
if @allowed_names.present?
|
|
User.where(username_lower: @allowed_names).pluck(:id).to_set
|
|
elsif @topic&.private_message?
|
|
TopicAllowedUser.where(topic: @topic).pluck(:user_id).to_set
|
|
end
|
|
end
|
|
|
|
def topic_allowed_group_ids
|
|
@topic_allowed_group_ids ||=
|
|
if @allowed_names.present?
|
|
Group
|
|
.messageable(current_user)
|
|
.where("LOWER(name) IN (?)", @allowed_names)
|
|
.pluck(:id)
|
|
.to_set
|
|
elsif @topic&.private_message?
|
|
TopicAllowedGroup.where(topic: @topic).pluck(:group_id).to_set
|
|
end
|
|
end
|
|
end
|