opencrabs 0.3.55

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
Documentation
//! /cowork Command: Add OpenCrabs to a Telegram group with auto-registration.
//!
//! Flow:
//! 1. User sends `/cowork` in DM → bot shows "Add to Group" button immediately
//! 2. User taps link → Telegram native UI lets them pick/create a group
//! 3. Bot detects `/start cowork_<id>` in new group → generates invite link + QR
//! 4. QR sent to user's DM. All group members auto-register in allowed_users.

use crate::config::{Config, opencrabs_home};

use super::TelegramState;
use super::send::{message_in_thread, photo_in_thread};
use teloxide::prelude::*;
use teloxide::types::{ChatId, InlineKeyboardButton, InlineKeyboardMarkup, InputFile};

/// Prefix for cowork startgroup parameters.
const COWORK_PREFIX: &str = "cowork_";

/// Lightweight session linking a `/cowork` DM to the group that will be created.
/// Stored so when the bot joins via the deep link, we know where to send the QR.
#[derive(Debug, Clone)]
pub struct CoworkState {
    /// User who initiated /cowork.
    pub user_id: i64,
    /// DM chat where /cowork was sent (for sending QR back).
    pub chat_id: i64,
    /// Unique session identifier for this cowork flow.
    pub session_id: String,
    /// When this state was created. If the user never taps "Add to Group",
    /// the state silently expires and their next message is processed normally.
    pub created_at: std::time::Instant,
}

/// If the user doesn't click "Add to Group" within this window, the cowork
/// state is silently cleared and their next message goes through normally.
const COWORK_TIMEOUT_SECS: u64 = 120;

impl CoworkState {
    pub fn new(user_id: i64, chat_id: i64, session_id: String) -> Self {
        Self {
            user_id,
            chat_id,
            session_id,
            created_at: std::time::Instant::now(),
        }
    }

    /// Returns true if this cowork state has expired (user never tapped the link).
    pub fn is_expired(&self) -> bool {
        self.created_at.elapsed().as_secs() > COWORK_TIMEOUT_SECS
    }
}

/// Check if a `/start` parameter is a cowork session.
pub fn is_cowork_session(param: &str) -> bool {
    param.starts_with(COWORK_PREFIX) && param.len() > COWORK_PREFIX.len()
}

/// Extract the session ID from a cowork startgroup parameter.
/// "cowork_abc123" → Some("abc123"), "other" → None.
pub fn parse_startgroup_param(param: &str) -> Option<&str> {
    if is_cowork_session(param) {
        Some(&param[COWORK_PREFIX.len()..])
    } else {
        None
    }
}

/// Build a Telegram deep link that opens the "create group with bot" UI.
/// Format: `https://t.me/{bot_username}?startgroup=cowork_{session_id}`
pub fn build_cowork_deep_link(bot_username: &str, session_id: &str) -> String {
    format!(
        "https://t.me/{}?startgroup={}{}",
        bot_username, COWORK_PREFIX, session_id
    )
}

/// Generate a QR code PNG from an invite link. Returns (png_bytes, file_path).
/// Reuses `render_qr_png` from whatsapp_connect.
pub fn build_invite_qr(invite_link: &str) -> Option<(Vec<u8>, std::path::PathBuf)> {
    let png_bytes = crate::brain::tools::whatsapp_connect::render_qr_png(invite_link)?;
    let dir = opencrabs_home().join("tmp");
    std::fs::create_dir_all(&dir).ok()?;
    let path = dir.join("cowork_invite_qr.png");
    std::fs::write(&path, &png_bytes).ok()?;
    Some((png_bytes, path))
}

/// Append a user_id to config.telegram.allowed_users if not already present.
/// Returns Ok(true) if newly registered, Ok(false) if already existed.
pub fn auto_register_user(user_id: i64) -> Result<bool, String> {
    let config = Config::load().map_err(|e| format!("Failed to load config: {e}"))?;

    let id_str = user_id.to_string();
    if config.channels.telegram.allowed_users.contains(&id_str) {
        return Ok(false);
    }

    let mut users = config.channels.telegram.allowed_users.clone();
    users.push(id_str);

    Config::write_array("channels.telegram", "allowed_users", &users)
        .map_err(|e| format!("Failed to write allowed_users: {e}"))?;

    Ok(true)
}

/// Check if a chat_id is a tracked cowork group.
pub async fn is_cowork_group(chat_id: i64, state: &TelegramState) -> bool {
    state.is_cowork_group(chat_id).await
}

/// Handle the /cowork command in DM. Immediately shows the "Add to Group" button.
pub async fn handle_cowork_command(
    bot: &Bot,
    _msg: &Message,
    state: &TelegramState,
    user_id: i64,
    chat_id: i64,
    thread_id: Option<teloxide::types::ThreadId>,
) -> Result<(), teloxide::RequestError> {
    let bot_username = state
        .bot_username()
        .await
        .unwrap_or_else(|| "opencrabsbot".to_string());

    // If user already has a pending cowork session, reuse it; otherwise start new one
    let session_id = if let Some(existing) = state.get_cowork_state(user_id).await {
        existing.session_id
    } else {
        let sid = uuid::Uuid::new_v4().to_string()[..8].to_string();
        state.start_cowork(user_id, chat_id, sid.clone()).await;
        sid
    };

    let deep_link = build_cowork_deep_link(&bot_username, &session_id);

    let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::url(
        "🦀 Add to Group".to_string(),
        deep_link.parse().unwrap(),
    )]]);

    let text = "Tap below to add me to a Telegram group.\n\n\
        After joining, I'll check if I have admin access. \
        If not, I'll send you instructions to promote me.";

    message_in_thread(bot, ChatId(chat_id), thread_id, text)
        .reply_markup(keyboard)
        .await?;
    Ok(())
}

