0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 11:15:10 +08:00
discourse/plugins/discourse-ai/lib/agents/bot_context.rb
RnL Dev 3f4a87b135
FEATURE: Add {username} template param to BotContext (#41446)
`BotContext` already tracks the authenticated user who triggered the bot
via `@user` and exposes `username` in `to_json`. This PR makes it
available as a system prompt template variable, consistent with the
existing params like `{participants}` and `{site_url}`.

**What this enables**

Persona authors can now use `{username}` in a system prompt to get the
current user's Discourse username substituted before the prompt reaches
the LLM. The substitution happens on the server, from the authenticated
Discourse session, not from text in the conversation.

One concrete case: a persona that proxies requests to an external
service needs to identify the caller reliably. Without `{username}`, the
LLM has to infer the username from post metadata, which is fragile and
introduces an injection surface. With `{username}`, the persona author
gets the actual authenticated username directly.

**Behavior when no user is present**

When `@user` is nil (report runners, automated pipelines, etc.),
`{username}` is left as a literal token in the prompt. This matches how
`{participants}` and `{resource_url}` behave in contexts where they have
no value. An explicit test covers this.

**Backwards compatibility**

Any persona prompt containing the literal string `{username}` would have
passed through unchanged before this change and will now be substituted.
In practice, no existing persona would have relied on that token staying
literal.

**Changes**

- `lib/agents/bot_context.rb`: add `username` to `TEMPLATE_PARAMS` and
add a `username` method
- `spec/lib/agents/agent_spec.rb`: assert substitution in the existing
render test; add a test covering the no-user case

---

I'm a paying Discourse customer (Pro, Andrea Ross / RnL Solar Inc.,
`community.ripplesandleaves.ca`) and hit this gap building an
integration that needs to pass a verified user identity from Discourse
to an external service. Happy to answer questions.
2026-08-03 12:38:39 -03:00

197 lines
5.3 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseAi
module Agents
class BotContext
attr_accessor :messages,
:topic_id,
:post_id,
:private_message,
:custom_instructions,
:user,
:feature_context,
:skip_show_thinking,
:participants,
:chosen_tools,
:message_id,
:channel_id,
:context_post_ids,
:feature_name,
:resource_url,
:cancel_manager,
:inferred_concepts,
:format_dates,
:temporal_context,
:user_language,
:bypass_response_format,
:mcp_state,
:guardian,
:reviewable_id
def initialize(
post: nil,
topic: nil,
participants: nil,
user: nil,
skip_show_thinking: nil,
messages: [],
custom_instructions: nil,
feature_context: nil,
site_url: nil,
site_title: nil,
site_description: nil,
time: nil,
message_id: nil,
channel_id: nil,
context_post_ids: nil,
feature_name: "bot",
resource_url: nil,
cancel_manager: nil,
inferred_concepts: [],
format_dates: false,
bypass_response_format: false,
guardian: nil
)
@participants = participants
@user = user
@skip_show_thinking = skip_show_thinking
@messages = messages
@custom_instructions = custom_instructions
@feature_context = feature_context || {}
@format_dates = format_dates
@message_id = message_id
@channel_id = channel_id
@context_post_ids = context_post_ids
@site_url = site_url
@site_title = site_title
@site_description = site_description
@time = time
@resource_url = resource_url
@feature_name = feature_name
@inferred_concepts = inferred_concepts
@cancel_manager = cancel_manager
@bypass_response_format = bypass_response_format
@mcp_state = {}
@guardian = guardian
if post
@post_id = post.id
@topic_id = post.topic_id
@private_message = post.topic.private_message?
@participants ||= post.topic.allowed_users.map(&:username).join(", ") if @private_message
@user ||= post.user
end
if topic
@topic_id ||= topic.id
@private_message ||= topic.private_message?
@participants ||= topic.allowed_users.map(&:username).join(", ") if @private_message
@user ||= topic.user
end
end
# these are strings that can be safely interpolated into templates
TEMPLATE_PARAMS = %w[
date
time
site_url
site_title
site_description
participants
username
resource_url
inferred_concepts
user_language
temporal_context
top_categories
]
def lookup_template_param(key)
public_send(key.to_sym) if TEMPLATE_PARAMS.include?(key)
end
def time
@time ||= Time.zone.now
end
def date
@date ||= time.strftime("%B %d, %Y")
end
def site_url
@site_url ||= Discourse.base_url
end
def site_title
@site_title ||= SiteSetting.title
end
def site_description
@site_description ||= SiteSetting.site_description
end
def private_message?
@private_message
end
def username
@user&.username
end
def top_categories
@top_categories ||=
Category
.where(read_restricted: false)
.order(posts_year: :desc)
.limit(10)
.pluck(:name)
.join(", ")
end
def to_json
{
messages: @messages,
topic_id: @topic_id,
post_id: @post_id,
private_message: @private_message,
custom_instructions: @custom_instructions,
username: @user&.username,
user_id: @user&.id,
participants: @participants,
chosen_tools: @chosen_tools,
message_id: @message_id,
channel_id: @channel_id,
context_post_ids: @context_post_ids,
site_url: @site_url,
site_title: @site_title,
site_description: @site_description,
skip_show_thinking: @skip_show_thinking,
feature_name: @feature_name,
feature_context: @feature_context,
resource_url: @resource_url,
inferred_concepts: @inferred_concepts,
user_language: @user_language,
temporal_context: @temporal_context,
top_categories: @top_categories,
bypass_response_format: @bypass_response_format,
}
end
def mcp_session_for(server_id)
@mcp_state&.dig(server_id.to_i, :session_id)
end
def store_mcp_session(server_id, session_id)
@mcp_state ||= {}
state = (@mcp_state[server_id.to_i] ||= { session_id: nil })
state[:session_id] = session_id
end
end
end
end