mod api;
mod client;
mod colorize;
pub mod commands;
mod display;
pub mod login;
mod verify;
use anyhow::Result;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "treehole", 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,
},
Status,
Logout,
#[command(alias = "ls")]
List {
#[arg(default_value = "latest")]
feed: String,
#[arg(short, long, default_value = "1")]
page: u32,
#[arg(short = 'n', long, default_value = "10")]
limit: u32,
},
Show {
pid: i64,
},
Search {
keyword: String,
#[arg(short, long, default_value = "1")]
page: u32,
#[arg(short = 'n', long, default_value = "10")]
limit: u32,
},
Post {
#[arg(short, long)]
text: Option<String>,
#[arg(long)]
tag: Option<String>,
#[arg(long)]
named: bool,
#[arg(long)]
fold: bool,
#[arg(long)]
reward: Option<i64>,
#[arg(short, long)]
image: Vec<std::path::PathBuf>,
},
Reply {
pid: i64,
#[arg(short, long)]
text: Option<String>,
#[arg(short, long)]
quote: Option<i64>,
#[arg(short, long)]
image: Option<std::path::PathBuf>,
},
Like {
pid: i64,
},
Tread {
pid: i64,
},
Star {
pid: i64,
},
Unstar {
pid: i64,
},
Stars {
#[arg(short, long, default_value = "1")]
page: u32,
#[arg(short = 'n', long, default_value = "20")]
limit: u32,
},
Follow {
pid: i64,
},
Unfollow {
pid: i64,
},
Msg {
#[arg(short, long, default_value = "1")]
page: u32,
#[arg(short = 'n', long, default_value = "20")]
limit: u32,
},
Read {
ids: Vec<i64>,
},
Me {
#[arg(long)]
posts: bool,
#[arg(short, long, default_value = "1")]
page: u32,
#[arg(short = 'n', long, default_value = "10")]
limit: u32,
},
Report {
pid: i64,
reason: String,
},
Score {
#[arg(short, long)]
semester: Option<String>,
#[arg(long)]
no_color: bool,
},
Course {
#[arg(long)]
times: bool,
},
#[command(alias = "academic")]
AcademicCal {
#[arg(short, long)]
start: Option<String>,
#[arg(short, long)]
end: Option<String>,
},
#[command(alias = "activity")]
ActivityCal {
#[arg(short, long)]
start: Option<String>,
#[arg(short, long)]
end: Option<String>,
#[arg(short, long, default_value = "1")]
page: u32,
#[arg(short = 'n', long, default_value = "10")]
limit: u32,
},
Schedule {
#[arg(short, long)]
start: Option<String>,
},
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 } => {
if password {
login::login_with_password(username.as_deref()).await?;
} else {
let qr_mode = if open {
pkuinfo_common::qr::QrDisplayMode::Open
} else {
pkuinfo_common::qr::QrDisplayMode::Terminal
};
login::login_with_qrcode(qr_mode).await?;
}
}
Commands::Status => login::status()?,
Commands::Logout => login::logout()?,
Commands::List { feed, page, limit } => commands::cmd_list(&feed, page, limit).await?,
Commands::Show { pid } => commands::cmd_show(pid).await?,
Commands::Search { keyword, page, limit } => {
commands::cmd_search(&keyword, page, limit).await?
}
Commands::Post { text, tag, named, fold, reward, image } => {
commands::cmd_post(text, tag, named, fold, reward, image).await?
}
Commands::Reply { pid, text, quote, image } => {
commands::cmd_reply(pid, text, quote, image).await?
}
Commands::Like { pid } => commands::cmd_like(pid).await?,
Commands::Tread { pid } => commands::cmd_tread(pid).await?,
Commands::Star { pid } => commands::cmd_star(pid).await?,
Commands::Unstar { pid } => commands::cmd_unstar(pid).await?,
Commands::Stars { page, limit } => commands::cmd_stars(page, limit).await?,
Commands::Follow { pid } => commands::cmd_follow(pid).await?,
Commands::Unfollow { pid } => commands::cmd_unfollow(pid).await?,
Commands::Msg { page, limit } => commands::cmd_msg(page, limit).await?,
Commands::Read { ids } => commands::cmd_msg_read(ids).await?,
Commands::Me { posts, page, limit } => commands::cmd_me(posts, page, limit).await?,
Commands::Report { pid, reason } => commands::cmd_report(pid, &reason).await?,
Commands::Score { semester, no_color } => {
commands::cmd_score(semester.as_deref(), no_color).await?
}
Commands::Course { times } => commands::cmd_course(times).await?,
Commands::AcademicCal { start, end } => {
commands::cmd_academic_cal(start.as_deref(), end.as_deref()).await?
}
Commands::ActivityCal { start, end, page, limit } => {
commands::cmd_activity_cal(start.as_deref(), end.as_deref(), page, limit).await?
}
Commands::Schedule { start } => commands::cmd_schedule(start.as_deref()).await?,
Commands::Otp { action } => {
let store = pkuinfo_common::session::Store::new("treehole")?;
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)?
.expect("OTP 配置存在");
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(())
}