mirror of
https://github.com/discourse/discourse.git
synced 2026-08-11 02:59:07 +08:00
> Was stacked on #41497, which has since merged. This PR is now rebased onto `main` and contains only the chat-approval work. ### What Lets a moderator approve or reject an AI-bot moderation tool action (`suspend_user` / `silence_user`) **inside a Chat direct message with the bot**, instead of leaving for the `/review` queue — the chat counterpart to the base PR's inline PM/topic card. When the bot queues one of these actions in a DM, it posts a message with **Approve / Reject** buttons rendered via the Chat plugin's native interactive **blocks**. Clicking performs the queued action through the existing `ReviewableAiToolAction` backend (credited to the approving moderator) and rewrites the message to its resolved state, removing the buttons. ### How - `bot.rb` — `enqueue_tool_for_approval` branches on chat context: in chat it emits a `:chat_approval` signal; in PM/topic it keeps the existing inline card. - `playground.rb` — `reply_to_chat_message` posts a bot chat message carrying the Approve/Reject blocks, in the **same DM thread as the bot's reply** (AI-bot DM replies are threaded by design). DM channels only. - `chat_tool_approval.rb` — builds/parses the button `action_id`s, builds the blocks, and handles the `chat_message_interaction` event: performs the reviewable and rewrites the message. Runs synchronously so the buttons clear before the request returns. - `entry_point.rb` — registers the `:chat_message_interaction` listener. ### Authorization `Chat::CreateMessageInteraction` only checks channel visibility, so staff-gating is enforced here: the handler requires `Reviewable.viewable_by(user)` **and** `Reviewable#perform` re-checks (`ensure_performed_by_is_a_real_person!` + the approver's guardian). Non-staff clicks are ignored. A crafted `action_id` can't target another reviewable — core only matches `action_id`s present in that message's own blocks. ### Core-chat changes (3 lines) The blocks system was built for **create-time-only** blocks; nothing had ever mutated a message's `blocks` after creation. Clearing the buttons on approve/reject is the first such case, which required: - `chat-message.js` — make `blocks` a `@tracked` property (so reassigning it re-renders). - `chat-channel-subscription-manager.js` + `chat-channel-thread-subscription-manager.js` — refresh `message.blocks` in `handleEditMessage` (so the block-clearing edit reaches the client, in both the channel and thread views). All are no-ops for the only other block user (category blocks, which are never edited after creation). ### Testing `plugins/discourse-ai/spec/lib/ai_bot/chat_tool_approval_spec.rb` — action-id round-trip, block shape, staff gating, foreign/stale action-ids, approve/reject, failure surfacing, and an end-to-end run through the real `Chat::CreateMessageInteraction` service. The existing `playground_spec.rb` chat-DM tests (threaded conversation + context) continue to pass. Verified manually in a bot DM.
136 lines
5 KiB
Ruby
Vendored
136 lines
5 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
module DiscourseAi
|
|
module AiBot
|
|
# Bridges the ReviewableAiToolAction approval queue to the Chat plugin's
|
|
# interactive "blocks", so a moderator can approve/reject a bot-requested
|
|
# action inline in a chat conversation — mirroring the inline card used in
|
|
# the bot's PM/topic replies. Scoped to bot direct-message channels.
|
|
module ChatToolApproval
|
|
ACTION_PREFIX = "ai_tool_approval"
|
|
|
|
def self.build_action_id(action, reviewable_id)
|
|
"#{ACTION_PREFIX}::#{action}::#{reviewable_id}"
|
|
end
|
|
|
|
def self.parse_action_id(raw)
|
|
prefix, action, reviewable_id = raw.to_s.split("::")
|
|
return if prefix != ACTION_PREFIX
|
|
return if !%w[approve reject].include?(action)
|
|
return if reviewable_id.to_i <= 0
|
|
|
|
{ action: action, reviewable_id: reviewable_id.to_i }
|
|
end
|
|
|
|
def self.pending_blocks(reviewable_id)
|
|
[
|
|
{
|
|
type: "actions",
|
|
schema_version: 1,
|
|
elements: [
|
|
{
|
|
type: "button",
|
|
schema_version: 1,
|
|
action_id: build_action_id("approve", reviewable_id),
|
|
style: "primary",
|
|
text: {
|
|
type: "plain_text",
|
|
text: I18n.t("discourse_ai.reviewables.ai_tool_action.approve.title"),
|
|
},
|
|
},
|
|
{
|
|
type: "button",
|
|
schema_version: 1,
|
|
action_id: build_action_id("reject", reviewable_id),
|
|
style: "danger",
|
|
text: {
|
|
type: "plain_text",
|
|
text: I18n.t("discourse_ai.reviewables.ai_tool_action.reject.title"),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]
|
|
end
|
|
|
|
# Handles a :chat_message_interaction event: performs the approval/
|
|
# rejection and rewrites the message to its resolved state. Done inline
|
|
# (not in a background job) so the buttons are cleared before the request
|
|
# returns — the button is disabled while in flight, so this closes the
|
|
# window for a double-click hitting an already-resolved message.
|
|
def self.handle_interaction(interaction)
|
|
return if interaction.blank?
|
|
|
|
parsed = parse_action_id(interaction.action&.dig("action_id"))
|
|
return if parsed.blank?
|
|
|
|
reviewable = ReviewableAiToolAction.find_by(id: parsed[:reviewable_id])
|
|
return if reviewable.blank? || !reviewable.pending?
|
|
|
|
user = interaction.user
|
|
return if user.blank?
|
|
|
|
# Same authorization as the review queue: only users who can see this
|
|
# reviewable may act on it. Everyone else is silently ignored.
|
|
return if !Reviewable.viewable_by(user).exists?(id: reviewable.id)
|
|
|
|
message = interaction.message
|
|
|
|
begin
|
|
reviewable.perform(user, parsed[:action].to_sym)
|
|
status_key = parsed[:action] == "approve" ? "approved" : "rejected"
|
|
resolve_message!(
|
|
message,
|
|
I18n.t("discourse_ai.ai_bot.chat_tool_approval.#{status_key}", username: user.username),
|
|
)
|
|
rescue => e
|
|
# The reviewable stays pending; keep the buttons for a retry and
|
|
# surface the reason. Never let the event handler raise — that would
|
|
# 500 the interaction request.
|
|
append_error!(
|
|
message,
|
|
I18n.t("discourse_ai.ai_bot.chat_tool_approval.failed", error: failure_reason(e)),
|
|
)
|
|
end
|
|
end
|
|
|
|
# Surfaces a user-facing reason for a failed action. Only the localized
|
|
# messages our own flow raises (Discourse::InvalidAccess) are shown; any
|
|
# other/unexpected exception falls back to a generic message so internal
|
|
# error text is never leaked into the chat.
|
|
def self.failure_reason(error)
|
|
if error.is_a?(Discourse::InvalidAccess)
|
|
if error.custom_message.present?
|
|
return I18n.t(error.custom_message, error.custom_message_params || {})
|
|
end
|
|
return error.message if error.message.present?
|
|
end
|
|
|
|
I18n.t("discourse_ai.ai_bot.chat_tool_approval.unexpected_error")
|
|
end
|
|
|
|
# Appends the resolved status to the approval message and removes the
|
|
# buttons so it can no longer be actioned.
|
|
def self.resolve_message!(message, status_text)
|
|
return if message.blank?
|
|
|
|
message.message = "#{message.message}\n\n#{status_text}"
|
|
message.blocks = nil
|
|
message.cook
|
|
message.save!
|
|
::Chat::Publisher.publish_edit!(message.chat_channel, message.reload)
|
|
end
|
|
|
|
# Keeps the buttons in place (so a permitted moderator can retry) but
|
|
# surfaces why the action could not be completed.
|
|
def self.append_error!(message, status_text)
|
|
return if message.blank?
|
|
|
|
message.message = "#{message.message}\n\n#{status_text}"
|
|
message.cook
|
|
message.save!
|
|
::Chat::Publisher.publish_edit!(message.chat_channel, message.reload)
|
|
end
|
|
end
|
|
end
|
|
end
|