1mod api;
2mod client;
3mod colorize;
4pub mod commands;
5mod display;
6pub mod login;
7mod verify;
8
9use anyhow::Result;
10use clap::{Parser, Subcommand};
11
12#[derive(Parser)]
13#[command(name = "treehole", about = "北大树洞 CLI 客户端", version)]
14pub struct Cli {
15 #[command(subcommand)]
16 pub command: Commands,
17}
18
19#[derive(Subcommand)]
20pub enum Commands {
21 Login {
23 #[arg(short, long)]
25 password: bool,
26 #[arg(short, long)]
28 username: Option<String>,
29 #[arg(long)]
31 open: bool,
32 },
33 Status,
35 Logout,
37
38 #[command(alias = "ls")]
40 List {
41 #[arg(default_value = "latest")]
43 feed: String,
44 #[arg(short, long, default_value = "1")]
46 page: u32,
47 #[arg(short = 'n', long, default_value = "10")]
49 limit: u32,
50 },
51 Show {
53 pid: i64,
55 },
56 Search {
58 keyword: String,
60 #[arg(short, long, default_value = "1")]
62 page: u32,
63 #[arg(short = 'n', long, default_value = "10")]
65 limit: u32,
66 },
67
68 Post {
70 #[arg(short, long)]
72 text: Option<String>,
73 #[arg(long)]
75 tag: Option<String>,
76 #[arg(long)]
78 named: bool,
79 #[arg(long)]
81 fold: bool,
82 #[arg(long)]
84 reward: Option<i64>,
85 #[arg(short, long)]
87 image: Vec<std::path::PathBuf>,
88 },
89 Reply {
91 pid: i64,
93 #[arg(short, long)]
95 text: Option<String>,
96 #[arg(short, long)]
98 quote: Option<i64>,
99 #[arg(short, long)]
101 image: Option<std::path::PathBuf>,
102 },
103
104 Like {
106 pid: i64,
108 },
109 Tread {
111 pid: i64,
113 },
114 Star {
116 pid: i64,
118 },
119 Unstar {
121 pid: i64,
123 },
124 Stars {
126 #[arg(short, long, default_value = "1")]
127 page: u32,
128 #[arg(short = 'n', long, default_value = "20")]
129 limit: u32,
130 },
131 Follow {
133 pid: i64,
135 },
136 Unfollow {
138 pid: i64,
140 },
141
142 Msg {
144 #[arg(short, long, default_value = "1")]
146 page: u32,
147 #[arg(short = 'n', long, default_value = "20")]
149 limit: u32,
150 },
151 Read {
153 ids: Vec<i64>,
155 },
156
157 Me {
159 #[arg(long)]
161 posts: bool,
162 #[arg(short, long, default_value = "1")]
164 page: u32,
165 #[arg(short = 'n', long, default_value = "10")]
167 limit: u32,
168 },
169
170 Report {
172 pid: i64,
174 reason: String,
176 },
177
178 Score {
180 #[arg(short, long)]
182 semester: Option<String>,
183 #[arg(long)]
185 no_color: bool,
186 },
187 Course {
189 #[arg(long)]
191 times: bool,
192 },
193 #[command(alias = "academic")]
195 AcademicCal {
196 #[arg(short, long)]
198 start: Option<String>,
199 #[arg(short, long)]
201 end: Option<String>,
202 },
203 #[command(alias = "activity")]
205 ActivityCal {
206 #[arg(short, long)]
208 start: Option<String>,
209 #[arg(short, long)]
211 end: Option<String>,
212 #[arg(short, long, default_value = "1")]
214 page: u32,
215 #[arg(short = 'n', long, default_value = "10")]
217 limit: u32,
218 },
219 Schedule {
221 #[arg(short, long)]
223 start: Option<String>,
224 },
225
226 Otp {
228 #[command(subcommand)]
229 action: OtpAction,
230 },
231}
232
233#[derive(Subcommand)]
234pub enum OtpAction {
235 Bind {
237 #[arg(short, long)]
238 username: Option<String>,
239 #[arg(long, conflicts_with = "verify")]
241 send: bool,
242 #[arg(long, value_name = "CODE", conflicts_with = "send")]
244 verify: Option<String>,
245 },
246 Set {
248 secret: String,
249 #[arg(short, long)]
250 username: Option<String>,
251 },
252 Show,
254 Clear,
256}
257
258fn init_tracing() {
259 tracing_subscriber::fmt()
260 .with_env_filter(
261 tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()),
262 )
263 .init();
264}
265
266pub async fn run() -> Result<()> {
267 init_tracing();
268 let cli = Cli::parse();
269 dispatch(cli.command).await
270}
271
272pub async fn run_from<I, T>(args: I) -> Result<()>
273where
274 I: IntoIterator<Item = T>,
275 T: Into<std::ffi::OsString> + Clone,
276{
277 let cli = Cli::try_parse_from(args)?;
278 dispatch(cli.command).await
279}
280
281pub async fn dispatch(command: Commands) -> Result<()> {
282 match command {
283 Commands::Login {
285 password,
286 username,
287 open,
288 } => {
289 if password {
290 login::login_with_password(username.as_deref()).await?;
291 } else {
292 let qr_mode = if open {
293 pkuinfo_common::qr::QrDisplayMode::Open
294 } else {
295 pkuinfo_common::qr::QrDisplayMode::Terminal
296 };
297 login::login_with_qrcode(qr_mode).await?;
298 }
299 }
300 Commands::Status => login::status()?,
301 Commands::Logout => login::logout()?,
302
303 Commands::List { feed, page, limit } => commands::cmd_list(&feed, page, limit).await?,
305 Commands::Show { pid } => commands::cmd_show(pid).await?,
306 Commands::Search {
307 keyword,
308 page,
309 limit,
310 } => commands::cmd_search(&keyword, page, limit).await?,
311
312 Commands::Post {
314 text,
315 tag,
316 named,
317 fold,
318 reward,
319 image,
320 } => commands::cmd_post(text, tag, named, fold, reward, image).await?,
321 Commands::Reply {
322 pid,
323 text,
324 quote,
325 image,
326 } => commands::cmd_reply(pid, text, quote, image).await?,
327
328 Commands::Like { pid } => commands::cmd_like(pid).await?,
330 Commands::Tread { pid } => commands::cmd_tread(pid).await?,
331 Commands::Star { pid } => commands::cmd_star(pid).await?,
332 Commands::Unstar { pid } => commands::cmd_unstar(pid).await?,
333 Commands::Stars { page, limit } => commands::cmd_stars(page, limit).await?,
334 Commands::Follow { pid } => commands::cmd_follow(pid).await?,
335 Commands::Unfollow { pid } => commands::cmd_unfollow(pid).await?,
336
337 Commands::Msg { page, limit } => commands::cmd_msg(page, limit).await?,
339 Commands::Read { ids } => commands::cmd_msg_read(ids).await?,
340
341 Commands::Me { posts, page, limit } => commands::cmd_me(posts, page, limit).await?,
343 Commands::Report { pid, reason } => commands::cmd_report(pid, &reason).await?,
344
345 Commands::Score { semester, no_color } => {
347 commands::cmd_score(semester.as_deref(), no_color).await?
348 }
349 Commands::Course { times } => commands::cmd_course(times).await?,
350 Commands::AcademicCal { start, end } => {
351 commands::cmd_academic_cal(start.as_deref(), end.as_deref()).await?
352 }
353 Commands::ActivityCal {
354 start,
355 end,
356 page,
357 limit,
358 } => commands::cmd_activity_cal(start.as_deref(), end.as_deref(), page, limit).await?,
359 Commands::Schedule { start } => commands::cmd_schedule(start.as_deref()).await?,
360
361 Commands::Otp { action } => {
363 let store = pkuinfo_common::session::Store::new("treehole")?;
364 handle_otp(action, store.config_dir()).await?;
365 }
366 }
367 Ok(())
368}
369
370async fn handle_otp(action: OtpAction, config_dir: &std::path::Path) -> anyhow::Result<()> {
371 use colored::Colorize;
372 match action {
373 OtpAction::Bind {
374 username,
375 send,
376 verify,
377 } => {
378 if send {
379 pkuinfo_common::otp::bind_otp_send_sms(config_dir, username.as_deref()).await?;
380 } else if let Some(code) = verify {
381 pkuinfo_common::otp::bind_otp_verify(config_dir, &code).await?;
382 } else {
383 pkuinfo_common::otp::bind_otp_interactive(config_dir, username.as_deref()).await?;
384 }
385 }
386 OtpAction::Set { secret, username } => {
387 let uid = username.unwrap_or_default();
388 pkuinfo_common::otp::set_otp_secret(config_dir, &secret, &uid)?;
389 }
390 OtpAction::Show => match pkuinfo_common::otp::get_current_otp(config_dir)? {
391 Some(code) => {
392 let config = pkuinfo_common::otp::load_otp_config(config_dir)?
393 .ok_or_else(|| anyhow::anyhow!("OTP 配置文件缺失,请先运行 `otp bind`"))?;
394 println!(
395 "{} {} ({})",
396 "OTP:".green().bold(),
397 code.bold(),
398 config.user_id
399 );
400 }
401 None => {
402 println!(
403 "{} 未配置 OTP。使用 `otp bind` 绑定或 `otp set <SECRET>` 手动设置",
404 "○".red()
405 );
406 }
407 },
408 OtpAction::Clear => {
409 pkuinfo_common::otp::clear_otp_config(config_dir)?;
410 println!("{} OTP 配置已清除", "✓".green());
411 }
412 }
413 Ok(())
414}
415
416pub const VERSION: &str = env!("CARGO_PKG_VERSION");