mod api;
mod client;
pub mod commands;
mod config;
mod display;
pub mod login;
pub fn client_build(
cookie_store: std::sync::Arc<reqwest_cookie_store::CookieStoreMutex>,
) -> anyhow::Result<reqwest::Client> {
client::build(cookie_store)
}
use anyhow::Result;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "elective", about = "北大选课网 CLI 客户端", version)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
Login {
#[arg(short, long)]
password: bool,
#[arg(short, long)]
username: Option<String>,
#[arg(long)]
open: bool,
#[arg(short, long, value_enum)]
dual: Option<login::DualDegree>,
},
Status,
Logout,
Show,
#[command(alias = "ls")]
List {
#[arg(short, long)]
page: Option<usize>,
},
Set,
Unset,
#[command(alias = "captcha")]
ConfigCaptcha {
backend: String,
},
Launch {
#[arg(short = 't', long, default_value = "15")]
interval: u64,
},
Otp {
#[command(subcommand)]
action: OtpAction,
},
}
#[derive(Subcommand)]
pub enum OtpAction {
Bind {
#[arg(short, long)]
username: Option<String>,
#[arg(long, conflicts_with = "verify")]
send: bool,
#[arg(long, value_name = "CODE", conflicts_with = "send")]
verify: Option<String>,
},
Set {
secret: String,
#[arg(short, long)]
username: Option<String>,
},
Show,
Clear,
}
fn init_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()),
)
.init();
}
pub async fn run() -> Result<()> {
init_tracing();
let cli = Cli::parse();
dispatch(cli.command).await
}
pub async fn run_from<I, T>(args: I) -> Result<()>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let cli = Cli::try_parse_from(args)?;
dispatch(cli.command).await
}
pub async fn dispatch(command: Commands) -> Result<()> {
match command {
Commands::Login {
password,
username,
open,
dual,
} => {
if password {
login::login_with_password(username.as_deref(), dual.as_ref()).await?;
} else {
let qr_mode = if open {
pkuinfo_common::qr::QrDisplayMode::Open
} else {
pkuinfo_common::qr::QrDisplayMode::Terminal
};
login::login_with_qrcode(qr_mode, dual.as_ref()).await?;
}
}
Commands::Status => login::status()?,
Commands::Logout => login::logout()?,
Commands::Show => commands::cmd_show().await?,
Commands::List { page } => {
let page = page.map(|p| p.saturating_sub(1));
commands::cmd_list(page).await?;
}
Commands::Set => commands::cmd_set().await?,
Commands::Unset => commands::cmd_unset()?,
Commands::ConfigCaptcha { backend } => commands::cmd_config_captcha(&backend)?,
Commands::Launch { interval } => commands::cmd_launch(interval).await?,
Commands::Otp { action } => {
let store = pkuinfo_common::session::Store::new("elective")?;
handle_otp(action, store.config_dir()).await?;
}
}
Ok(())
}
async fn handle_otp(action: OtpAction, config_dir: &std::path::Path) -> anyhow::Result<()> {
use colored::Colorize;
match action {
OtpAction::Bind {
username,
send,
verify,
} => {
if send {
pkuinfo_common::otp::bind_otp_send_sms(config_dir, username.as_deref()).await?;
} else if let Some(code) = verify {
pkuinfo_common::otp::bind_otp_verify(config_dir, &code).await?;
} else {
pkuinfo_common::otp::bind_otp_interactive(config_dir, username.as_deref()).await?;
}
}
OtpAction::Set { secret, username } => {
let uid = username.unwrap_or_default();
pkuinfo_common::otp::set_otp_secret(config_dir, &secret, &uid)?;
}
OtpAction::Show => match pkuinfo_common::otp::get_current_otp(config_dir)? {
Some(code) => {
let config = pkuinfo_common::otp::load_otp_config(config_dir)?
.ok_or_else(|| anyhow::anyhow!("OTP 配置文件缺失,请先运行 `otp bind`"))?;
println!(
"{} {} ({})",
"OTP:".green().bold(),
code.bold(),
config.user_id
);
}
None => {
println!(
"{} 未配置 OTP。使用 `otp bind` 绑定或 `otp set <SECRET>` 手动设置",
"○".red()
);
}
},
OtpAction::Clear => {
pkuinfo_common::otp::clear_otp_config(config_dir)?;
println!("{} OTP 配置已清除", "✓".green());
}
}
Ok(())
}
pub const VERSION: &str = env!("CARGO_PKG_VERSION");