beam-cli 0.3.2

CLI entrypoint for beam, a Rust runtime that connects Feishu/Lark threads to local AI coding CLIs
use std::time::Duration;

use anyhow::{Context, Result, bail};
use qrcode::{QrCode, render::unicode};
use reqwest::Client;
use serde::Deserialize;
use tokio::time::{Instant, sleep};
use url::Url;

const FEISHU_BASE_URL: &str = "https://accounts.feishu.cn";
const LARK_BASE_URL: &str = "https://accounts.larksuite.com";
const REGISTRATION_PATH: &str = "/oauth/v1/app/registration";

#[derive(Debug)]
pub struct SetupCredentials {
    pub app_id: String,
    pub app_secret: String,
    pub user_open_id: Option<String>,
    tenant_brand: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
struct UserInfo {
    open_id: Option<String>,
    tenant_brand: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
struct RegistrationResponse {
    device_code: Option<String>,
    verification_uri_complete: Option<String>,
    expires_in: Option<u64>,
    interval: Option<u64>,
    client_id: Option<String>,
    client_secret: Option<String>,
    user_info: Option<UserInfo>,
    error: Option<String>,
    error_description: Option<String>,
}

#[derive(Debug)]
enum PollOutcome {
    Pending,
    SlowDown,
    SwitchToLark,
    Complete(SetupCredentials),
}

pub async fn prompt_credentials() -> Result<SetupCredentials> {
    println!("── 飞书应用建立 ──\n");
    println!("1) 扫码建应用(推荐,一步拿到 AppID/Secret,需要飞书 App 扫码)");
    println!("2) 手动粘 AppID/Secret(已经在开放平台创建好应用了)\n");
    let choice = super::ask_line("选择 [1]: ")?;

    if choice.trim() != "2" {
        match register_app().await {
            Ok(credentials) => {
                println!("\n✅ 应用创建成功");
                println!("   App ID: {}", credentials.app_id);
                if let Some(open_id) = &credentials.user_open_id {
                    println!("   扫码人 open_id: {}(已加入允许用户)", open_id);
                }
                return Ok(credentials);
            }
            Err(err) => {
                if err.to_string().contains("用户取消扫码") {
                    return Err(err);
                }
                println!("\n⚠️  扫码创建失败: {err:#}");
                println!("   已降级到手动输入 AppID/Secret。\n");
            }
        }
    }

    let app_id = super::ask_line("Lark AppID: ")?;
    let app_secret = super::ask_line("Lark AppSecret: ")?;
    if app_id.is_empty() || app_secret.is_empty() {
        bail!("AppID/AppSecret 不能为空");
    }
    Ok(SetupCredentials {
        app_id,
        app_secret,
        user_open_id: None,
        tenant_brand: None,
    })
}

async fn register_app() -> Result<SetupCredentials> {
    let client = Client::new();
    let begin = request_registration(
        &client,
        FEISHU_BASE_URL,
        &[
            ("action", "begin"),
            ("archetype", "PersonalAgent"),
            ("auth_method", "client_secret"),
            ("request_user_info", "open_id"),
        ],
    )
    .await
    .context("无法发起飞书应用注册")?;

    if let Some(error) = begin.error {
        bail!("{}: {}", error, begin.error_description.unwrap_or_default());
    }
    let device_code = begin.device_code.context("注册响应缺少 device_code")?;
    let mut verification_url = Url::parse(
        begin
            .verification_uri_complete
            .as_deref()
            .context("注册响应缺少 verification_uri_complete")?,
    )?;
    verification_url
        .query_pairs_mut()
        .append_pair("from", "sdk")
        .append_pair("source", "node-sdk/beam")
        .append_pair("tp", "sdk");

    let expires_in = begin.expires_in.unwrap_or(600);
    print_qr_code(verification_url.as_str(), expires_in)?;

    let deadline = Instant::now() + Duration::from_secs(expires_in);
    let mut interval = Duration::from_secs(begin.interval.unwrap_or(5));
    let mut base_url = FEISHU_BASE_URL;
    let mut switched_to_lark = false;

    loop {
        if Instant::now() >= deadline {
            bail!("二维码已过期,请重试");
        }
        let poll_params = [("action", "poll"), ("device_code", device_code.as_str())];
        let poll = request_registration(&client, base_url, &poll_params);
        let response = tokio::select! {
            result = poll => result.context("轮询应用注册状态失败")?,
            _ = tokio::signal::ctrl_c() => bail!("用户取消扫码"),
        };

        match classify_poll(response, !switched_to_lark)? {
            PollOutcome::Complete(credentials) => {
                if credentials.tenant_brand.as_deref() == Some("lark") {
                    bail!("检测到 Lark 国际版租户;beam 当前运行链路仅支持飞书 (feishu.cn)");
                }
                return Ok(credentials);
            }
            PollOutcome::Pending => {}
            PollOutcome::SlowDown => {
                interval += Duration::from_secs(5);
                println!("轮询过快,间隔自动调整到 {} 秒。", interval.as_secs());
            }
            PollOutcome::SwitchToLark if !switched_to_lark => {
                base_url = LARK_BASE_URL;
                switched_to_lark = true;
                println!("识别到国际版租户,已切换到 larksuite.com 继续轮询。");
                continue;
            }
            PollOutcome::SwitchToLark => {}
        }

        let remaining = deadline.saturating_duration_since(Instant::now());
        tokio::select! {
            _ = sleep(interval.min(remaining)) => {}
            _ = tokio::signal::ctrl_c() => bail!("用户取消扫码"),
        }
    }
}

async fn request_registration(
    client: &Client,
    base_url: &str,
    params: &[(&str, &str)],
) -> Result<RegistrationResponse> {
    let body: String = url::form_urlencoded::Serializer::new(String::new())
        .extend_pairs(params.iter().copied())
        .finish();
    let response = client
        .post(format!("{base_url}{REGISTRATION_PATH}"))
        .header("content-type", "application/x-www-form-urlencoded")
        .body(body)
        .send()
        .await?;
    let status = response.status();
    let body = response
        .json::<RegistrationResponse>()
        .await
        .context("应用注册接口返回了无效 JSON")?;
    if !status.is_success() && body.error.is_none() {
        bail!("应用注册接口返回 HTTP {status}");
    }
    Ok(body)
}

fn classify_poll(response: RegistrationResponse, allow_lark_switch: bool) -> Result<PollOutcome> {
    if response
        .user_info
        .as_ref()
        .and_then(|info| info.tenant_brand.as_deref())
        == Some("lark")
        && allow_lark_switch
    {
        return Ok(PollOutcome::SwitchToLark);
    }

    if let (Some(app_id), Some(app_secret)) = (response.client_id, response.client_secret) {
        let tenant_brand = response
            .user_info
            .as_ref()
            .and_then(|info| info.tenant_brand.clone());
        let user_open_id = response
            .user_info
            .and_then(|info| info.open_id)
            .filter(|value| value.starts_with("ou_"));
        return Ok(PollOutcome::Complete(SetupCredentials {
            app_id,
            app_secret,
            user_open_id,
            tenant_brand,
        }));
    }

    match response.error.as_deref() {
        None | Some("authorization_pending") => Ok(PollOutcome::Pending),
        Some("slow_down") => Ok(PollOutcome::SlowDown),
        Some("access_denied") => bail!("用户拒绝了应用创建授权"),
        Some("expired_token") => bail!("二维码已过期,请重试"),
        Some(error) => bail!(
            "{}: {}",
            error,
            response.error_description.unwrap_or_default()
        ),
    }
}

fn print_qr_code(url: &str, expires_in: u64) -> Result<()> {
    let code = QrCode::new(url.as_bytes()).context("无法生成二维码")?;
    let image = code.render::<unicode::Dense1x2>().quiet_zone(true).build();
    let minutes = ((expires_in + 30) / 60).max(1);
    eprintln!("\n请用飞书 App 扫码完成应用创建:\n\n{image}");
    eprintln!("\n二维码有效期约 {minutes} 分钟。也可在浏览器打开:\n  {url}\n");
    Ok(())
}

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

