beam-daemon 0.5.0

Daemon process for beam that receives Feishu/Lark events and manages coding sessions
Documentation
use super::*;

pub(crate) fn build_adopt_already_attached_reply(session: &Session) -> String {
    let adopted = match session.adopted_from.as_ref() {
        Some(adopted) => adopted,
        None => return "session is not adopted".to_string(),
    };
    let cli_name = adopted.cli_id.as_deref().unwrap_or("cli");
    let pane = adopted
        .tmux_target
        .as_deref()
        .map(str::to_string)
        .or_else(|| {
            adopted
                .zellij_session
                .as_deref()
                .zip(adopted.zellij_pane_id.as_deref())
                .map(|(session, pane_id)| format!("{}/{}", session, pane_id))
        })
        .unwrap_or_else(|| "unknown".to_string());
    format!(
        "session already adopted from {} ({})\ndisconnect it before running /adopt again",
        cli_name, pane
    )
}

pub(crate) fn build_adopt_zellij_result_reply(result: Result<&SessionSummary, &str>) -> String {
    match result {
        Ok(session) => format!("adopted {}", session.session_id),
        Err(err) => format!("adopt failed: {}", err),
    }
}

pub(crate) fn build_zellij_adopt_list_reply(items: &[ZellijAdoptCandidate]) -> String {
    if items.is_empty() {
        return "no zellij sessions available for adoption".to_string();
    }
    let mut out = String::from("Available zellij sessions:\n");
    for item in items {
        out.push_str(&format!(
            "  {}:{}  {}  {}\n",
            item.zellij_session, item.zellij_pane_id, item.title, item.cwd
        ));
    }
    out.push_str("\n/adopt <session>:<pane_id>");
    out
}

pub(crate) fn build_closed_session_reply(session: &Session) -> String {
    format!(
        "session closed: {}\nresume: beam session resume {}",
        session.session_id, session.session_id
    )
}

pub(crate) fn build_closed_session_card(session: &Session) -> String {
    let locale = session.locale.as_deref();
    let title = if session.title.trim().is_empty() {
        session
            .cli_id
            .clone()
            .unwrap_or_else(|| session.session_id.clone())
    } else {
        session.title.clone()
    };
    let cli_name = session.cli_id.clone().unwrap_or_else(|| "cli".to_string());
    let resume_cmd = format!("beam session resume {}", session.session_id);
    let working_dir = session.working_dir.clone().unwrap_or_default();
    let body_zh = if working_dir.is_empty() {
        format!(
            "**{}**\n{} 已终止。\n恢复命令:\n```bash\n{}\n```",
            title, cli_name, resume_cmd
        )
    } else {
        format!(
            "**{}**\n{} 已终止。\n恢复命令:\n```bash\n{}\n```\n工作目录:`{}`",
            title, cli_name, resume_cmd, working_dir
        )
    };
    let body_en = if working_dir.is_empty() {
        format!(
            "**{}**\n{} terminated.\nresume with:\n```bash\n{}\n```",
            title, cli_name, resume_cmd
        )
    } else {
        format!(
            "**{}**\n{} terminated.\nresume with:\n```bash\n{}\n```\nworking dir: `{}`",
            title, cli_name, resume_cmd, working_dir
        )
    };
    serde_json::json!({
        "config": { "wide_screen_mode": true },
        "header": {
            "title": card_i18n::plain_text(locale, "会话已关闭", "session closed"),
            "template": "grey"
        },
        "elements": [
            { "tag": "markdown", "content": body_en, "i18n_content": { "zh_cn": body_zh, "en_us": body_en } },
            {
                "tag": "action",
                "actions": [{
                    "tag": "button",
                    "text": card_i18n::plain_text(locale, "恢复会话", "Resume session"),
                    "type": "primary",
                    "value": {
                        "action": "resume",
                        "root_id": session.root_message_id,
                        "session_id": session.session_id,
                        "cli_id": session.cli_id.clone().unwrap_or_else(|| "cli".to_string())
                    }
                }]
            }
        ]
    })
    .to_string()
}

