use crate::config::{
CONFIG_KEY_EXA_KEY, CONFIG_KEY_FIRECRAWL_KEY, CONFIG_KEY_TELEGRAM_BOT_TOKEN,
CONFIG_KEY_WEB_SEARCH_PROVIDER,
};
use crate::users::FieldUpdate;
use crate::{Role, Tool, Workspace};
use anyhow::{Context, anyhow};
use async_trait::async_trait;
use serde_json::json;
fn acting_user(ws: &Workspace) -> &str {
crate::users::personal_user_name(&ws.name).unwrap_or("admin")
}
fn err(msg: impl Into<String>) -> anyhow::Error {
anyhow!(msg.into())
}
pub(crate) struct SetupTelegramBotTool;
#[async_trait]
impl Tool for SetupTelegramBotTool {
fn name(&self) -> &'static str {
"setup_telegram_bot"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"bot_token": {
"type": "string",
"description": "The Telegram bot token from BotFather (the `NNN:AAA...` string)."
}
}),
&["bot_token"],
)
}
fn should_scrub_output(&self, _args: &serde_json::Value) -> bool {
false
}
async fn execute(&self, _ws: &Workspace, args: serde_json::Value) -> anyhow::Result<String> {
let token = super::get_str(&args, "bot_token")?;
crate::config::persist_settled_string_field(CONFIG_KEY_TELEGRAM_BOT_TOKEN, token).await?;
Ok(
"Telegram bot token saved — the Telegram listener hot-reloads it immediately. \
Next, send `/start` to your bot in Telegram, then use `bind_telegram` to bind \
your @username so messages are routed to you."
.to_string(),
)
}
}
pub(crate) struct BindTelegramTool;
#[async_trait]
impl Tool for BindTelegramTool {
fn name(&self) -> &'static str {
"bind_telegram"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"telegram_handle": {
"type": "string",
"description": "The admin's Telegram @username (with or without the leading @)."
}
}),
&["telegram_handle"],
)
}
fn should_scrub_output(&self, _args: &serde_json::Value) -> bool {
false
}
async fn execute(&self, _ws: &Workspace, args: serde_json::Value) -> anyhow::Result<String> {
let user = "admin";
let handle = super::get_str(&args, "telegram_handle")?;
let store = crate::users::store();
let handle = store.validate_telegram_bind(user, handle).await?;
store.bind_channel(user, "telegram", &handle).await?;
store
.update_channel_contact("telegram", &handle, &handle)
.await?;
Ok(format!(
"Bound @{handle} to your account. Messages sent to the bot from that @username \
will now be routed to you."
))
}
}
pub(crate) struct AddWorkspaceTool;
#[async_trait]
impl Tool for AddWorkspaceTool {
fn name(&self) -> &'static str {
"add_workspace"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"name": {
"type": "string",
"description": "A short unique name for the workspace (used in ticket ids and the GUI)."
},
"path": {
"type": "string",
"description": "Absolute path to the project directory to manage."
}
}),
&["name", "path"],
)
}
fn should_scrub_output(&self, _args: &serde_json::Value) -> bool {
false
}
async fn execute(&self, ws: &Workspace, args: serde_json::Value) -> anyhow::Result<String> {
let name = super::get_str(&args, "name")?;
let path = super::get_str(&args, "path")?;
let store = crate::workspace::store();
store.add(name, path).await?;
crate::users::store()
.update_user(
acting_user(ws),
FieldUpdate::Unchanged,
FieldUpdate::Set(name),
FieldUpdate::Unchanged,
)
.await?;
Ok(format!(
"Registered workspace '{name}' at {path} and switched your active workspace to it. \
The workspace is being picked up — if the LLM provider is configured the pipeline \
will claim and discover it shortly."
))
}
}
pub(crate) struct AddUserTool;
#[async_trait]
impl Tool for AddUserTool {
fn name(&self) -> &'static str {
"add_user"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"name": {
"type": "string",
"description": "The new user's display name."
},
"telegram_handle": {
"type": "string",
"description": "The user's Telegram @username (with or without the leading @)."
},
"default_agent": {
"type": "string",
"description": "The default agent for this user: 'assistant'."
}
}),
&["name", "telegram_handle", "default_agent"],
)
}
fn should_scrub_output(&self, _args: &serde_json::Value) -> bool {
false
}
async fn execute(&self, _ws: &Workspace, args: serde_json::Value) -> anyhow::Result<String> {
let name = super::get_str(&args, "name")?;
let handle = super::get_str(&args, "telegram_handle")?;
let agent = super::get_str(&args, "default_agent")?
.parse::<Role>()
.context("default_agent must be 'assistant'")?;
if agent != Role::Assistant {
return Err(err("default_agent must be 'assistant'"));
}
let store = crate::users::store();
let handle = store.validate_telegram_bind(name, handle).await?;
let mut existing_unbound = false;
if store.user_exists(name).await? {
let bound = store
.get_user_channels(name)
.await?
.iter()
.any(|c| c.channel == "telegram");
if bound {
return Err(err(format!("A user named '{name}' already exists")));
}
if store.get_permissions(name).await?.as_deref() == Some("full") {
return Err(err(format!(
"'{name}' is an admin — add_user only creates regular (non-admin) users"
)));
}
existing_unbound = true;
store
.update_user(
name,
FieldUpdate::Set(agent.as_str()),
FieldUpdate::Unchanged,
FieldUpdate::Unchanged,
)
.await?;
}
store.add_user(name, None, agent).await?;
store.bind_channel(name, "telegram", &handle).await?;
let role_note = "They are a regular (non-admin) user: they can chat with the \
Assistant agent only.";
if existing_unbound {
Ok(format!(
"Bound @{handle} to the existing user '{name}' and set their default agent to \
'{}'. {role_note}",
agent.as_str()
))
} else {
Ok(format!(
"Created user '{name}' with default agent '{}' and bound @{handle} to them. \
{role_note}",
agent.as_str()
))
}
}
}
pub(crate) struct SetupWebSearchTool;
#[async_trait]
impl Tool for SetupWebSearchTool {
fn name(&self) -> &'static str {
"setup_web_search"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"provider": {
"type": "string",
"description": "The web-search provider: 'firecrawl' or 'exa'."
},
"api_key": {
"type": "string",
"description": "The API key for the chosen provider."
}
}),
&["provider", "api_key"],
)
}
fn should_scrub_output(&self, _args: &serde_json::Value) -> bool {
false
}
async fn execute(&self, _ws: &Workspace, args: serde_json::Value) -> anyhow::Result<String> {
let provider = super::get_str(&args, "provider")?;
let provider = provider.to_ascii_lowercase();
let firecrawl = provider == "firecrawl";
if !firecrawl && provider != "exa" {
return Err(err("provider must be 'firecrawl' or 'exa'"));
}
let api_key = super::get_str(&args, "api_key")?;
crate::config::persist_settled_string_field(CONFIG_KEY_WEB_SEARCH_PROVIDER, &provider)
.await?;
crate::config::persist_settled_string_field(
if firecrawl {
CONFIG_KEY_FIRECRAWL_KEY
} else {
CONFIG_KEY_EXA_KEY
},
api_key,
)
.await?;
Ok(format!(
"Web-search backend registered: {provider}. Agents can now use `web_search`."
))
}
}
pub(crate) struct InstallChromeUseTool;
#[async_trait]
impl Tool for InstallChromeUseTool {
fn name(&self) -> &'static str {
"install_chrome_use"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(&json!({}), &[])
}
fn should_scrub_output(&self, _args: &serde_json::Value) -> bool {
false
}
async fn execute(&self, _ws: &Workspace, _args: serde_json::Value) -> anyhow::Result<String> {
crate::tools::chrome_daemon::install_chrome_use()
.await
.map_err(err)?;
Ok(
"chrome-use is installed: the release binary was downloaded from the latest \
chrome-use GitHub release (the version is resolved at install time — only the \
SHA-256 checksum is pinned, verified against the published `.sha256` sidecar), \
placed at mahbot's managed bin location, and the native-messaging host was \
registered via `chrome-use extension install --no-profile` — no managed-Chrome \
configuration profile is ever created, so Chrome never enters \
\"managed by your organization\" mode. A guarded PATH block was appended to \
~/.zshrc/~/.bashrc (Unix) so the `chrome-use` CLI is reachable from the \
user's shell.\n\
One MANUAL step remains — the user must install the chrome-use browser \
extension:\n\
1. Open Chrome and go to the Chrome Web Store.\n\
2. Install the `chrome-use` extension (Web Store listing `chrome-use`, ID \
knfcmbamhjmaonkfnjhldjedeobeafmk; `ab-connect` is its internal codename, seen \
in runtime messages like ab-connect.mobileconfig — same product, don't search \
the Store for \"ab-connect\").\n\
3. The connection works as soon as the extension is installed and enabled. \
Pinning it to the toolbar is an optional UX tip only — it is NOT required for \
connectivity (Chrome native messaging, no debug port).\n\
Only after that will agents be able to drive the user's normal browser via \
`chrome`.\n\
Note: this setup is intentionally invasive — chrome-use gets full control \
over the user's real browser. It must only be run after you (the Support \
agent) have explained what it does and obtained the user's explicit consent.\n\
mahbot auto-updates the chrome-use binary once per boot, ~5 minutes after \
service startup — a binary-only, checksum-verified in-place swap that never \
first-installs and never re-registers the extension or native host."
.to_string(),
)
}
}
pub(crate) struct FinalizeTool;
#[async_trait]
impl Tool for FinalizeTool {
fn name(&self) -> &'static str {
"finalize"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"agent": {
"type": "string",
"description": "The agent to switch to after onboarding: 'assistant'."
}
}),
&["agent"],
)
}
fn should_scrub_output(&self, _args: &serde_json::Value) -> bool {
false
}
async fn execute(&self, ws: &Workspace, args: serde_json::Value) -> anyhow::Result<String> {
let user = acting_user(ws).to_string();
let agent = super::get_str(&args, "agent")?.parse::<Role>()?;
if agent != Role::Assistant {
return Err(err("agent must be 'assistant'"));
}
crate::users::switch_active_role(&user, agent).await?;
crate::config::persist_settled_string_field(
crate::config::CONFIG_KEY_ONBOARDING_STATE,
crate::config::OnboardingState::Finished.as_str(),
)
.await?;
let label = crate::agent::role::role_info(&agent).display_label;
Ok(format!(
"Onboarding complete. You're now chatting with the {label} agent. Switch agents \
anytime from the role picker, or open Settings via the gear icon to adjust anything."
))
}
}