0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-11 02:59:07 +08:00
discourse/plugins/discourse-ai/lib/ai_bot/chat_tool_approval.rb
Gabriel Grubba 9c09e988b3
FEATURE: Approve AI moderation actions inline in bot chat DMs (#41565)
> 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.
2026-07-13 17:08:35 -03:00

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