mirror of
https://github.com/discourse/discourse.git
synced 2026-08-10 23:59:31 +08:00
Previously, `POST /posts.json` accepted an `ai_agent_id` topic custom field of any size and content, storing it unvalidated since it is registered as a publicly editable topic custom field. Additionally, a PM could be created directly to an agent's dedicated bot user even when that agent disallowed personal messages or the sender was outside its allowed groups, by simply omitting the field. This change validates agent access during topic creation via `TopicAgentValidator`: a supplied `ai_agent_id` must be a numeric id within 20 bytes resolving to an agent the user can access (and one allowing personal messages when the topic is a PM), and PMs targeting an agent's dedicated user now enforce the same group and PM policy from the recipient side, returning a 422 otherwise. PMs to default LLM bot users without an agent id remain unaffected.
321 lines
11 KiB
Ruby
Vendored
321 lines
11 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
module DiscourseAi
|
|
module AiBot
|
|
class EntryPoint
|
|
Bot = Struct.new(:id, :name, :llm)
|
|
|
|
def self.all_bot_ids
|
|
AiAgent
|
|
.agent_users
|
|
.map { |agent| agent[:user_id] }
|
|
.concat(LlmModel.where(id: LlmModel.enabled_chat_bot_ids).pluck(:user_id).compact)
|
|
end
|
|
|
|
def self.find_participant_in(participant_ids)
|
|
model = LlmModel.includes(:user).where(user_id: participant_ids).last
|
|
return if model.nil?
|
|
|
|
bot_user = model.user
|
|
|
|
Bot.new(bot_user.id, bot_user.username_lower, model.name)
|
|
end
|
|
|
|
def self.find_user_from_model(model_name)
|
|
# Hack(Roman): Added this because Command R Plus had a different in the bot settings.
|
|
# Will eventually amend it with a data migration.
|
|
name = model_name
|
|
name = "command-r-plus" if name == "cohere-command-r-plus"
|
|
|
|
LlmModel.joins(:user).where(name: name).last&.user
|
|
end
|
|
|
|
def self.enabled_user_ids_and_models_map
|
|
enabled_ids = LlmModel.enabled_chat_bot_ids
|
|
return [] if enabled_ids.empty?
|
|
|
|
DB.query_hash(<<~SQL, ids: enabled_ids)
|
|
SELECT users.username AS username, users.id AS id, llms.id AS llm_model_id, llms.name AS model_name, llms.display_name AS display_name
|
|
FROM llm_models llms
|
|
INNER JOIN users ON llms.user_id = users.id
|
|
WHERE llms.id IN (:ids)
|
|
SQL
|
|
end
|
|
|
|
def self.personal_message_bot_user_ids(user)
|
|
return [] if user.blank? || !SiteSetting.ai_bot_enabled
|
|
|
|
bot_user_ids = []
|
|
|
|
if user.in_any_groups?(SiteSetting.ai_bot_allowed_groups_map)
|
|
bot_user_ids.concat(
|
|
LlmModel
|
|
.where(id: LlmModel.enabled_chat_bot_ids)
|
|
.where.not(user_id: nil)
|
|
.pluck(:user_id),
|
|
)
|
|
end
|
|
|
|
bot_user_ids.concat(
|
|
AiAgent
|
|
.allowed_modalities(user: user, allow_personal_messages: true)
|
|
.map { |agent| agent[:user_id] },
|
|
)
|
|
|
|
bot_user_ids.compact
|
|
end
|
|
|
|
# Most errors are simply "not_allowed"
|
|
# we do not want to reveal information about this system
|
|
# the 2 exceptions are "other_people_in_pm" and "other_content_in_pm"
|
|
# in both cases you have access to the PM so we are not revealing anything
|
|
def self.ai_share_error(topic, guardian)
|
|
return nil if guardian.can_share_ai_bot_conversation?(topic)
|
|
|
|
return :not_allowed if !guardian.can_see?(topic)
|
|
|
|
# other people in PM
|
|
if topic.topic_allowed_users.where("user_id > 0 and user_id <> ?", guardian.user.id).exists?
|
|
return :other_people_in_pm
|
|
end
|
|
|
|
# other content in PM
|
|
if topic.posts.where("user_id > 0 and user_id <> ?", guardian.user.id).exists?
|
|
return :other_content_in_pm
|
|
end
|
|
|
|
:not_allowed
|
|
end
|
|
|
|
def inject_into(plugin)
|
|
# Long term we need a better API here
|
|
# we only want to load this custom field for bots
|
|
TopicView.default_post_custom_fields << POST_AI_LLM_NAME_FIELD
|
|
|
|
plugin.register_topic_custom_field_type(TOPIC_AI_BOT_PM_FIELD, :string)
|
|
|
|
# Hide bot PMs from the personal inbox queries (Latest, New, Unread)
|
|
# so human conversations are not buried under bot replies. Sent and
|
|
# Archive are intentionally untouched.
|
|
plugin.register_modifier(:private_messages_personal_inbox_query) do |list, _user|
|
|
next list unless SiteSetting.ai_bot_enabled
|
|
|
|
list.where(<<~SQL, field: TOPIC_AI_BOT_PM_FIELD)
|
|
NOT EXISTS (
|
|
SELECT 1 FROM topic_custom_fields tcf_pm_inbox
|
|
WHERE tcf_pm_inbox.topic_id = topics.id
|
|
AND tcf_pm_inbox.name = :field
|
|
AND tcf_pm_inbox.value = 't'
|
|
)
|
|
SQL
|
|
end
|
|
|
|
plugin.register_modifier(:guardian_can_send_private_message_to_target) do |allowed, params|
|
|
allowed ||
|
|
(
|
|
params[:private_message_context] == PERSONAL_MESSAGE_CONTEXT &&
|
|
params[:guardian].can_send_pm_to_ai_bot?(params[:target])
|
|
)
|
|
end
|
|
|
|
plugin.on(:topic_created) do |topic|
|
|
next if !topic.private_message?
|
|
creator = topic.user
|
|
|
|
# Only process if creator is not a bot or system user
|
|
next if DiscourseAi::AiBot::Playground.is_bot_user_id?(creator.id)
|
|
|
|
# Get all bot user IDs defined by the discourse-ai plugin
|
|
bot_ids = DiscourseAi::AiBot::EntryPoint.all_bot_ids
|
|
|
|
# Check if the only recipients are bots
|
|
recipients = topic.topic_allowed_users.pluck(:user_id)
|
|
|
|
# Remove creator from recipients for checking
|
|
recipients -= [creator.id]
|
|
|
|
# If all remaining recipients are AI bots and there's exactly one recipient
|
|
if recipients.length == 1 && (recipients - bot_ids).empty?
|
|
# The only recipient is an AI bot - add the custom field to the topic
|
|
topic.custom_fields[TOPIC_AI_BOT_PM_FIELD] = true
|
|
|
|
# Save the custom fields
|
|
topic.save_custom_fields
|
|
end
|
|
end
|
|
|
|
plugin.register_modifier(:chat_allowed_bot_user_ids) do |user_ids, guardian|
|
|
if guardian.user
|
|
allowed_chat =
|
|
AiAgent.allowed_modalities(
|
|
user: guardian.user,
|
|
allow_chat_direct_messages: true,
|
|
allow_chat_channel_mentions: true,
|
|
)
|
|
allowed_bot_ids = allowed_chat.map { |info| info[:user_id] }
|
|
user_ids.concat(allowed_bot_ids)
|
|
end
|
|
user_ids
|
|
end
|
|
|
|
plugin.on(:site_setting_changed) do |name, _old_value, _new_value|
|
|
if name == :ai_bot_enabled || name == :discourse_ai_enabled ||
|
|
name == :ai_bot_enabled_llms
|
|
DiscourseAi::AiBot::SiteSettingsExtension.enable_or_disable_ai_bots
|
|
end
|
|
end
|
|
|
|
Oneboxer.register_local_handler(
|
|
"discourse_ai/ai_bot/shared_ai_conversations",
|
|
) do |url, route|
|
|
if route[:action] == "show" && share_key = route[:share_key]
|
|
if conversation = SharedAiConversation.find_by(share_key: share_key)
|
|
conversation.onebox
|
|
end
|
|
end
|
|
end
|
|
|
|
plugin.on(:reduce_excerpt) do |doc, options|
|
|
doc.css("details").remove if options && options[:strip_details]
|
|
end
|
|
|
|
plugin.register_seedfu_fixtures(Rails.root.join("plugins/discourse-ai/db/fixtures/ai_bot"))
|
|
|
|
plugin.add_to_serializer(
|
|
:topic_view,
|
|
:is_bot_pm,
|
|
include_condition: -> do
|
|
object.topic && object.topic.private_message? &&
|
|
object.topic.custom_fields[TOPIC_AI_BOT_PM_FIELD]
|
|
end,
|
|
) { true }
|
|
|
|
plugin.add_to_serializer(
|
|
:post,
|
|
:llm_name,
|
|
include_condition: -> do
|
|
object&.topic&.private_message? && object.custom_fields[POST_AI_LLM_NAME_FIELD]
|
|
end,
|
|
) { object.custom_fields[POST_AI_LLM_NAME_FIELD] }
|
|
|
|
plugin.add_to_serializer(
|
|
:current_user,
|
|
:ai_enabled_agents,
|
|
include_condition: -> { scope.authenticated? },
|
|
) do
|
|
DiscourseAi::Agents::Agent
|
|
.all(user: scope.user)
|
|
.map do |agent|
|
|
{
|
|
id: agent.id,
|
|
name: agent.name,
|
|
description: agent.description,
|
|
force_default_llm: agent.force_default_llm,
|
|
username: agent.username,
|
|
allow_personal_messages: agent.allow_personal_messages,
|
|
}
|
|
end
|
|
end
|
|
|
|
plugin.add_to_serializer(
|
|
:current_user,
|
|
:can_debug_ai_bot_conversations,
|
|
include_condition: -> do
|
|
SiteSetting.ai_bot_enabled && scope.authenticated? &&
|
|
SiteSetting.ai_bot_debugging_allowed_groups.present? &&
|
|
scope.user.in_any_groups?(SiteSetting.ai_bot_debugging_allowed_groups_map)
|
|
end,
|
|
) { true }
|
|
|
|
plugin.add_to_serializer(
|
|
:current_user,
|
|
:ai_enabled_chat_bots,
|
|
include_condition: -> do
|
|
SiteSetting.ai_bot_enabled && scope.authenticated? &&
|
|
scope.user.in_any_groups?(SiteSetting.ai_bot_allowed_groups_map)
|
|
end,
|
|
) do
|
|
bots_map = DiscourseAi::AiBot::EntryPoint.enabled_user_ids_and_models_map
|
|
|
|
agent_users = AiAgent.agent_users(user: scope.user)
|
|
if agent_users.present?
|
|
agent_users.filter! { |agent_user| agent_user[:username].present? }
|
|
|
|
bots_map.concat(
|
|
agent_users.map do |agent_user|
|
|
{
|
|
"id" => agent_user[:user_id],
|
|
"username" => agent_user[:username],
|
|
"has_default_llm" => agent_user[:default_llm_id].present?,
|
|
"force_default_llm" => agent_user[:force_default_llm],
|
|
"is_agent" => true,
|
|
}
|
|
end,
|
|
)
|
|
end
|
|
|
|
bots_map
|
|
end
|
|
|
|
plugin.add_to_serializer(:current_user, :can_share_ai_bot_conversations) do
|
|
scope.user.in_any_groups?(SiteSetting.ai_bot_public_sharing_allowed_groups_map)
|
|
end
|
|
|
|
plugin.add_to_serializer(
|
|
:topic_view,
|
|
:ai_agent_name,
|
|
include_condition: -> { SiteSetting.ai_bot_enabled && object.topic.private_message? },
|
|
) do
|
|
topic = object.topic
|
|
id = topic.custom_fields["ai_agent_id"]
|
|
name = DiscourseAi::Agents::Agent.find_by(user: scope.user, id: id.to_i)&.name if id
|
|
name || topic.custom_fields["ai_agent"]
|
|
end
|
|
|
|
plugin.on(:post_created) { |post| DiscourseAi::AiBot::Playground.schedule_reply(post) }
|
|
|
|
plugin.on(:chat_message_created) do |chat_message, channel, user, context|
|
|
DiscourseAi::AiBot::Playground.schedule_chat_reply(chat_message, channel, user, context)
|
|
end
|
|
|
|
plugin.on(:chat_message_interaction) do |interaction|
|
|
DiscourseAi::AiBot::ChatToolApproval.handle_interaction(interaction)
|
|
end
|
|
|
|
plugin.register_editable_topic_custom_field(:ai_agent_id)
|
|
plugin.register_topic_custom_field_type(
|
|
:ai_agent_id,
|
|
:string,
|
|
max_length: TOPIC_AI_AGENT_ID_MAX_LENGTH,
|
|
)
|
|
|
|
plugin.on(:after_validate_topic) do |topic, topic_creator|
|
|
DiscourseAi::AiBot::TopicAgentValidator.validate(topic, topic_creator)
|
|
end
|
|
|
|
plugin.add_api_key_scope(
|
|
:ai,
|
|
{ stream_completion: { actions: %w[discourse_ai/admin/ai_agents#stream_reply] } },
|
|
)
|
|
|
|
plugin.on(:site_setting_changed) do |name, old_value, new_value|
|
|
if name == :ai_embeddings_selected_model && DiscourseAi::Embeddings.enabled? &&
|
|
new_value != old_value
|
|
RagDocumentFragment.delete_all
|
|
UploadReference
|
|
.where(target: AiAgent.all)
|
|
.each do |ref|
|
|
Jobs.enqueue(
|
|
:digest_rag_upload,
|
|
target_type: ref.target_type,
|
|
target_id: ref.target_id,
|
|
ai_agent_id: ref.target_id,
|
|
upload_id: ref.upload_id,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|