mahbot 0.4.0

An autonomous agentic engineering system that manages software development through role separation, subagents, and deterministic diagnostics.
Documentation
mod enrichment;
pub mod gui;
pub mod telegram;
pub mod voice;
pub use enrichment::{EnrichmentStrategy, enrich_links, enrich_message, has_only_audio_markers};
pub use telegram::mirror_gui_message_to_telegram;

use crate::chat_history::ChatHistoryInsert;
use crate::turso;
use crate::{ChannelMessage, ChatDirection};
use tokio_util::sync::CancellationToken;

const CHANNEL_TYPING_REFRESH_INTERVAL_SECS: u64 = 4;

/// Entry for a single chat message that should be both broadcast to the GUI
/// dashboard and persisted to chat_history. Fields map directly to the
/// [`crate::ChatEvent::Message`] and [`ChatHistoryInsert`] parameters.
#[derive(Debug, Clone)]
struct BroadcastPersistEntry {
    user_name: String,
    channel: String,
    content: String,
    direction: ChatDirection,
    agent_role: Option<String>,
    workspace: String,
    optimistic_id: Option<String>,
}

impl BroadcastPersistEntry {
    /// Broadcast this entry to [`crate::CHAT_BROADCAST`] and persist it to
    /// `chat_history`.
    async fn broadcast_and_persist(self) {
        debug_assert!(
            self.direction != ChatDirection::Agent || self.agent_role.is_some(),
            "BroadcastPersistEntry: direction=Agent but agent_role is None"
        );

        let message_id = crate::generate_id();
        let timestamp = turso::now();

        let db_direction = match self.direction {
            ChatDirection::Agent => "agent".to_string(),
            ChatDirection::User => "user".to_string(),
            ChatDirection::Divider => {
                unreachable!("Divider markers should not go through broadcast_and_persist")
            }
        };

        broadcast_chat_event(
            &message_id,
            &self.user_name,
            &self.content,
            self.direction,
            &self.channel,
            self.agent_role.clone(),
            &self.workspace,
            self.optimistic_id.clone(),
            &timestamp,
        );

        let store = crate::chat_history::store();
        let _ = store
            .insert(&ChatHistoryInsert {
                message_id,
                user_name: self.user_name,
                direction: db_direction,
                content: self.content,
                agent_role: self.agent_role,
                workspace: self.workspace,
            })
            .await;
    }
}

/// Broadcast an agent response to CHAT_BROADCAST for live GUI display and
/// persist it to chat_history. This is the canonical entry point for all
/// agent responses — used by the per-agent consumer loop in
/// [`crate::message_router`] and by the raw reply-target delivery path.
///
/// TTS audio playback is handled separately by [`crate::audio::tts::init_listener()`],
/// which subscribes to [`CHAT_BROADCAST`](crate::CHAT_BROADCAST) and triggers
/// speech for matching agent messages.  This function does not itself invoke
/// any TTS logic.
///
/// Takes explicit `user_name` (canonical user name), `channel` (e.g. "telegram", "gui"),
/// and primitive fields — does **not** depend on [`crate::SendMessage`], so it can be used
/// from the per-agent consumer loop which works from [`crate::users::UserRecord`].
pub(crate) async fn broadcast_and_persist_agent_response(
    user_name: &str,
    channel: &str,
    content: &str,
    agent_role: Option<String>,
    workspace: &str,
) {
    BroadcastPersistEntry {
        user_name: user_name.to_string(),
        channel: channel.to_string(),
        content: content.to_string(),
        direction: ChatDirection::Agent,
        agent_role, // moved — no clone needed
        workspace: workspace.to_string(),
        optimistic_id: None, // agent messages must not carry one
    }
    .broadcast_and_persist()
    .await;
}

/// Send a [`ChatEvent::Message`] to the broadcast channel.
///
/// This is the single shared entry point for all broadcast operations,
/// ensuring consistent message construction across user messages, agent
/// responses, and any future message types.  The caller is responsible
/// for generating a stable [`message_id`] and [`timestamp`] if they need
/// to correlate the broadcast event with a persist operation.
#[expect(clippy::too_many_arguments)]
pub(crate) fn broadcast_chat_event(
    message_id: &str,
    user_name: &str,
    content: &str,
    direction: ChatDirection,
    channel: &str,
    agent_role: Option<String>,
    workspace: &str,
    optimistic_id: Option<String>,
    timestamp: &str,
) {
    use crate::ChatEvent;

    if let Some(tx) = crate::CHAT_BROADCAST.get() {
        let _ = tx.send(ChatEvent::Message {
            message_id: message_id.to_string(),
            user_name: user_name.to_string(),
            content: content.to_string(),
            direction,
            timestamp: timestamp.to_string(),
            channel: channel.to_string(),
            agent_role,
            workspace: workspace.to_string(),
            optimistic_id,
        });
    }
}

/// Broadcast an incoming user message to the GUI and persist it to chat_history,
/// mirroring it to Telegram in parallel. `broadcast_content` is the enriched text
/// sent to the GUI (e.g. audio transcriptions, renderable data URIs), while
/// `persist_content` is stored in chat_history and mirrored to Telegram — the raw
/// original text (no data-URI bloat), except for audio-only messages which carry
/// the enriched transcription (icon + text) so no temp file path is persisted.
/// The GUI bubble is broadcast synchronously before the async persist + mirror
/// join begins.
///
/// Forwards [`ChannelMessage::optimistic_id`] so the GUI can replace its optimistic
/// bubble with the real one.
pub async fn broadcast_and_persist_incoming_message(
    msg: &ChannelMessage,
    broadcast_content: &str,
    persist_content: &str,
) {
    let message_id = crate::generate_id();
    let timestamp = turso::now();

    broadcast_chat_event(
        &message_id,
        &msg.user_name,
        broadcast_content,
        ChatDirection::User,
        &msg.channel,
        None,
        &msg.workspace,
        msg.optimistic_id.clone(),
        &timestamp,
    );

    tokio::join!(
        async {
            let store = crate::chat_history::store();
            let _ = store
                .insert(&ChatHistoryInsert {
                    message_id,
                    user_name: msg.user_name.clone(),
                    direction: "user".to_string(),
                    content: persist_content.to_string(),
                    agent_role: None,
                    workspace: msg.workspace.clone(),
                })
                .await;
        },
        async {
            let mut mirror_msg = msg.clone();
            mirror_msg.content = persist_content.to_string();
            mirror_gui_message_to_telegram(&mirror_msg).await;
        },
    );
}

#[must_use]
pub fn spawn_scoped_typing_task(
    recipient: String,
    channel: String,
    cancellation_token: CancellationToken,
) -> tokio::task::JoinHandle<()> {
    let refresh_interval = std::time::Duration::from_secs(CHANNEL_TYPING_REFRESH_INTERVAL_SECS);
    tokio::spawn(async move {
        let Some(ch) = crate::channel_registry().get(&channel) else {
            tracing::warn!(
                channel = %channel,
                "Channel not found in registry — skipping typing indicator"
            );
            return;
        };
        let mut interval = tokio::time::interval(refresh_interval);
        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);

        loop {
            tokio::select! {
                () = cancellation_token.cancelled() => break,
                _ = interval.tick() => {
                    if let Err(e) = ch.start_typing(&recipient).await {
                        tracing::debug!("Failed to start typing on {}: {e}", ch.name());
                    }
                }
            }
        }
    })
}

/// Cancel the typing task (via token) and await its completion.
pub async fn stop_typing(handle: tokio::task::JoinHandle<()>) {
    if let Err(error) = handle.await {
        tracing::error!("Typing task crashed: {error}");
    }
}