/// Handle the bot being added to a group via `?startgroup=cowork_<id>`.
pub async fn handle_cowork_group_join(
    bot: &Bot,
    msg: &Message,
    state: &TelegramState,
    param: &str,
    thread_id: Option<teloxide::types::ThreadId>,
) -> Result<(), teloxide::RequestError> {
    let Some(session_id) = parse_startgroup_param(param) else {
        return Ok(());
    };

    let group_chat_id = msg.chat.id.0;

    // Look up the cowork state by session_id
    let cowork = state.take_cowork_by_session(session_id).await;

    // Track this group as a cowork group
    state.add_cowork_group(group_chat_id).await;

    // Check if the bot is an admin in the group before attempting admin-only ops
    let bot_info = bot.get_me().await?;
    let bot_id = bot_info.id;
    let bot_member = bot.get_chat_member(ChatId(group_chat_id), bot_id).await?;
    let is_admin = matches!(
        bot_member.status(),
        teloxide::types::ChatMemberStatus::Administrator | teloxide::types::ChatMemberStatus::Owner
    );

    if !is_admin {
        let promote_msg = "🦀 I need **admin privileges** to create invite links and read messages.\n\n\
            **To fix this:**\n\
            1. Open Group Settings → Administrators → Add Admin\n\
            2. Select me and confirm\n\
            3. Disable privacy mode: send `/setprivacy` to @BotFather, choose this bot, set to Disabled\n\n\
            Then send `/cowork` in your DM with me again to generate the invite link.";

        let _ = message_in_thread(bot, ChatId(group_chat_id), thread_id, promote_msg).await;

        // Also notify the user in their DM
        if let Some(ref cowork_state) = cowork {
            let user_chat = ChatId(cowork_state.chat_id);
            let dm_msg = "🦀 I was added to the group but I'm **not an admin** yet.\n\n\
                **What to do:**\n\
                1. Go to the group → Group Settings → Administrators\n\
                2. Add me as admin\n\
                3. Disable privacy mode: send `/setprivacy` to @BotFather, choose this bot, set to Disabled\n\n\
                Then send `/cowork` here again to generate the invite link and QR code.";

            let _ = message_in_thread(bot, user_chat, None, dm_msg).await;
        }

        return Ok(());
    }

    // Bot is admin — generate invite link
    let invite_result = bot.create_chat_invite_link(ChatId(group_chat_id)).await;

    match invite_result {
        Ok(link) => {
            let invite_url = &link.invite_link;

            if let (Some((_png_bytes, qr_path)), Some(cowork_state)) =
                (build_invite_qr(invite_url), cowork.as_ref())
            {
                let user_chat = ChatId(cowork_state.chat_id);
                let _ = photo_in_thread(bot, user_chat, None, InputFile::file(qr_path)).await;
                let _ = message_in_thread(
                    bot,
                    user_chat,
                    None,
                    format!(
                        "🦀 **All set!**\n\n\
                         Invite link: {}\n\n\
                         Share the QR or link. \
                         Everyone auto-registers when they join and send a message.",
                        invite_url
                    ),
                )
                .await;
            }

            let _ = message_in_thread(
                bot,
                ChatId(group_chat_id),
                thread_id,
                "🦀 I'm in! @mention me anytime to chat.\n\n\
                 Everyone here is auto-registered. No setup needed.",
            )
            .await;
        }
        Err(e) => {
            tracing::error!("Cowork: failed to create invite link: {}", e);
            let _ = message_in_thread(
                bot,
                ChatId(group_chat_id),
                thread_id,
                "Failed to generate invite link. Make sure I'm an admin in this group.",
            )
            .await;
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_startgroup_valid() {
        assert_eq!(parse_startgroup_param("cowork_abc123"), Some("abc123"));
    }

    #[test]
    fn parse_startgroup_not_cowork() {
        assert_eq!(parse_startgroup_param("other_param"), None);
    }

    #[test]
    fn parse_startgroup_empty() {
        assert_eq!(parse_startgroup_param(""), None);
    }

    #[test]
    fn parse_startgroup_just_prefix() {
        assert_eq!(parse_startgroup_param("cowork_"), None);
    }

    #[test]
    fn is_cowork_session_true() {
        assert!(is_cowork_session("cowork_xxx"));
    }

    #[test]
    fn is_cowork_session_false() {
        assert!(!is_cowork_session("other"));
    }

    #[test]
    fn build_deep_link_format() {
        let link = build_cowork_deep_link("mybot", "abc123");
        assert_eq!(link, "https://t.me/mybot?startgroup=cowork_abc123");
    }

    #[test]
    fn build_deep_link_with_bot_suffix() {
        let link = build_cowork_deep_link("team_crab_bot", "xyz");
        assert_eq!(link, "https://t.me/team_crab_bot?startgroup=cowork_xyz");
    }

    #[test]
    fn cowork_state_lifecycle() {
        let state = CoworkState::new(123, 456, "abc".to_string());
        assert_eq!(state.user_id, 123);
        assert_eq!(state.chat_id, 456);
        assert_eq!(state.session_id, "abc");
        assert!(!state.is_expired());
    }

    #[test]
    fn invite_qr_generation() {
        let result = build_invite_qr("https://t.me/+AbCdEfGh");
        assert!(result.is_some());
        let (bytes, path) = result.unwrap();
        // PNG magic number
        assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
        assert!(path.exists());
        // Cleanup
        let _ = std::fs::remove_file(path);
    }
}