pub(crate) fn build_close_result_reply(
    session: &Session,
    result: Result<StatusCode, &str>,
) -> String {
    match result {
        Ok(_) => build_closed_session_reply(session),
        Err(err) => format!("close failed: {}", err),
    }
}

pub(crate) fn build_restart_result_reply(result: Result<StatusCode, &str>) -> String {
    match result {
        Ok(_) => "session restarting".to_string(),
        Err(err) => format!("restart failed: {}", err),
    }
}

pub(crate) fn decide_lark_card_delivery(session: &Session) -> LarkCardDeliveryPlan {
    if session.lark_app_id == "local"
        || session.root_message_id.is_empty()
        || session.terminal_url.is_none()
    {
        return LarkCardDeliveryPlan::NotReady;
    }
    if session.stream_card_id.is_some() {
        LarkCardDeliveryPlan::PatchExisting
    } else {
        LarkCardDeliveryPlan::PostNew
    }
}

pub(crate) fn build_card_not_ready_reply() -> &'static str {
    "session card not ready"
}

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

    #[test]
    fn lark_reply_message_includes_reply_in_thread_when_true() {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .expect("runtime");
        rt.block_on(async {
            let _env_lock = lark_base_url_env_lock().lock().expect("lark env lock");
            let base_url = start_mock_lark_server().await;
            let _env_guard = LarkBaseUrlEnvGuard::set(&base_url);

            let paths = temp_paths("reply-thread");
            maybe_remove_dir(&paths.root().to_path_buf());

            let app_id = "app-reply";
            let bot = BotConfig {
                name: None,
                lark_app_id: app_id.to_string(),
                lark_app_secret: "secret".to_string(),
                cli_id: "codex".to_string(),
                cli_bin: None,
                cli_args: Vec::new(),
                skip_working_dir_prompt: false,
                model: None,
                working_dir: None,
                lark_encrypt_key: None,
                lark_verification_token: None,
                allowed_users: Vec::new(),
                private_card: false,
                allowed_chat_groups: Vec::new(),
                chat_grants: std::collections::HashMap::new(),
                global_grants: Vec::new(),
                oncall_chats: Vec::new(),
                restrict_grant_commands: false,
                message_quota: None,
                quota_state: std::collections::HashMap::new(),
            };
            let state = make_state(
                paths.clone(),
                HashMap::from([(app_id.to_string(), bot.clone())]),
            );

            let result =
                lark_reply_message_with_opts(&state, &bot, "msg-reply-1", "test message", true)
                    .await;
            assert!(result.is_ok());
            assert_eq!(result.unwrap(), "om_reply_mock");

            maybe_remove_dir(&paths.root().to_path_buf());
        });
    }

    #[test]
    fn user_notify_uses_reply_in_thread_for_thread_scope_session() {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .expect("runtime");
        rt.block_on(async {
            let _env_lock = lark_base_url_env_lock().lock().expect("lark env lock");
            let base_url = start_mock_lark_server().await;
            let _env_guard = LarkBaseUrlEnvGuard::set(&base_url);

            let paths = temp_paths("notify-thread");
            maybe_remove_dir(&paths.root().to_path_buf());

            let app_id = "app-notify-thread";
            let bot = BotConfig {
                name: None,
                lark_app_id: app_id.to_string(),
                lark_app_secret: "secret".to_string(),
                cli_id: "codex".to_string(),
                cli_bin: None,
                cli_args: Vec::new(),
                skip_working_dir_prompt: false,
                model: None,
                working_dir: None,
                lark_encrypt_key: None,
                lark_verification_token: None,
                allowed_users: Vec::new(),
                private_card: false,
                allowed_chat_groups: Vec::new(),
                chat_grants: std::collections::HashMap::new(),
                global_grants: Vec::new(),
                oncall_chats: Vec::new(),
                restrict_grant_commands: false,
                message_quota: None,
                quota_state: std::collections::HashMap::new(),
            };
            let state = make_state(
                paths.clone(),
                HashMap::from([(app_id.to_string(), bot.clone())]),
            );

            let session_id = "sess-notify-thread-1";
            let mut session = make_session(session_id);
            session.lark_app_id = app_id.to_string();
            session.scope = SessionScope::Thread;
            session.root_message_id = "root-notify-1".to_string();
            session.chat_id = "chat-notify-1".to_string();
            session.status = SessionStatus::Active;
            {
                let mut sessions = state.sessions.lock().await;
                sessions.insert(session_id.to_string(), session);
            }

            let snapshot = {
                let sessions = state.sessions.lock().await;
                sessions.get(session_id).cloned().expect("stored session")
            };
            let result = match snapshot.scope {
                SessionScope::Thread if !snapshot.root_message_id.is_empty() => {
                    lark_reply_message_with_opts(
                        &state,
                        &bot,
                        &snapshot.root_message_id,
                        "notify message",
                        true,
                    )
                    .await
                }
                _ => {
                    lark_send_chat_message(&state, &bot, &snapshot.chat_id, "notify message").await
                }
            };
            assert!(result.is_ok());
            assert_eq!(result.unwrap(), "om_reply_mock");

            maybe_remove_dir(&paths.root().to_path_buf());
        });
    }

    #[test]
    fn user_notify_uses_send_for_chat_scope_session() {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .expect("runtime");
        rt.block_on(async {
            let _env_lock = lark_base_url_env_lock().lock().expect("lark env lock");
            let base_url = start_mock_lark_server().await;
            let _env_guard = LarkBaseUrlEnvGuard::set(&base_url);

            let paths = temp_paths("notify-chat");
            maybe_remove_dir(&paths.root().to_path_buf());

            let app_id = "app-notify-chat";
            let bot = BotConfig {
                name: None,
                lark_app_id: app_id.to_string(),
                lark_app_secret: "secret".to_string(),
                cli_id: "codex".to_string(),
                cli_bin: None,
                cli_args: Vec::new(),
                skip_working_dir_prompt: false,
                model: None,
                working_dir: None,
                lark_encrypt_key: None,
                lark_verification_token: None,
                allowed_users: Vec::new(),
                private_card: false,
                allowed_chat_groups: Vec::new(),
                chat_grants: std::collections::HashMap::new(),
                global_grants: Vec::new(),
                oncall_chats: Vec::new(),
                restrict_grant_commands: false,
                message_quota: None,
                quota_state: std::collections::HashMap::new(),
            };
            let state = make_state(
                paths.clone(),
                HashMap::from([(app_id.to_string(), bot.clone())]),
            );

            let session_id = "sess-notify-chat-1";
            let mut session = make_session(session_id);
            session.lark_app_id = app_id.to_string();
            session.scope = SessionScope::Chat;
            session.root_message_id = "root-notify-chat-1".to_string();
            session.chat_id = "chat-notify-1".to_string();
            session.status = SessionStatus::Active;
            {
                let mut sessions = state.sessions.lock().await;
                sessions.insert(session_id.to_string(), session);
            }

            let snapshot = {
                let sessions = state.sessions.lock().await;
                sessions.get(session_id).cloned().expect("stored session")
            };
            let result = match snapshot.scope {
                SessionScope::Thread if !snapshot.root_message_id.is_empty() => {
                    lark_reply_message_with_opts(
                        &state,
                        &bot,
                        &snapshot.root_message_id,
                        "notify message",
                        true,
                    )
                    .await
                }
                _ => {
                    lark_send_chat_message(&state, &bot, &snapshot.chat_id, "notify message").await
                }
            };
            assert!(result.is_ok());
            assert_eq!(result.unwrap(), "om_send_mock");

            maybe_remove_dir(&paths.root().to_path_buf());
        });
    }
}