Skip to main content

pku_elective/
lib.rs

1mod api;
2mod client;
3pub mod commands;
4mod config;
5mod display;
6pub mod login;
7
8/// 暴露 client::build 供其他 crate(如 claspider)复用
9pub fn client_build(
10    cookie_store: std::sync::Arc<reqwest_cookie_store::CookieStoreMutex>,
11) -> anyhow::Result<reqwest::Client> {
12    client::build(cookie_store)
13}
14
15use anyhow::Result;
16use clap::{Parser, Subcommand};
17
18#[derive(Parser)]
19#[command(name = "elective", about = "北大选课网 CLI 客户端", version)]
20pub struct Cli {
21    #[command(subcommand)]
22    pub command: Commands,
23}
24
25#[derive(Subcommand)]
26pub enum Commands {
27    /// 登录选课网(通过 IAAA 统一身份认证)
28    Login {
29        /// 使用用户名密码登录(默认为扫码登录)
30        #[arg(short, long)]
31        password: bool,
32        /// 学号/职工号(仅密码登录时需要)
33        #[arg(short, long)]
34        username: Option<String>,
35        /// 用系统图片查看器打开二维码(默认终端渲染)
36        #[arg(long)]
37        open: bool,
38        /// 双学位选择
39        #[arg(short, long, value_enum)]
40        dual: Option<login::DualDegree>,
41    },
42    /// 查看当前登录状态
43    Status,
44    /// 退出登录
45    Logout,
46
47    /// 查看选课结果
48    Show,
49
50    /// 浏览补退选课程列表
51    #[command(alias = "ls")]
52    List {
53        /// 页码(从 1 开始,默认第 1 页)
54        #[arg(short, long)]
55        page: Option<usize>,
56    },
57
58    /// 添加自动选课目标(交互式从补退选列表中选择)
59    Set,
60
61    /// 移除自动选课目标
62    Unset,
63
64    /// 配置验证码识别后端
65    #[command(alias = "captcha")]
66    ConfigCaptcha {
67        /// 后端类型: manual / utool / ttshitu / yunma
68        backend: String,
69    },
70
71    /// 启动自动选课循环(持续监控并尝试选课)
72    Launch {
73        /// 检查间隔(秒,默认 15)
74        #[arg(short = 't', long, default_value = "15")]
75        interval: u64,
76    },
77
78    /// 手机令牌 (OTP) 管理
79    Otp {
80        #[command(subcommand)]
81        action: OtpAction,
82    },
83}
84
85#[derive(Subcommand)]
86pub enum OtpAction {
87    /// 绑定手机令牌(默认交互式;支持 --send / --verify 两阶段绑定)
88    Bind {
89        #[arg(short, long)]
90        username: Option<String>,
91        /// 只发送短信验证码并保存会话,不等待输入(供 AI Agent 使用)
92        #[arg(long, conflicts_with = "verify")]
93        send: bool,
94        /// 用已保存的会话和指定短信验证码完成绑定
95        #[arg(long, value_name = "CODE", conflicts_with = "send")]
96        verify: Option<String>,
97    },
98    /// 手动设置 TOTP secret
99    Set {
100        secret: String,
101        #[arg(short, long)]
102        username: Option<String>,
103    },
104    /// 查看当前 OTP 码
105    Show,
106    /// 清除已保存的 OTP 配置
107    Clear,
108}
109
110fn init_tracing() {
111    tracing_subscriber::fmt()
112        .with_env_filter(
113            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()),
114        )
115        .init();
116}
117
118pub async fn run() -> Result<()> {
119    init_tracing();
120    let cli = Cli::parse();
121    dispatch(cli.command).await
122}
123
124pub async fn run_from<I, T>(args: I) -> Result<()>
125where
126    I: IntoIterator<Item = T>,
127    T: Into<std::ffi::OsString> + Clone,
128{
129    let cli = Cli::try_parse_from(args)?;
130    dispatch(cli.command).await
131}
132
133pub async fn dispatch(command: Commands) -> Result<()> {
134    match command {
135        // ── Auth ──
136        Commands::Login {
137            password,
138            username,
139            open,
140            dual,
141        } => {
142            if password {
143                login::login_with_password(username.as_deref(), dual.as_ref()).await?;
144            } else {
145                let qr_mode = if open {
146                    pkuinfo_common::qr::QrDisplayMode::Open
147                } else {
148                    pkuinfo_common::qr::QrDisplayMode::Terminal
149                };
150                login::login_with_qrcode(qr_mode, dual.as_ref()).await?;
151            }
152        }
153        Commands::Status => login::status()?,
154        Commands::Logout => login::logout()?,
155
156        // ── Browse ──
157        Commands::Show => commands::cmd_show().await?,
158        Commands::List { page } => {
159            // CLI 用 1-indexed,内部 0-indexed
160            let page = page.map(|p| p.saturating_sub(1));
161            commands::cmd_list(page).await?;
162        }
163
164        // ── Auto-elect config ──
165        Commands::Set => commands::cmd_set().await?,
166        Commands::Unset => commands::cmd_unset()?,
167        Commands::ConfigCaptcha { backend } => commands::cmd_config_captcha(&backend)?,
168
169        // ── Launch ──
170        Commands::Launch { interval } => commands::cmd_launch(interval).await?,
171
172        // ── OTP ──
173        Commands::Otp { action } => {
174            let store = pkuinfo_common::session::Store::new("elective")?;
175            handle_otp(action, store.config_dir()).await?;
176        }
177    }
178    Ok(())
179}
180
181async fn handle_otp(action: OtpAction, config_dir: &std::path::Path) -> anyhow::Result<()> {
182    use colored::Colorize;
183    match action {
184        OtpAction::Bind {
185            username,
186            send,
187            verify,
188        } => {
189            if send {
190                pkuinfo_common::otp::bind_otp_send_sms(config_dir, username.as_deref()).await?;
191            } else if let Some(code) = verify {
192                pkuinfo_common::otp::bind_otp_verify(config_dir, &code).await?;
193            } else {
194                pkuinfo_common::otp::bind_otp_interactive(config_dir, username.as_deref()).await?;
195            }
196        }
197        OtpAction::Set { secret, username } => {
198            let uid = username.unwrap_or_default();
199            pkuinfo_common::otp::set_otp_secret(config_dir, &secret, &uid)?;
200        }
201        OtpAction::Show => match pkuinfo_common::otp::get_current_otp(config_dir)? {
202            Some(code) => {
203                let config = pkuinfo_common::otp::load_otp_config(config_dir)?
204                    .ok_or_else(|| anyhow::anyhow!("OTP 配置文件缺失,请先运行 `otp bind`"))?;
205                println!(
206                    "{} {} ({})",
207                    "OTP:".green().bold(),
208                    code.bold(),
209                    config.user_id
210                );
211            }
212            None => {
213                println!(
214                    "{} 未配置 OTP。使用 `otp bind` 绑定或 `otp set <SECRET>` 手动设置",
215                    "○".red()
216                );
217            }
218        },
219        OtpAction::Clear => {
220            pkuinfo_common::otp::clear_otp_config(config_dir)?;
221            println!("{} OTP 配置已清除", "✓".green());
222        }
223    }
224    Ok(())
225}
226
227pub const VERSION: &str = env!("CARGO_PKG_VERSION");