    fn response(raw: &str) -> RegistrationResponse {
        serde_json::from_str(raw).unwrap()
    }

    #[test]
    fn poll_success_returns_credentials_and_scanner_open_id() {
        let outcome = classify_poll(
            response(
                r#"{
                "client_id":"cli_test",
                "client_secret":"secret",
                "user_info":{"tenant_brand":"feishu","open_id":"ou_owner"}
            }"#,
            ),
            true,
        )
        .unwrap();
        let PollOutcome::Complete(credentials) = outcome else {
            panic!("expected completed registration");
        };
        assert_eq!(credentials.app_id, "cli_test");
        assert_eq!(credentials.app_secret, "secret");
        assert_eq!(credentials.user_open_id.as_deref(), Some("ou_owner"));
    }

    #[test]
    fn lark_tenant_switches_domain_before_accepting_credentials() {
        let outcome = classify_poll(
            response(r#"{"user_info":{"tenant_brand":"lark","open_id":"ou_owner"}}"#),
            true,
        )
        .unwrap();
        assert!(matches!(outcome, PollOutcome::SwitchToLark));
    }

    #[test]
    fn lark_credentials_complete_after_domain_switch() {
        let outcome = classify_poll(
            response(
                r#"{
                    "client_id":"cli_lark",
                    "client_secret":"secret",
                    "user_info":{"tenant_brand":"lark","open_id":"ou_owner"}
                }"#,
            ),
            false,
        )
        .unwrap();
        let PollOutcome::Complete(credentials) = outcome else {
            panic!("expected completed registration");
        };
        assert_eq!(credentials.tenant_brand.as_deref(), Some("lark"));
    }

    #[test]
    fn pending_and_slow_down_are_retryable() {
        assert!(matches!(
            classify_poll(response(r#"{"error":"authorization_pending"}"#), true).unwrap(),
            PollOutcome::Pending
        ));
        assert!(matches!(
            classify_poll(response(r#"{"error":"slow_down"}"#), true).unwrap(),
            PollOutcome::SlowDown
        ));
    }

    #[test]
    fn denied_and_expired_are_terminal() {
        assert!(
            classify_poll(response(r#"{"error":"access_denied"}"#), true)
                .unwrap_err()
                .to_string()
                .contains("拒绝")
        );
        assert!(
            classify_poll(response(r#"{"error":"expired_token"}"#), true)
                .unwrap_err()
                .to_string()
                .contains("过期")
        );
    }
}