#![warn(clippy::pedantic)]
use anyhow::Result;
use chrono::{Duration as ChronoDuration, Utc};
use futures_util::FutureExt;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::time::Duration;
use tokio::task::spawn;
use tracing::{debug, error, info, warn};
use std::future::Future;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use mahbot::channels::{
send_channel_reply, send_channel_reply_with_buttons, spawn_scoped_typing_task, stop_typing,
write_incoming_to_broadcast,
};
use mahbot::config::CONFIG;
use mahbot::extraction::{decode_action, decode_callback, is_action, is_callback};
use mahbot::gui::{BOOT_LOG_STORE, Dashboard, JETBRAINS_MONO, Message as DashboardMessage};
use mahbot::manager_queue;
use mahbot::session::{Session, direct_session_key, manager_session_key};
use mahbot::util::UnwrapPoison;
use mahbot::{Agent, Channel, ChannelMessage, Command, Role, Workspace};
const JETBRAINS_MONO_FONT_BYTES: &[u8] = include_bytes!("gui/JetBrainsMono-Regular.ttf");
const JETBRAINS_MONO_BOLD_FONT_BYTES: &[u8] = include_bytes!("gui/JetBrainsMono-Bold.ttf");
async fn resolve_workspace_for_user(msg: &ChannelMessage) -> Workspace {
if let Ok(Some(ws)) = mahbot::users::get_workspace(&msg.user_name).await {
ws
} else {
let path = mahbot::users::personal_workspace_path(&msg.user_name);
mahbot::users::personal_workspace_struct(&msg.user_name, &path)
}
}
async fn enrich_message_for_role(msg: &mut ChannelMessage, role: Role, ws: &Workspace) {
let strategy = if role.requires_multimodal() {
mahbot::channels::EnrichmentStrategy::Multimodal {
workspace_path: Some(ws.as_path().to_path_buf()),
}
} else {
mahbot::channels::EnrichmentStrategy::NonMultimodal
};
mahbot::channels::enrich_message(msg, &strategy).await;
let enriched = mahbot::channels::enrich_links(&msg.content).await;
if enriched != msg.content {
tracing::info!(
channel = %msg.source_channel,
user_name = %msg.user_name,
"Link enricher: prepended URL summaries to message"
);
msg.content = enriched;
}
}
async fn handle_option_callback(mut msg: ChannelMessage) {
let Some((ticket_id, label)) = decode_callback(&msg.content) else {
return;
};
msg.content = match &ticket_id {
Some(ticket_id_val) => format!("{ticket_id_val} - {label}"),
None => label,
};
let ws = resolve_workspace_for_user(&msg).await;
manager_queue::manager_queue().enqueue(manager_queue::ManagerJob {
content: msg.content.clone(),
workspace_name: ws.name.clone(),
kind: manager_queue::JobKind::UserMessage,
});
}
async fn build_session_key(user_name: &str, role: &Role, source_channel: &str) -> String {
let ws_name = match mahbot::users::get_workspace(user_name).await {
Ok(Some(ws)) => ws.name,
_ => "unknown".to_string(),
};
if *role == Role::Manager {
manager_session_key(&ws_name)
} else {
direct_session_key(source_channel, user_name, role.as_str(), &ws_name)
}
}
async fn bootstrap_mahbot_safe() -> Result<(), String> {
match AssertUnwindSafe(bootstrap_mahbot()).catch_unwind().await {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(e.to_string()),
Err(payload) => Err(format_startup_panic(&*payload)),
}
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(msg) = payload.downcast_ref::<&str>() {
msg.to_string()
} else if let Some(msg) = payload.downcast_ref::<String>() {
msg.clone()
} else {
"unknown panic".to_string()
}
}
fn format_startup_panic(payload: &(dyn std::any::Any + Send)) -> String {
format!("Startup panicked: {}", panic_message(payload))
}
async fn bootstrap_mahbot() -> Result<()> {
mahbot::config::load_or_init().await?;
let (log_store, log_broadcast) =
mahbot::logs::init_tracing(&CONFIG.global_storage_root()).await?;
let _ = mahbot::gui::LOG_BROADCAST.set(log_broadcast);
mahbot::search_engine::init_global(); mahbot::ticket_buffer::init_global(); mahbot::manager_queue::init_global()?;
tokio::try_join!(
mahbot::session::init_global(),
mahbot::workspace::init_global(),
mahbot::users::init_global(),
mahbot::board::init_global(),
mahbot::stats::init_global(),
mahbot::chat_history::init_global(),
)?;
mahbot::config_db::init_global().await?;
mahbot::config::reload_from_db().await?;
mahbot::providers::init_global().await?;
spawn_background_tasks(log_store.clone());
info!("MahBot initialized — dashboard ready");
BOOT_LOG_STORE
.set(log_store.as_ref().clone())
.map_err(|_| anyhow::anyhow!("BOOT_LOG_STORE already set"))?;
let admin_target = mahbot::self_update::resolve_admin_telegram_target().await;
mahbot::self_update::notify_admin("✅ MahBot is back online.", admin_target.as_ref()).await;
Ok(())
}
static BACKGROUND_TASKS: std::sync::Mutex<Option<JoinSet<()>>> = std::sync::Mutex::new(None);
fn spawn_cancellable<F>(
tasks: &mut JoinSet<()>,
shutdown_token: &CancellationToken,
name: &'static str,
fut: F,
) where
F: Future<Output = ()> + Send + 'static,
{
let cancel = shutdown_token.clone();
tasks.spawn(async move {
tokio::select! {
result = AssertUnwindSafe(fut).catch_unwind() => {
if let Err(payload) = result {
error!(
"Background task panicked [{name}]: {}",
panic_message(&*payload),
);
}
}
() = cancel.cancelled() => {},
}
});
}
fn spawn_background_tasks(log_store: Arc<mahbot::logs::LogStore>) {
let mut tasks = JoinSet::<()>::new();
let shutdown_token = mahbot::shutdown::shutdown_token();
tasks.spawn(cleanup_loop_task("Session cleanup", |cutoff| async move {
mahbot::session::cleanup_old_transient_sessions(&cutoff).await
}));
tasks.spawn(cleanup_loop_task("Log cleanup", {
let store = log_store;
move |cutoff| {
let store = store.clone();
async move { store.delete_older_than("INFO", &cutoff).await }
}
}));
spawn_cancellable(
&mut tasks,
&shutdown_token,
"maintainer",
mahbot::maintainer::run_maintainer_loop(),
);
spawn_cancellable(
&mut tasks,
&shutdown_token,
"archive-cancelled",
mahbot::board::run_archive_cancelled_loop(),
);
spawn_cancellable(
&mut tasks,
&shutdown_token,
"search-engine-init",
mahbot::search_engine::init_all_engines(),
);
let rx = init_message_pipeline(&mut tasks, &shutdown_token);
spawn_cancellable(
&mut tasks,
&shutdown_token,
"message-handler",
handle_messages(rx),
);
spawn_cancellable(
&mut tasks,
&shutdown_token,
"management",
mahbot::management::run_management(),
);
tasks.spawn(async move {
if mahbot::shutdown::wait_for_shutdown_signal().await.is_ok() {
info!("Received OS signal, triggering shutdown");
mahbot::shutdown::shutdown();
}
});
{
let mut guard = BACKGROUND_TASKS.lock().unwrap_poison();
let _ = guard.insert(tasks);
}
}
fn init_message_pipeline(
tasks: &mut JoinSet<()>,
cancel: &CancellationToken,
) -> tokio::sync::mpsc::Receiver<ChannelMessage> {
let (tx, rx) = tokio::sync::mpsc::channel::<ChannelMessage>(100);
mahbot::MESSAGE_TX
.set(tx.clone())
.expect("MESSAGE_TX already set — should be first init");
let gui_pipeline_tx = tx.clone();
let (chat_tx, _chat_rx) = tokio::sync::broadcast::channel::<mahbot::ChatEvent>(256);
mahbot::CHAT_BROADCAST
.set(chat_tx)
.expect("CHAT_BROADCAST already set — should be first init");
let _ = mahbot::CHANNEL_REGISTRY.set(mahbot::ChannelRegistry::default());
if let Some(token) = CONFIG.telegram_bot_token() {
use mahbot::channels::telegram::TelegramChannel;
let channel: Arc<dyn Channel> = Arc::new(TelegramChannel::new(token));
mahbot::channel_registry().register(Arc::clone(&channel));
spawn_cancellable(tasks, cancel, "telegram-listener", {
let channel = Arc::clone(&channel);
async move {
let _ = channel.listen(tx).await;
}
});
} else {
info!("No Telegram bot token configured — running in dashboard-only mode");
}
{
use mahbot::channels::gui::GuiChannel;
let (gui_channel, gui_tx) = GuiChannel::new();
mahbot::GUI_MESSAGE_TX
.set(gui_tx)
.expect("GUI_MESSAGE_TX already set — should be first init");
let gui_channel: Arc<dyn Channel> = Arc::new(gui_channel);
mahbot::channel_registry().register(Arc::clone(&gui_channel));
spawn_cancellable(tasks, cancel, "gui-listener", {
let channel = Arc::clone(&gui_channel);
async move {
let _ = channel.listen(gui_pipeline_tx).await;
}
});
}
rx
}
async fn shutdown_after_dashboard() {
info!("Dashboard window closed — shutting down");
mahbot::shutdown::shutdown();
mahbot::registry::AGENT_REGISTRY.shutdown_all();
mahbot::tools::browser::close_all_browser_sessions().await;
let maybe_tasks = {
let mut guard = BACKGROUND_TASKS.lock().unwrap_poison();
guard.take()
};
if let Some(mut tasks) = maybe_tasks {
while let Some(result) = tasks.join_next().await {
match result {
Ok(()) => {}
Err(e) if e.is_cancelled() => {
debug!("background task cancelled during shutdown");
}
Err(e) => {
warn!("background task panicked: {e}");
}
}
}
}
}
fn main() -> Result<()> {
mahbot::shutdown::install_fatal_signal_handlers();
if std::env::args().nth(1).as_deref() == Some("debug") {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
match rt.block_on(mahbot::debug::run_debug()) {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("Error: {e:#}");
std::process::exit(1);
}
}
}
let update_available = mahbot::self_update::is_update_available();
let storage_root = mahbot::config::default_config_dir()?;
mahbot::self_update::acquire_lock(&storage_root)?;
let window_state = mahbot::gui::read_window_state();
iced::application(
move || {
(
Dashboard::loading(update_available),
iced::Task::perform(bootstrap_mahbot_safe(), DashboardMessage::Boot),
)
},
Dashboard::update,
Dashboard::view,
)
.title(Dashboard::title)
.font(iced_fonts::LUCIDE_FONT_BYTES)
.font(JETBRAINS_MONO_FONT_BYTES)
.font(JETBRAINS_MONO_BOLD_FONT_BYTES)
.default_font(JETBRAINS_MONO)
.subscription(Dashboard::subscription)
.theme(Dashboard::theme)
.window(iced::window::Settings {
size: iced::Size::new(window_state.width, window_state.height),
position: window_state.position(),
min_size: Some(iced::Size::new(800.0, 500.0)),
..iced::window::Settings::default()
})
.exit_on_close_request(false)
.run()
.map_err(|e| anyhow::anyhow!("Iced application error: {e}"))?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| anyhow::anyhow!("shutdown runtime: {e}"))?;
rt.block_on(shutdown_after_dashboard());
Ok(())
}
async fn cleanup_loop_task<F, Fut>(label: &'static str, cleanup: F)
where
F: Fn(String) -> Fut + Send + 'static,
Fut: Future<Output = anyhow::Result<u64>> + Send,
{
loop {
if !mahbot::shutdown::sleep_or_shutdown(Duration::from_mins(10)).await {
break;
}
let cutoff = (Utc::now() - ChronoDuration::hours(8)).to_rfc3339();
match cleanup(cutoff).await {
Ok(n) if n > 0 => info!(deleted = n, "{label}: deleted old entries"),
Ok(_) => tracing::debug!("{label}: nothing to delete"),
Err(e) => warn!(error = %e, "{label} failed"),
}
}
}
async fn handle_messages(mut rx: tokio::sync::mpsc::Receiver<ChannelMessage>) {
let shutdown_token = mahbot::shutdown::shutdown_token();
loop {
let mut msg = tokio::select! {
() = shutdown_token.cancelled() => break,
msg = rx.recv() => match msg {
Some(msg) => msg,
None => break,
},
};
if is_callback(&msg.content) {
spawn(handle_option_callback(msg));
continue;
}
if is_action(&msg.content) {
handle_action_callback(msg).await;
continue;
}
if handle_dispatch_command(&mut msg).await {
continue;
}
spawn(process_channel_message(msg));
}
}
async fn handle_dispatch_command(msg: &mut ChannelMessage) -> bool {
let cmd = parse(&msg.content);
let Some(cmd) = cmd else {
return false;
};
if msg.source_channel != "telegram" {
return false;
}
match cmd {
Command::Start => handle_start_command(msg).await,
}
true
}
async fn handle_start_command(msg: &ChannelMessage) {
let Some(reply_markup) = build_start_keyboard(msg).await else {
return;
};
let reply = mahbot::SendMessage {
content: "Choose an action:".to_string(),
recipient: msg.reply_target.clone(),
reply_markup: Some(reply_markup),
agent_role: None,
workspace: msg.workspace.clone(),
};
if let Some(channel) = mahbot::channel_registry().get(&msg.source_channel) {
let _ = channel.send(&reply).await;
}
}
async fn build_start_keyboard(msg: &ChannelMessage) -> Option<serde_json::Value> {
if msg.source_channel != "telegram" {
return None;
}
let role = mahbot::users::resolve_active_role(&msg.user_name).await;
let rows = if role == Role::Artist {
let mut rows: Vec<serde_json::Value> = Vec::new();
build_model_button_rows(
&mut rows,
&CONFIG.image_gen_models(),
&CONFIG.image_gen_model(),
"__act__set_image_model",
);
build_model_button_rows(
&mut rows,
&CONFIG.video_gen_models(),
&CONFIG.video_gen_model(),
"__act__set_video_model",
);
rows.push(serde_json::json!([{
"text": "Clear session",
"callback_data": "__act__clear_session|",
}]));
rows
} else {
vec![serde_json::json!([{
"text": "Clear session",
"callback_data": "__act__clear_session|",
}])]
};
Some(serde_json::json!({ "inline_keyboard": rows }))
}
fn build_model_button_rows(
rows: &mut Vec<serde_json::Value>,
models: &[String],
active_model: &str,
action_prefix: &str,
) {
for model in models {
let label = if model == active_model {
format!("\u{2713} {model}")
} else {
model.clone()
};
rows.push(serde_json::json!([{
"text": label,
"callback_data": format!("{action_prefix}|{model}"),
}]));
}
}
async fn handle_action_callback(msg: ChannelMessage) {
let Some((action, payload)) = decode_action(&msg.content) else {
tracing::warn!("Malformed __act__ callback data: {}", &msg.content);
return;
};
match action.as_str() {
"set_image_model" => {
handle_set_model_action(&msg, &payload, "image_gen_model", "Image").await;
}
"set_video_model" => {
handle_set_model_action(&msg, &payload, "video_gen_model", "Video").await;
}
"clear_session" => {
answer_telegram_callback(&msg, None).await;
let role = mahbot::users::resolve_active_role(&msg.user_name).await;
let session_key = build_session_key(&msg.user_name, &role, &msg.source_channel).await;
let reply = Session::reset(&session_key).await;
send_channel_reply(reply, &msg).await;
}
_ => {
answer_telegram_callback(&msg, None).await;
tracing::warn!(action = %action, "Unknown __act__ action — ignoring");
}
}
}
async fn handle_set_model_action(
msg: &ChannelMessage,
payload: &str,
config_key: &str,
display_name: &str,
) {
if payload.is_empty() {
tracing::warn!(config_key, "{config_key} action with empty payload");
answer_telegram_callback(msg, Some("No model specified.".to_string())).await;
return;
}
let store = mahbot::config_db::store();
if let Err(e) = store.set_kv(config_key, payload).await {
tracing::error!(config_key, error = %e, "Failed to save {config_key}");
answer_telegram_callback(msg, Some(format!("Failed to save model: {e}"))).await;
return;
}
let _ = CONFIG.set_string_field_and_apply(config_key, payload);
answer_telegram_callback(
msg,
Some(format!("{display_name} generation model set to: {payload}")),
)
.await;
}
async fn answer_telegram_callback(msg: &ChannelMessage, toast: Option<String>) {
let Some(cq_id) = &msg.callback_query_id else {
return;
};
if let Some(channel) = mahbot::channel_registry().get("telegram")
&& let Some(tc) = channel
.as_any()
.downcast_ref::<mahbot::channels::telegram::TelegramChannel>()
{
tc.answer_callback_query(cq_id, toast.as_deref()).await;
}
}
async fn process_channel_message(mut msg: ChannelMessage) {
tracing::info!(
"💬 [{}] from {}: {}",
msg.source_channel,
msg.user_name,
mahbot::util::truncate(&msg.content, 80)
);
let ws = resolve_workspace_for_user(&msg).await;
msg.workspace = ws.name.clone();
write_incoming_to_broadcast(&msg).await;
let role = mahbot::users::resolve_active_role(&msg.user_name).await;
let effective_role = if role == Role::Manager && mahbot::users::is_personal_workspace(&ws.name)
{
Role::Analyst
} else {
role
};
enrich_message_for_role(&mut msg, effective_role, &ws).await;
if effective_role == Role::Manager {
manager_queue::manager_queue().enqueue(manager_queue::ManagerJob {
content: msg.content.clone(),
workspace_name: ws.name.clone(),
kind: manager_queue::JobKind::UserMessage,
});
return;
}
let session_key = direct_session_key(
&msg.source_channel,
&msg.user_name,
effective_role.as_str(),
&ws.name,
);
let mut agent = Agent::new(session_key, effective_role, &ws, None);
let cancel = agent.cancel_token();
let typing_handle = spawn_scoped_typing_task(
msg.reply_target.clone(),
msg.source_channel.clone(),
cancel.clone(),
);
let agent_result = tokio::select! {
() = cancel.cancelled() => return,
result = agent.work(&msg.content) => result,
};
let response = match agent_result {
Ok(response) => response,
Err(e) => {
tracing::error!("❌ Agent error: {e}");
format!("⚠️ `{e}`")
}
};
if agent.is_cancelled() {
stop_typing(typing_handle).await;
return;
}
send_channel_reply_with_buttons(response, &msg, None, Some(effective_role.to_string())).await;
cancel.cancel();
stop_typing(typing_handle).await;
}
#[must_use]
pub fn parse(content: &str) -> Option<Command> {
let cmd_line = content.trim().strip_prefix('/')?;
let (cmd, _arg) = cmd_line
.split_once(' ')
.map_or((cmd_line, ""), |(c, a)| (c, a.trim()));
match cmd.to_ascii_lowercase().as_str() {
"start" => Some(Command::Start),
_ => None,
}
}