use crate::Role;
use crate::Workspace;
use crate::WorkspaceStatus;
use crate::db::{self, TxGuard};
use crate::git::commands::run_git_output;
use crate::util::UnwrapPoison;
use anyhow::{Context as _, Result};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{LazyLock, Mutex};
use tracing::{error, warn};
pub(crate) const TELEGRAM_UNKNOWN_SENTINEL: &str = "unknown";
const TELEGRAM_SERVICE_IDS: [&str; 3] = ["1087968824", "136817688", "777000"];
const TELEGRAM_SERVICE_NICKNAMES: [&str; 2] = ["groupanonymousbot", "channel_bot"];
pub(crate) const ADMIN_USER_NAME: &str = "admin";
enum TelegramKey {
Number(String),
Nickname(String),
}
fn telegram_digits(value: &str) -> Option<&str> {
let digits = value.trim();
let digits = digits.strip_prefix('+').unwrap_or(digits);
(!digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())).then_some(digits)
}
fn telegram_key(value: &str) -> Option<TelegramKey> {
let Some(digits) = telegram_digits(value) else {
return Some(TelegramKey::Nickname(value.to_string()));
};
let canonical = digits.trim_start_matches('0');
(!canonical.is_empty()).then(|| TelegramKey::Number(canonical.to_string()))
}
fn canonical_telegram_number(value: &str) -> Option<String> {
match telegram_key(value) {
Some(TelegramKey::Number(number)) => Some(number),
_ => None,
}
}
fn normalize_telegram_binding(value: &str) -> anyhow::Result<String> {
let trimmed = value.trim();
let trimmed = trimmed.strip_prefix('@').unwrap_or(trimmed).trim();
match telegram_key(trimmed) {
Some(TelegramKey::Number(id)) => {
if is_telegram_service_id(&id) {
anyhow::bail!("'{id}' is a Telegram service identity and cannot be bound");
}
Ok(id)
}
Some(TelegramKey::Nickname(nickname)) => {
if nickname.is_empty() {
anyhow::bail!("Telegram binding is empty");
}
if nickname == TELEGRAM_UNKNOWN_SENTINEL {
anyhow::bail!("'unknown' is a reserved Telegram nickname and cannot be bound");
}
if is_telegram_service_nickname(&nickname) {
anyhow::bail!("'@{nickname}' is a Telegram service identity and cannot be bound");
}
Ok(nickname)
}
None => anyhow::bail!("a Telegram id of nothing but zeros is not a person's number"),
}
}
pub(crate) fn is_telegram_service_id(id: &str) -> bool {
TELEGRAM_SERVICE_IDS.contains(&id)
}
fn is_telegram_service_nickname(nickname: &str) -> bool {
let lowercase = nickname.to_ascii_lowercase();
TELEGRAM_SERVICE_NICKNAMES.contains(&lowercase.as_str())
}
fn telegram_identifier_is_number(identifier: &str) -> bool {
telegram_digits(identifier).is_some()
}
pub(crate) fn describe_telegram_binding(identifier: &str) -> String {
if telegram_identifier_is_number(identifier) {
format!("id {}", identifier.trim())
} else {
format!("@{identifier}")
}
}
pub(crate) fn settings_binding_label(identifier: &str) -> String {
if telegram_identifier_is_number(identifier) {
describe_telegram_binding(identifier)
} else {
identifier.to_string()
}
}
crate::define_store! {
pub static USER_STORE: UserStore,
post_open = ensure_admin_user,
expect = "USER_STORE not initialized — call init_all_stores() first",
}
crate::columns! {
USERS_COLUMNS [USERS] {
NAME => "name",
SELECTED_WORKSPACE => "selected_workspace",
GRANTED_TOOLS => "granted_tools",
}
}
crate::columns! {
USER_CHANNEL_COLUMNS [UC] {
CHANNEL => "channel",
IDENTIFIER => "identifier",
REPLY_TARGET => "reply_target",
}
}
impl UserStore {
pub(crate) async fn ensure_admin_user(&self) -> Result<()> {
if !self.user_exists(ADMIN_USER_NAME).await? {
self.add_user(ADMIN_USER_NAME).await?;
}
Ok(())
}
pub async fn add_user(&self, name: &str) -> Result<()> {
if !self.user_exists(name).await? {
validate_folder_name(name)?;
}
self.conn
.execute(
"INSERT OR IGNORE INTO users (name) VALUES (?1)",
db::params![name],
)
.await?;
if let Err(e) = ensure_personal_workspace(name).await {
self.report_personal_workspace_failure(name, &e).await;
}
Ok(())
}
pub(crate) async fn report_personal_workspace_failure(
&self,
name: &str,
error: &anyhow::Error,
) {
if claim_report(name, error, "log") {
error!(
user_name = %name,
path = %personal_workspace_path(name).display(),
error = %error,
"Personal workspace folder could not be created"
);
}
if crate::CHANNEL_REGISTRY
.get()
.and_then(|registry| registry.get("telegram"))
.is_none()
{
return;
}
let mut targets =
telegram_reply_targets(self.get_user_channels(name).await.unwrap_or_default());
if targets.is_empty() && name != ADMIN_USER_NAME {
targets = telegram_reply_targets(
self.get_user_channels(ADMIN_USER_NAME)
.await
.unwrap_or_default(),
);
}
if targets.is_empty() || !claim_report(name, error, "owner") {
return;
}
let message = owner_message(name, error);
for target in targets {
crate::channels::telegram::send_reply(&target, &message).await;
}
}
pub async fn delete_user(&self, name: &str) -> Result<()> {
let tx = self.conn.begin_tx().await?;
tx.execute(
"DELETE FROM user_channels WHERE user_name = ?1",
db::params![name],
)
.await?;
tx.execute("DELETE FROM users WHERE name = ?1", db::params![name])
.await?;
tx.commit().await?;
Ok(())
}
async fn user_column(&self, column: &str, user_name: &str) -> Result<Option<String>> {
self.conn
.query_optional(
&format!("SELECT {column} FROM users WHERE name = ?1"),
db::params![user_name],
|row| row.get::<Option<String>>(0),
)
.await
.map(Option::flatten)
}
async fn get_selected_workspace_name(&self, user_name: &str) -> Result<Option<String>> {
self.user_column("selected_workspace", user_name).await
}
pub async fn user_exists(&self, name: &str) -> Result<bool> {
let rows = self
.conn
.query("SELECT 1 FROM users WHERE name = ?1", db::params![name])
.await?;
Ok(!rows.is_empty())
}
pub async fn bind_channel(
&self,
user_name: &str,
channel: &str,
identifier: &str,
) -> Result<()> {
self.conn
.execute(
"INSERT OR REPLACE INTO user_channels (user_name, channel, identifier) \
VALUES (?1, ?2, ?3)",
db::params![user_name, channel, identifier],
)
.await?;
Ok(())
}
pub async fn validate_telegram_bind(&self, user_name: &str, value: &str) -> Result<String> {
let identifier = normalize_telegram_binding(value)?;
if let Some(existing) = self.telegram_binding(user_name).await? {
anyhow::bail!(
"'{user_name}' already has a Telegram binding ({}) — remove it on the \
Settings → Users page first, then attach the new one",
describe_telegram_binding(&existing)
);
}
if let Some(owner) = self.telegram_binding_owner(&identifier).await? {
anyhow::bail!(
"{} is already bound to user '{owner}'",
describe_telegram_binding(&identifier)
);
}
Ok(identifier)
}
pub async fn bind_telegram(&self, user_name: &str, value: &str) -> Result<String> {
let identifier = self.validate_telegram_bind(user_name, value).await?;
self.attach_telegram_binding(user_name, &identifier).await?;
Ok(identifier)
}
pub async fn bind_telegram_for_admin(&self, value: &str) -> Result<String> {
let identifier = self.bind_telegram(ADMIN_USER_NAME, value).await?;
if !telegram_identifier_is_number(&identifier) {
self.update_channel_contact("telegram", &identifier, &identifier)
.await?;
}
Ok(identifier)
}
pub async fn attach_telegram_binding(&self, user_name: &str, identifier: &str) -> Result<()> {
self.bind_channel(user_name, "telegram", identifier).await?;
if telegram_identifier_is_number(identifier) {
self.update_channel_contact("telegram", identifier, identifier)
.await?;
}
Ok(())
}
pub(crate) async fn telegram_binding(&self, user_name: &str) -> Result<Option<String>> {
Ok(self
.get_user_channels(user_name)
.await?
.into_iter()
.find(|c| c.channel == "telegram")
.map(|c| c.identifier))
}
async fn telegram_binding_owner(&self, identifier: &str) -> Result<Option<String>> {
let matched = match telegram_key(identifier) {
Some(TelegramKey::Number(number)) => {
self.resolve_telegram_user(Some(&number), None).await?
}
_ => self.resolve_telegram_user(None, Some(identifier)).await?,
};
Ok(matched.map(|matched| matched.user_name))
}
pub async fn resolve_telegram_user(
&self,
numeric_id: Option<&str>,
nickname: Option<&str>,
) -> Result<Option<TelegramMatch>> {
let numeric_id = numeric_id.and_then(canonical_telegram_number);
let rows = self
.conn
.query(
"SELECT user_name, identifier FROM user_channels WHERE channel = 'telegram'",
db::params![],
)
.await?;
let mut nickname_match = None;
for row in &rows {
let user_name: String = row.get(0)?;
let identifier: String = row.get(1)?;
match telegram_key(&identifier) {
Some(TelegramKey::Number(number)) if !is_telegram_service_id(&number) => {
if numeric_id.as_deref() == Some(number.as_str()) {
return Ok(Some(TelegramMatch {
user_name,
identifier,
}));
}
}
Some(TelegramKey::Nickname(nick))
if nick != TELEGRAM_UNKNOWN_SENTINEL
&& nickname_match.is_none()
&& nickname == Some(nick.as_str()) =>
{
nickname_match = Some(TelegramMatch {
user_name,
identifier,
});
}
_ => {}
}
}
Ok(nickname_match)
}
pub async fn unbind_channel(
&self,
user_name: &str,
channel: &str,
identifier: &str,
) -> Result<()> {
self.conn
.execute(
"DELETE FROM user_channels WHERE user_name = ?1 AND channel = ?2 AND identifier = ?3",
db::params![user_name, channel, identifier],
)
.await?;
Ok(())
}
pub async fn update_channel_contact(
&self,
channel: &str,
identifier: &str,
reply_target: &str,
) -> Result<()> {
self.conn
.execute(
"UPDATE user_channels SET reply_target = ?1 \
WHERE channel = ?2 AND identifier = ?3",
db::params![reply_target, channel, identifier],
)
.await?;
Ok(())
}
pub async fn get_user_channels(&self, user_name: &str) -> Result<Vec<ChannelBinding>> {
self.conn
.query_map_strict(
&format!("SELECT {USER_CHANNEL_COLUMNS} FROM user_channels WHERE user_name = ?1"),
db::params![user_name],
|row| {
Ok::<_, ::turso::Error>(ChannelBinding {
channel: row.get::<String>(COL_UC_CHANNEL)?,
identifier: row.get::<String>(COL_UC_IDENTIFIER)?,
reply_target: row.get::<Option<String>>(COL_UC_REPLY_TARGET)?,
})
},
)
.await
}
async fn user_entry_from_row(&self, row: &db::Row) -> Result<UserRecordEntry> {
let name: String = row.get(COL_USERS_NAME)?;
let (channels, channels_error) = match self.get_user_channels(&name).await {
Ok(channels) => (channels, None),
Err(e) => (Vec::new(), Some(e.to_string())),
};
Ok(UserRecordEntry {
record: UserRecord {
name,
selected_workspace: row.get::<Option<String>>(COL_USERS_SELECTED_WORKSPACE)?,
granted_tools: parse_grants(row.get::<Option<String>>(COL_USERS_GRANTED_TOOLS)?),
channels,
},
channels_error,
})
}
async fn list_users_where(
&self,
suffix: &str,
params: impl db::IntoParams + Send + 'static,
) -> Result<Vec<UserRecordEntry>> {
let sql = format!("SELECT {USERS_COLUMNS} FROM users {suffix}");
let rows = self.conn.query(&sql, params).await?;
let mut users = Vec::with_capacity(rows.len());
for row in rows {
users.push(self.user_entry_from_row(&row).await?);
}
Ok(users)
}
pub async fn find_by_name(&self, user_name: &str) -> Result<Option<UserRecord>> {
self.list_users_where("WHERE name = ?1", db::params![user_name])
.await
.map(|users| users.into_iter().next().map(|entry| entry.record))
}
pub async fn list_users(&self) -> Result<Vec<UserRecordEntry>> {
self.list_users_where("", db::params![]).await
}
pub(crate) async fn set_selected_workspace(
&self,
name: &str,
workspace_name: Option<&str>,
) -> Result<()> {
let tx = self.conn.begin_tx().await?;
upsert_user_column(&tx, name, "selected_workspace", workspace_name).await?;
tx.commit().await?;
Ok(())
}
pub async fn set_image_gen_model(&self, name: &str, model: &str) -> Result<()> {
let tx = self.conn.begin_tx().await?;
upsert_user_column(&tx, name, "image_gen_model", Some(model)).await?;
tx.commit().await?;
Ok(())
}
pub async fn set_video_model(&self, name: &str, model: &str) -> Result<()> {
let tx = self.conn.begin_tx().await?;
upsert_user_column(&tx, name, "video_model", Some(model)).await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn get_grants(&self, user_name: &str) -> Result<Vec<String>> {
Ok(parse_grants(
self.user_column("granted_tools", user_name).await?,
))
}
pub(crate) async fn add_grant(&self, user_name: &str, tool: &str) -> Result<GrantChange> {
self.update_grants(user_name, |grants| {
if grants.iter().any(|g| g == tool) {
return false;
}
grants.push(tool.to_string());
true
})
.await
}
pub(crate) async fn remove_grant(&self, user_name: &str, tool: &str) -> Result<GrantChange> {
self.update_grants(user_name, |grants| {
let before = grants.len();
grants.retain(|g| g != tool);
grants.len() != before
})
.await
}
pub(crate) async fn list_grants(&self) -> Result<Vec<(String, Vec<String>)>> {
let rows = self
.conn
.query_map_strict(
"SELECT name, granted_tools FROM users ORDER BY name",
db::params![],
|row| -> Result<(String, Vec<String>)> {
Ok((
row.get::<String>(0)?,
parse_grants(row.get::<Option<String>>(1)?),
))
},
)
.await?;
Ok(rows
.into_iter()
.filter(|(_, grants)| !grants.is_empty())
.collect())
}
async fn update_grants(
&self,
user_name: &str,
mutate: impl FnOnce(&mut Vec<String>) -> bool,
) -> Result<GrantChange> {
let tx = self.conn.begin_tx().await?;
let rows = tx
.query(
"SELECT granted_tools FROM users WHERE name = ?1",
db::params![user_name],
)
.await?;
let Some(row) = rows.first() else {
tx.rollback().await?;
return Ok(GrantChange::NoUser);
};
let mut grants = parse_grants(row.get::<Option<String>>(0)?);
if !mutate(&mut grants) {
tx.rollback().await?;
return Ok(GrantChange::Unchanged);
}
grants.sort();
tx.execute(
"UPDATE users SET granted_tools = ?1 WHERE name = ?2",
db::params![serde_json::to_string(&grants)?, user_name],
)
.await?;
tx.commit().await?;
Ok(GrantChange::Changed)
}
}
fn owner_message(name: &str, error: &anyhow::Error) -> String {
let cause = if let Some(problem) = crate::util::folder_name::folder_name_problem(name) {
format!(
"the name cannot be a folder name on every platform the service runs on ({problem}), so \
this account cannot keep a workspace under it — remove the account and add it again \
under a name the service accepts"
)
} else {
format!(
"the storage location {} cannot be written ({error}) — check permissions and free \
space, then try again",
personal_workspace_path(name).display()
)
};
format!(
"⚠️ The personal workspace folder for the account '{name}' could not be created, so nothing \
that account stores in its workspace is kept: {cause}."
)
}
static REPORTED_WORKSPACE_FAILURES: LazyLock<Mutex<HashSet<String>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
fn claim_report(name: &str, error: &anyhow::Error, medium: &str) -> bool {
REPORTED_WORKSPACE_FAILURES
.lock()
.unwrap_poison()
.insert(format!("{medium}\u{0}{name}\u{0}{error}"))
}
fn telegram_reply_targets(bindings: Vec<ChannelBinding>) -> Vec<String> {
bindings
.into_iter()
.filter(|b| b.channel == "telegram")
.filter_map(|b| b.reply_target)
.collect()
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum GrantChange {
NoUser,
Unchanged,
Changed,
}
fn parse_grants(raw: Option<String>) -> Vec<String> {
let mut grants = raw
.and_then(|raw| serde_json::from_str::<Vec<String>>(&raw).ok())
.unwrap_or_default();
grants.sort();
grants.dedup();
grants
}
async fn upsert_user_column(
tx: &TxGuard<'_>,
name: &str,
field: &str,
value: Option<&str>,
) -> Result<()> {
tx.upsert_row(
&format!("UPDATE users SET {field} = ?1 WHERE name = ?2"),
|| db::params![value, name],
&format!(
"INSERT INTO users (name, {field}) VALUES (?1, ?2) \
ON CONFLICT(name) DO NOTHING"
),
db::params![name, value],
)
.await?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct UserRecord {
pub name: String,
pub selected_workspace: Option<String>,
pub granted_tools: Vec<String>,
pub channels: Vec<ChannelBinding>,
}
#[derive(Debug, Clone)]
pub struct UserRecordEntry {
pub record: UserRecord,
pub channels_error: Option<String>,
}
#[must_use]
pub(crate) fn is_admin_name(name: &str) -> bool {
name == ADMIN_USER_NAME
}
pub(crate) fn refuse_admin_name(name: &str) -> Result<()> {
if is_admin_name(name) {
anyhow::bail!(
"'{ADMIN_USER_NAME}' is the admin account — only guest accounts can be created"
);
}
Ok(())
}
fn validate_folder_name(name: &str) -> Result<()> {
let Some(problem) = crate::util::folder_name::folder_name_problem(name) else {
return Ok(());
};
anyhow::bail!(
"'{name}' cannot be a user name because {problem}. The account name is used verbatim \
as the name of its personal workspace folder, so it must be creatable on every \
platform MahBot runs on — pick another name."
);
}
pub(crate) fn validate_new_user_name(name: &str) -> Result<()> {
refuse_admin_name(name)?;
validate_folder_name(name)
}
#[derive(Debug, Clone)]
pub struct ChannelBinding {
pub channel: String,
pub identifier: String,
pub reply_target: Option<String>,
}
#[derive(Debug, Clone)]
pub struct TelegramMatch {
pub user_name: String,
pub identifier: String,
}
#[must_use]
pub(crate) fn userspaces_root() -> PathBuf {
let storage_root = crate::config::CONFIG
.try_storage_root()
.unwrap_or_else(fallback_storage_root);
storage_root.join("userspaces")
}
#[cfg(test)]
fn fallback_storage_root() -> PathBuf {
crate::util::test::test_root().clone()
}
#[cfg(not(test))]
fn fallback_storage_root() -> PathBuf {
crate::config::default_config_dir()
.unwrap_or_else(|_| std::env::temp_dir().join("mahbot_userspaces"))
}
#[must_use]
pub(crate) fn is_valid_personal_user_name(user_name: &str) -> bool {
!user_name.trim().is_empty()
&& !user_name.contains(['/', '\\'])
&& !matches!(user_name, "." | "..")
}
#[must_use]
pub fn personal_workspace_path(user_name: &str) -> PathBuf {
userspaces_root().join(user_name)
}
const PERSONAL_WORKSPACE_PREFIX: &str = "personal:";
#[must_use]
pub fn personal_workspace_name(user_name: &str) -> String {
format!("{PERSONAL_WORKSPACE_PREFIX}{user_name}")
}
pub(crate) async fn ensure_personal_workspace(name: &str) -> Result<()> {
if !is_valid_personal_user_name(name) {
anyhow::bail!("'{name}' is not a usable personal-workspace name");
}
let path = personal_workspace_path(name);
if !path.is_dir() {
validate_folder_name(name)?;
tokio::fs::create_dir_all(&path)
.await
.with_context(|| format!("create the folder {}", path.display()))?;
}
if path.join(".git").exists() {
return Ok(());
}
match run_git_output(&path, &["init", "-q"]).await {
Ok(o) if o.status.success() => {}
Ok(_) => warn!(
path = %path.display(),
"git init failed for personal workspace (git may not be installed)"
),
Err(e) => warn!(
path = %path.display(),
error = %e,
"git init failed for personal workspace"
),
}
Ok(())
}
pub async fn get_raw_selected_workspace(user_name: &str) -> Result<Option<String>> {
store().get_selected_workspace_name(user_name).await
}
pub async fn resolve_image_gen_model(user_name: &str) -> String {
resolve_user_model_column(user_name, "image_gen_model")
.await
.unwrap_or_else(|| crate::config::DEFAULT_IMAGE_GEN_MODEL.to_string())
}
pub async fn resolve_video_model(user_name: &str) -> String {
resolve_user_model_column(user_name, "video_model")
.await
.unwrap_or_else(|| crate::config::DEFAULT_VIDEO_MODEL.to_string())
}
async fn resolve_user_model_column(user_name: &str, column: &str) -> Option<String> {
match store().user_column(column, user_name).await {
Ok(value) => value
.map(|m| m.trim().to_string())
.filter(|m| !m.is_empty()),
Err(e) => {
tracing::warn!(user_name, column, error = %e, "user model lookup failed; using default");
None
}
}
}
pub(crate) async fn resolve_selected_workspace_name(user_name: &str) -> Option<String> {
let stored = match store().get_selected_workspace_name(user_name).await {
Ok(stored) => stored,
Err(e) => {
warn!(user_name = %user_name, error = %e, "Failed to read selected workspace");
return None;
}
};
if is_admin_name(user_name) {
return Some(match stored {
Some(ws) if !is_personal_workspace(&ws) => ws,
_ => personal_workspace_name(user_name),
});
}
if let Some(ws) = stored
&& !is_personal_workspace(&ws)
{
warn!(
user_name = %user_name,
workspace = %ws,
"guest has a shared selected_workspace — clamping to their personal workspace"
);
}
Some(personal_workspace_name(user_name))
}
pub async fn registered_workspaces() -> Result<Vec<Workspace>> {
Ok(crate::workspace::store()
.list()
.await?
.into_iter()
.filter(|ws| !is_personal_workspace(&ws.name))
.collect())
}
#[must_use]
pub const fn switcher_exists(shared_count: usize) -> bool {
shared_count >= 2
}
#[must_use]
pub async fn workspace_switcher_available() -> bool {
registered_workspaces()
.await
.is_ok_and(|workspaces| switcher_exists(workspaces.len()))
}
pub async fn set_active_workspace(user_name: &str, workspace_name: &str) -> Result<Workspace> {
if !is_admin(user_name).await {
anyhow::bail!("Only the admin can change the active workspace.");
}
if workspace_name.trim().is_empty() {
anyhow::bail!("No workspace was named.");
}
if is_personal_workspace(workspace_name) {
anyhow::bail!("A personal workspace can never be the active workspace.");
}
let ws = crate::workspace::get_by_name(workspace_name)
.await?
.ok_or_else(|| anyhow::anyhow!("Workspace '{workspace_name}' no longer exists."))?;
store()
.set_selected_workspace(user_name, Some(workspace_name))
.await?;
Ok(ws)
}
async fn get_workspace(user_name: &str) -> Result<Option<Workspace>> {
match resolve_selected_workspace_name(user_name).await {
Some(ws_name) => resolve_workspace(&ws_name).await,
None => Ok(Some(personal_workspace_struct(user_name))),
}
}
pub async fn resolve_workspace(workspace_name: &str) -> Result<Option<Workspace>> {
if let Some(ws) = crate::workspace::get_by_name(workspace_name).await? {
if is_personal_workspace(&ws.name) {
warn!(
workspace_name = %ws.name,
"Shared workspace name shadows the personal workspace key 'personal:{{user}}'"
);
}
Ok(Some(ws))
} else if is_personal_workspace(workspace_name) {
let user_name = personal_user_name(workspace_name)
.expect("invariant: is_personal_workspace checked the prefix");
Ok(Some(personal_workspace_struct(user_name)))
} else {
Ok(None)
}
}
#[must_use]
pub(crate) fn personal_workspace_struct(user_name: &str) -> Workspace {
let mut ws = Workspace::from_path(&personal_workspace_path(user_name));
ws.name = personal_workspace_name(user_name);
ws.status = WorkspaceStatus::Ready;
ws
}
pub async fn resolve_workspace_for_user_name(user_name: &str) -> Workspace {
match get_workspace(user_name).await {
Ok(Some(ws)) => ws,
Ok(None) => {
warn!(
user_name = %user_name,
"workspace resolution: selected_workspace points to non-existent workspace; \
falling back to personal workspace",
);
personal_workspace_struct(user_name)
}
Err(e) => {
warn!(
user_name = %user_name,
error = %e,
"workspace resolution: database error; falling back to personal workspace",
);
personal_workspace_struct(user_name)
}
}
}
#[must_use]
fn is_pinned_role(role: Role) -> bool {
matches!(role, Role::Assistant)
}
#[must_use]
pub(crate) fn enforce_personal_pinning(
role: Role,
workspace_name: &str,
user_name: &str,
) -> Option<String> {
if !is_pinned_role(role) {
return Some(workspace_name.to_string());
}
if user_name.is_empty() {
return None;
}
Some(personal_workspace_name(user_name))
}
#[must_use]
pub fn effective_workspace_for_role(role: Role, ws: Workspace, user_name: &str) -> Workspace {
if !is_pinned_role(role) {
return ws;
}
if is_personal_workspace(&ws.name) {
return ws;
}
if user_name.is_empty() {
tracing::error!(
role = %role.as_str(),
workspace = %ws.name,
"Personal-workspace pin bypassed: pinned role with empty user_name — caller must pass a resolvable user"
);
return ws;
}
personal_workspace_struct(user_name)
}
pub async fn resolve_session_target(user_name: &str) -> (Role, Workspace) {
let ws = resolve_workspace_for_user_name(user_name).await;
(
Role::Assistant,
effective_workspace_for_role(Role::Assistant, ws, user_name),
)
}
pub async fn resolve_user_by_reply_target(channel: &str, target: &str) -> Option<String> {
let store = USER_STORE.get()?;
store
.conn
.query(
"SELECT user_name, reply_target FROM user_channels WHERE channel = ?1",
db::params![channel],
)
.await
.and_then(|rows| {
let thread_prefix = format!("{target}:");
for row in rows {
let user_name: String = row.get(0)?;
let reply_target: Option<String> = row.get(1)?;
if let Some(t) = reply_target
&& (t == target || t.starts_with(&thread_prefix))
{
return Ok(Some(user_name));
}
}
Ok(None)
})
.unwrap_or_else(|e| {
tracing::warn!(error = %e, ?channel, ?target, "Failed to resolve user by reply target");
None
})
}
async fn is_admin_in(store: Option<&UserStore>, user_name: &str) -> bool {
if !is_admin_name(user_name) {
return false;
}
let Some(store) = store else {
return false;
};
store.user_exists(user_name).await.unwrap_or_else(|e| {
warn!(error = %e, user_name, "Failed to read the admin account");
false
})
}
pub async fn is_admin(user_name: &str) -> bool {
is_admin_in(USER_STORE.get(), user_name).await
}
pub(crate) async fn granted_tools(user_name: &str) -> Vec<String> {
match USER_STORE.get() {
Some(store) => match store.get_grants(user_name).await {
Ok(grants) => grants,
Err(e) => {
tracing::warn!(error = %e, user_name, "Failed to read granted custom tools");
Vec::new()
}
},
None => Vec::new(),
}
}
#[must_use]
pub(crate) fn format_grants(grants: &[String]) -> String {
if grants.is_empty() {
"none".to_string()
} else {
grants.join(", ")
}
}
pub async fn update_channel_contact(
channel: &str,
identifier: &str,
reply_target: &str,
) -> Result<()> {
store()
.update_channel_contact(channel, identifier, reply_target)
.await
}
#[must_use]
pub fn personal_user_name(workspace_name: &str) -> Option<&str> {
workspace_name.strip_prefix(PERSONAL_WORKSPACE_PREFIX)
}
#[must_use]
pub fn is_personal_workspace(workspace_name: &str) -> bool {
personal_user_name(workspace_name).is_some()
}
#[cfg(test)]
pub(crate) mod test_util {
use super::*;
pub(crate) async fn init_test_store() {
crate::util::test::init_test_stores().await;
if let Some(store) = USER_STORE.get() {
store
.add_user("alice")
.await
.expect("failed to add alice to test USER_STORE");
store
.add_user("bob")
.await
.expect("failed to add bob to test USER_STORE");
store
.bind_channel("alice", "telegram", "alice")
.await
.expect("failed to bind alice telegram");
store
.bind_channel("bob", "telegram", "bob")
.await
.expect("failed to bind bob telegram");
}
}
pub(crate) async fn with_admin_workspace_restored<F>(body: F)
where
F: std::future::Future<Output = ()>,
{
use futures_util::FutureExt as _;
let store = store();
let previous = get_raw_selected_workspace(ADMIN_USER_NAME)
.await
.expect("read admin selected_workspace");
let outcome = std::panic::AssertUnwindSafe(body).catch_unwind().await;
store
.set_selected_workspace(ADMIN_USER_NAME, previous.as_deref())
.await
.expect("restore admin selected_workspace");
if let Err(panic) = outcome {
std::panic::resume_unwind(panic);
}
}
pub(crate) async fn with_test_workspaces<F>(workspaces: &[(&str, &str)], body: F)
where
F: std::future::Future<Output = ()>,
{
use futures_util::FutureExt as _;
let outcome = std::panic::AssertUnwindSafe(async {
for (path, name) in workspaces {
crate::util::test::create_test_workspace(path, name).await;
}
body.await;
})
.catch_unwind()
.await;
for (_, name) in workspaces {
crate::workspace::store()
.delete(name)
.await
.expect("delete test workspace");
}
if let Err(panic) = outcome {
std::panic::resume_unwind(panic);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn admin_test_is_the_account_name_backed_by_its_row() {
let (store, _dir) = crate::open_test_store!(UserStore, "user");
assert!(
is_admin_in(Some(&store), ADMIN_USER_NAME).await,
"the seeded admin account is the admin"
);
store.add_user("guest").await.unwrap();
assert!(
!is_admin_in(Some(&store), "guest").await,
"a guest account is not the admin"
);
store.delete_user(ADMIN_USER_NAME).await.unwrap();
assert!(
!is_admin_in(Some(&store), ADMIN_USER_NAME).await,
"the admin's name without an account row is not the admin"
);
assert!(
!is_admin_in(None, ADMIN_USER_NAME).await,
"a missing account store is not the admin"
);
}
#[tokio::test]
async fn add_user_refuses_a_name_that_cannot_be_a_folder() {
crate::util::test::init_test_stores().await;
let store = store();
for name in ["../evil", "a/b", "/tmp/evil", "nul", "trailing."] {
let err = store.add_user(name).await.unwrap_err();
assert!(
err.to_string().contains(name),
"the refusal must name the offending name: {err}"
);
assert!(
!store.user_exists(name).await.unwrap(),
"a refused name must not leave an account row"
);
}
assert!(
!userspaces_root().join("../evil").exists(),
"a parent reference must not escape the userspaces root"
);
}
#[tokio::test]
async fn add_user_keeps_a_stored_name_the_folder_rule_would_refuse() {
crate::util::test::init_test_stores().await;
let store = store();
let legacy = "legacy:name";
store
.conn
.execute(
"INSERT INTO users (name) VALUES (?1)",
crate::db::params![legacy],
)
.await
.unwrap();
store.add_user(legacy).await.unwrap();
assert!(
!personal_workspace_path(legacy).exists(),
"completing a stored account must not create the folder the rule refuses"
);
}
#[tokio::test]
async fn delete_user_removes_channel_rows() {
crate::util::test::init_test_stores().await;
let store = store();
store.add_user("doomed").await.unwrap();
store.delete_user("doomed").await.unwrap();
assert!(
store
.conn
.query(
"SELECT 1 FROM user_channels WHERE user_name = 'doomed'",
crate::db::params![],
)
.await
.unwrap()
.is_empty(),
"user_channels rows must be deleted with the user"
);
assert!(
store.find_by_name("doomed").await.unwrap().is_none(),
"the user row must be deleted"
);
}
#[tokio::test]
async fn grant_storage_round_trip() {
crate::util::test::init_test_stores().await;
let store = store();
let user = "grant_round_trip";
store.add_user(user).await.unwrap();
assert!(
store.get_grants(user).await.unwrap().is_empty(),
"a fresh user has no grants"
);
store.add_grant(user, "zeta").await.unwrap();
store.add_grant(user, "alpha").await.unwrap();
let stored: Option<String> = store
.conn
.query_row(
"SELECT granted_tools FROM users WHERE name = ?1",
db::params![user],
|row| row.get(0),
)
.await
.unwrap();
assert_eq!(stored.as_deref(), Some(r#"["alpha","zeta"]"#));
assert_eq!(
store.add_grant(user, "alpha").await.unwrap(),
GrantChange::Unchanged,
"re-granting a tool the user already holds changes nothing"
);
assert_eq!(
store.get_grants(user).await.unwrap(),
vec!["alpha".to_string(), "zeta".to_string()]
);
assert_eq!(
store
.find_by_name(user)
.await
.unwrap()
.unwrap()
.granted_tools,
vec!["alpha".to_string(), "zeta".to_string()]
);
assert_eq!(
store.remove_grant(user, "absent").await.unwrap(),
GrantChange::Unchanged,
"revoking a grant the user does not hold changes nothing"
);
assert_eq!(
store
.remove_grant("grant_no_such_user", "alpha")
.await
.unwrap(),
GrantChange::NoUser,
"revoking from a missing user reports that no row exists"
);
assert_eq!(
store.get_grants(user).await.unwrap(),
vec!["alpha".to_string(), "zeta".to_string()]
);
assert_eq!(
store.add_grant("grant_ghost_user", "alpha").await.unwrap(),
GrantChange::NoUser,
"granting to an unknown user must report that no row exists"
);
assert!(
store
.find_by_name("grant_ghost_user")
.await
.unwrap()
.is_none(),
"a failed grant must not leave a ghost user row"
);
let listed = store.list_grants().await.unwrap();
assert!(
listed.contains(&(
user.to_string(),
vec!["alpha".to_string(), "zeta".to_string()]
)),
"got: {listed:?}"
);
assert!(
listed.iter().all(|(_, grants)| !grants.is_empty()),
"a user with no grants must not be listed: {listed:?}"
);
}
#[test]
fn normalize_telegram_binding_rules() {
assert_eq!(normalize_telegram_binding("alice").unwrap(), "alice");
assert_eq!(normalize_telegram_binding(" alice ").unwrap(), "alice");
assert_eq!(normalize_telegram_binding("@alice").unwrap(), "alice");
assert_eq!(normalize_telegram_binding(" @ alice ").unwrap(), "alice");
assert!(normalize_telegram_binding("unknown").is_err());
assert!(normalize_telegram_binding(" ").is_err());
assert!(normalize_telegram_binding("@").is_err());
assert_eq!(normalize_telegram_binding("Unknown").unwrap(), "Unknown");
assert_eq!(
normalize_telegram_binding("123456789").unwrap(),
"123456789"
);
assert_eq!(normalize_telegram_binding(" +00123 ").unwrap(), "123");
assert_eq!(normalize_telegram_binding("@123").unwrap(), "123");
assert_eq!(normalize_telegram_binding("alice123").unwrap(), "alice123");
assert!(normalize_telegram_binding("0").is_err());
assert!(normalize_telegram_binding("000").is_err());
assert!(normalize_telegram_binding("+000").is_err());
assert!(normalize_telegram_binding("777000").is_err());
assert!(normalize_telegram_binding("1087968824").is_err());
assert!(normalize_telegram_binding("136817688").is_err());
assert!(normalize_telegram_binding("@GroupAnonymousBot").is_err());
assert!(normalize_telegram_binding("channel_bot").is_err());
}
#[tokio::test]
async fn validate_telegram_bind_guards() {
crate::util::test::init_test_stores().await;
let store = store();
store.add_user("bind_guard_owner").await.unwrap();
store
.bind_channel("bind_guard_owner", "telegram", "guard_handle")
.await
.unwrap();
let err = store
.validate_telegram_bind("bind_guard_other", "guard_handle")
.await
.unwrap_err();
assert!(
err.to_string().contains("bind_guard_owner"),
"error must name the current owner: {err}"
);
let err = store
.validate_telegram_bind("bind_guard_owner", "@guard_handle")
.await
.unwrap_err();
assert!(
err.to_string().contains("@guard_handle") && err.to_string().contains("remove"),
"error must name the existing binding and how to free the account: {err}"
);
let err = store
.validate_telegram_bind("bind_guard_owner", "unknown")
.await
.unwrap_err();
assert!(
err.to_string().contains("reserved"),
"error must mention 'reserved': {err}"
);
}
#[tokio::test]
async fn numeric_telegram_bindings_match_by_sender_identity() {
let (store, _dir) = crate::open_test_store!(UserStore, "numeric_telegram_bind");
for name in ["num_owner", "nick_owner", "num_taker"] {
store.add_user(name).await.unwrap();
}
store.bind_telegram("nick_owner", "frank").await.unwrap();
assert_eq!(
store.bind_telegram("num_owner", " +00123 ").await.unwrap(),
"123",
"the number itself is what is kept"
);
assert_eq!(
store.get_user_channels("num_owner").await.unwrap()[0]
.reply_target
.as_deref(),
Some("123"),
"a number is its own deliverable address"
);
let err = store
.validate_telegram_bind("num_taker", " 00123 ")
.await
.unwrap_err();
assert!(
err.to_string().contains("num_owner"),
"a differently spelled number is the same binding: {err}"
);
let matched = store
.resolve_telegram_user(Some("123"), None)
.await
.unwrap()
.expect("the number binds");
assert_eq!(matched.user_name, "num_owner");
assert_eq!(matched.identifier, "123");
let matched = store
.resolve_telegram_user(Some("124"), Some("frank"))
.await
.unwrap()
.expect("a nickname-only sender matches their nickname");
assert_eq!(matched.user_name, "nick_owner");
assert!(
store
.resolve_telegram_user(Some("124"), Some("frank_ish"))
.await
.unwrap()
.is_none()
);
store
.unbind_channel("num_owner", "telegram", "123")
.await
.unwrap();
store
.bind_channel("nick_owner", "telegram", "00123 ")
.await
.unwrap();
let matched = store
.resolve_telegram_user(Some("123"), None)
.await
.unwrap()
.expect("a stored non-canonical spelling is matched as the number");
assert_eq!(matched.user_name, "nick_owner");
store
.bind_channel("nick_owner", "telegram", "777000")
.await
.unwrap();
assert!(
store
.resolve_telegram_user(Some("777000"), None)
.await
.unwrap()
.is_none()
);
store
.bind_channel("nick_owner", "telegram", "unknown")
.await
.unwrap();
assert!(
store
.resolve_telegram_user(None, Some("unknown"))
.await
.unwrap()
.is_none()
);
}
#[test]
fn effective_workspace_pins_assistant_to_personal() {
let project = Workspace {
name: "ws1".to_string(),
..Default::default()
};
let personal = effective_workspace_for_role(Role::Assistant, project.clone(), "alice");
assert_eq!(personal.name, "personal:alice");
assert!(
personal.path.ends_with("userspaces/alice"),
"the personal workspace must use the userspaces path, got: {}",
personal.path
);
let kept = effective_workspace_for_role(Role::Engineer, project.clone(), "alice");
assert_eq!(kept.name, "ws1");
let already = effective_workspace_for_role(Role::Assistant, personal.clone(), "alice");
assert_eq!(already.name, "personal:alice");
let unpinned = effective_workspace_for_role(Role::Assistant, project, "");
assert_eq!(unpinned.name, "ws1");
}
#[test]
fn enforce_personal_pinning_repins_pinned_roles_and_refuses_empty_user() {
assert_eq!(
enforce_personal_pinning(Role::Assistant, "proj-ws", "alice"),
Some("personal:alice".to_string())
);
assert_eq!(
enforce_personal_pinning(Role::Assistant, "personal:alice", "alice"),
Some("personal:alice".to_string())
);
assert_eq!(
enforce_personal_pinning(Role::Assistant, "personal:bob", "alice"),
Some("personal:alice".to_string())
);
assert_eq!(
enforce_personal_pinning(Role::Engineer, "proj-ws", ""),
Some("proj-ws".to_string())
);
assert_eq!(
enforce_personal_pinning(Role::Assistant, "proj-ws", ""),
None
);
}
#[tokio::test]
async fn resolve_session_target_matches_routing() {
crate::util::test::init_test_stores().await;
let user = "home_clear_target";
let store = store();
store.add_user(user).await.unwrap();
crate::util::test::create_test_workspace(
"/tmp/home_clear_target_ws",
"ws_home_clear_target",
)
.await;
store
.set_selected_workspace(user, Some("ws_home_clear_target"))
.await
.unwrap();
let (role, ws) = resolve_session_target(user).await;
assert_eq!(role, Role::Assistant);
assert_eq!(ws.name, "personal:home_clear_target");
}
#[tokio::test]
#[serial_test::serial(gui_admin_workspace)] async fn resolve_selected_workspace_name_is_admin_aware() {
crate::util::test::init_test_stores().await;
let store = store();
crate::users::test_util::with_admin_workspace_restored(async {
store
.set_selected_workspace(ADMIN_USER_NAME, Some("ws_shared"))
.await
.unwrap();
assert_eq!(
resolve_selected_workspace_name(ADMIN_USER_NAME).await,
Some("ws_shared".to_string())
);
store
.set_selected_workspace(ADMIN_USER_NAME, Some("personal:admin"))
.await
.unwrap();
assert_eq!(
resolve_selected_workspace_name(ADMIN_USER_NAME).await,
Some("personal:admin".to_string())
);
store
.set_selected_workspace(ADMIN_USER_NAME, None)
.await
.unwrap();
assert_eq!(
resolve_selected_workspace_name(ADMIN_USER_NAME).await,
Some("personal:admin".to_string())
);
})
.await;
store
.set_selected_workspace("u_shared", Some("ws_shared"))
.await
.unwrap();
assert_eq!(
resolve_selected_workspace_name("u_shared").await,
Some("personal:u_shared".to_string())
);
store.set_selected_workspace("u_null", None).await.unwrap();
assert_eq!(
resolve_selected_workspace_name("u_null").await,
Some("personal:u_null".to_string())
);
assert_eq!(
resolve_selected_workspace_name("no_such_account").await,
Some("personal:no_such_account".to_string())
);
}
#[tokio::test]
#[serial_test::serial(gui_admin_workspace)] async fn resolve_workspace_for_user_name_is_admin_aware() {
crate::util::test::init_test_stores().await;
let store = store();
crate::util::test::create_test_workspace("/tmp/resolve_admin_aware_ws", "ws_admin_aware")
.await;
store
.set_selected_workspace("u_shared", Some("ws_admin_aware"))
.await
.unwrap();
let ws = resolve_workspace_for_user_name("u_shared").await;
assert_eq!(ws.name, "personal:u_shared");
crate::users::test_util::with_admin_workspace_restored(async {
store
.set_selected_workspace(ADMIN_USER_NAME, Some("ws_admin_aware"))
.await
.unwrap();
let ws = resolve_workspace_for_user_name(ADMIN_USER_NAME).await;
assert_eq!(ws.name, "ws_admin_aware");
})
.await;
}
#[tokio::test]
#[serial_test::serial(gui_admin_workspace)] async fn set_active_workspace_refuses_every_invalid_switch_and_writes_the_admin_row() {
test_util::init_test_store().await;
test_util::with_test_workspaces(
&[("/tmp/mahbot_test_ws_switch", "switch_ws")],
test_util::with_admin_workspace_restored(async {
let before = get_raw_selected_workspace(ADMIN_USER_NAME).await.unwrap();
for (user, name, refusal) in [
("bob", "switch_ws", "Only the admin"),
(ADMIN_USER_NAME, "", "No workspace was named"),
(ADMIN_USER_NAME, " ", "No workspace was named"),
(ADMIN_USER_NAME, "personal:bob", "personal workspace"),
(ADMIN_USER_NAME, "no_such_workspace", "no_such_workspace"),
] {
let err = set_active_workspace(user, name)
.await
.unwrap_err()
.to_string();
assert!(
err.contains(refusal),
"{user} / {name:?} must be refused: {err}"
);
}
assert_eq!(
get_raw_selected_workspace("bob").await.unwrap(),
None,
"a refused switch must not write the guest's row"
);
assert_eq!(
get_raw_selected_workspace(ADMIN_USER_NAME).await.unwrap(),
before,
"a refused switch must not write the admin's row"
);
let ws = set_active_workspace(ADMIN_USER_NAME, "switch_ws")
.await
.unwrap();
assert_eq!(ws.name, "switch_ws");
assert_eq!(
get_raw_selected_workspace(ADMIN_USER_NAME).await.unwrap(),
Some("switch_ws".to_string())
);
}),
)
.await;
}
}