Skip to main content

headless_lms_chatbot/
user_context.rs

1use headless_lms_authorization::{Resource, fetch_user_roles};
2use headless_lms_models::chatbot_configurations::ChatbotConfiguration;
3use headless_lms_models::roles::Role;
4use tokio::sync::OnceCell;
5
6use crate::chatbot_error::ChatbotResult;
7use crate::chatbot_tools::tool_category::EnabledToolCategories;
8use crate::prelude::*;
9
10/// Context for a chatbot turn, in two halves:
11/// - who is asking: `user_id`, `course_id`, `course_name`, and the roles fetched from them.
12/// - what the configuration offers: `enabled_tool_categories`, read once per request from the
13///   same configuration row the turn is assembled from.
14pub struct ChatbotTurnContext {
15    pub user_id: Option<Uuid>,
16    pub course_id: Option<Uuid>,
17    pub course_name: Option<String>,
18    /// The conversation this turn belongs to. `None` only in tests that build a context without
19    /// one; every production call site has a conversation id in scope.
20    pub conversation_id: Option<Uuid>,
21    pub enabled_tool_categories: EnabledToolCategories,
22    roles: OnceCell<Vec<Role>>,
23}
24
25impl ChatbotTurnContext {
26    /// Collects what a chatbot request needs to know about its caller and about what the
27    /// chatbot's configuration offers.
28    pub fn new(
29        user_id: Option<Uuid>,
30        course_id: Option<Uuid>,
31        course_name: Option<String>,
32        conversation_id: Uuid,
33        configuration: &ChatbotConfiguration,
34    ) -> Self {
35        Self {
36            user_id,
37            course_id,
38            course_name,
39            conversation_id: Some(conversation_id),
40            enabled_tool_categories: EnabledToolCategories::from_configuration(configuration),
41            roles: OnceCell::new(),
42        }
43    }
44
45    /// The resource an offer-time check runs against, before any call has named a target: the
46    /// course this chatbot belongs to, or global permissions for a chatbot that belongs to none.
47    pub fn turn_resource(&self) -> Resource {
48        match self.course_id {
49            Some(course_id) => Resource::Course(course_id),
50            None => Resource::GlobalPermissions,
51        }
52    }
53
54    /// The caller's roles, fetched the first time an authorization check needs them.
55    ///
56    /// One roles query per request however many authorization checks ask for them, and none at
57    /// all for a request whose tools need no role.
58    pub(crate) async fn roles(&self, conn: &mut PgConnection) -> ChatbotResult<&[Role]> {
59        let roles = self
60            .roles
61            .get_or_try_init(|| async {
62                fetch_user_roles(conn, self.user_id)
63                    .await
64                    .map_err(ChatbotError::from)
65            })
66            .await?;
67        Ok(roles)
68    }
69
70    #[cfg(test)]
71    pub(crate) fn with_roles(
72        user_id: Option<Uuid>,
73        course_id: Option<Uuid>,
74        course_name: Option<String>,
75        roles: Vec<Role>,
76    ) -> Self {
77        Self::with_roles_and_categories(
78            user_id,
79            course_id,
80            course_name,
81            roles,
82            EnabledToolCategories::all(),
83        )
84    }
85
86    /// Like [Self::with_roles], but with a specific enabled-category set instead of everything
87    /// enabled — for tests of the category gate itself rather than of authorization.
88    #[cfg(test)]
89    pub(crate) fn with_roles_and_categories(
90        user_id: Option<Uuid>,
91        course_id: Option<Uuid>,
92        course_name: Option<String>,
93        roles: Vec<Role>,
94        enabled_tool_categories: EnabledToolCategories,
95    ) -> Self {
96        Self {
97            user_id,
98            course_id,
99            course_name,
100            conversation_id: None,
101            enabled_tool_categories,
102            roles: OnceCell::new_with(Some(roles)),
103        }
104    }
105}