Skip to main content

pku_treehole/
lib.rs

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    /// 登录北大树洞(通过 IAAA 统一身份认证)
22    Login {
23        /// 使用用户名密码登录(默认为扫码登录)
24        #[arg(short, long)]
25        password: bool,
26        /// 学号/职工号(仅密码登录时需要)
27        #[arg(short, long)]
28        username: Option<String>,
29        /// 用系统图片查看器打开二维码(默认终端渲染)
30        #[arg(long)]
31        open: bool,
32    },
33    /// 查看当前登录状态
34    Status,
35    /// 退出登录
36    Logout,
37
38    /// 浏览帖子列表
39    #[command(alias = "ls")]
40    List {
41        /// 信息流类型:latest(默认)/ follow
42        #[arg(default_value = "latest")]
43        feed: String,
44        /// 页码
45        #[arg(short, long, default_value = "1")]
46        page: u32,
47        /// 每页条数
48        #[arg(short = 'n', long, default_value = "10")]
49        limit: u32,
50    },
51    /// 查看帖子详情及评论
52    Show {
53        /// 帖子 PID
54        pid: i64,
55    },
56    /// 搜索帖子
57    Search {
58        /// 搜索关键词或 #PID
59        keyword: String,
60        /// 页码
61        #[arg(short, long, default_value = "1")]
62        page: u32,
63        /// 每页条数
64        #[arg(short = 'n', long, default_value = "10")]
65        limit: u32,
66    },
67
68    /// 发布新树洞
69    Post {
70        /// 帖子内容(不提供则进入交互式输入)
71        #[arg(short, long)]
72        text: Option<String>,
73        /// 标签 ID(逗号分隔)
74        #[arg(long)]
75        tag: Option<String>,
76        /// 使用昵称发帖(默认匿名)
77        #[arg(long)]
78        named: bool,
79        /// 折叠显示
80        #[arg(long)]
81        fold: bool,
82        /// 悬赏树叶数量
83        #[arg(long)]
84        reward: Option<i64>,
85        /// 图片路径(可多次指定,如 --image a.jpg --image b.png)
86        #[arg(short, long)]
87        image: Vec<std::path::PathBuf>,
88    },
89    /// 回复帖子
90    Reply {
91        /// 帖子 PID
92        pid: i64,
93        /// 回复内容(不提供则进入交互式输入)
94        #[arg(short, long)]
95        text: Option<String>,
96        /// 引用某条评论的 CID
97        #[arg(short, long)]
98        quote: Option<i64>,
99        /// 图片路径(评论仅限一张)
100        #[arg(short, long)]
101        image: Option<std::path::PathBuf>,
102    },
103
104    /// 点赞帖子
105    Like {
106        /// 帖子 PID
107        pid: i64,
108    },
109    /// 踩帖子
110    Tread {
111        /// 帖子 PID
112        pid: i64,
113    },
114    /// 收藏帖子
115    Star {
116        /// 帖子 PID
117        pid: i64,
118    },
119    /// 取消收藏
120    Unstar {
121        /// 帖子 PID
122        pid: i64,
123    },
124    /// 查看收藏列表
125    Stars {
126        #[arg(short, long, default_value = "1")]
127        page: u32,
128        #[arg(short = 'n', long, default_value = "20")]
129        limit: u32,
130    },
131    /// 关注帖子
132    Follow {
133        /// 帖子 PID
134        pid: i64,
135    },
136    /// 取消关注
137    Unfollow {
138        /// 帖子 PID
139        pid: i64,
140    },
141
142    /// 查看消息通知
143    Msg {
144        /// 页码
145        #[arg(short, long, default_value = "1")]
146        page: u32,
147        /// 每页条数
148        #[arg(short = 'n', long, default_value = "20")]
149        limit: u32,
150    },
151    /// 标记消息为已读
152    Read {
153        /// 消息 ID 列表
154        ids: Vec<i64>,
155    },
156
157    /// 查看个人信息
158    Me {
159        /// 同时显示我的帖子
160        #[arg(long)]
161        posts: bool,
162        /// 页码(我的帖子)
163        #[arg(short, long, default_value = "1")]
164        page: u32,
165        /// 每页条数(我的帖子)
166        #[arg(short = 'n', long, default_value = "10")]
167        limit: u32,
168    },
169
170    /// 举报帖子
171    Report {
172        /// 帖子 PID
173        pid: i64,
174        /// 举报原因
175        reason: String,
176    },
177
178    /// 查询成绩(带颜色渲染)
179    Score {
180        /// 只显示指定学期,格式如 "25-26-1"
181        #[arg(short, long)]
182        semester: Option<String>,
183        /// 不显示颜色
184        #[arg(long)]
185        no_color: bool,
186    },
187    /// 查看课表
188    Course {
189        /// 同时显示作息时间
190        #[arg(long)]
191        times: bool,
192    },
193    /// 查看学术日历
194    #[command(alias = "academic")]
195    AcademicCal {
196        /// 起始日期(默认今天),格式 YYYY-MM-DD
197        #[arg(short, long)]
198        start: Option<String>,
199        /// 结束日期(默认30天后),格式 YYYY-MM-DD
200        #[arg(short, long)]
201        end: Option<String>,
202    },
203    /// 查看活动日历
204    #[command(alias = "activity")]
205    ActivityCal {
206        /// 起始日期(默认今天),格式 YYYY-MM-DD
207        #[arg(short, long)]
208        start: Option<String>,
209        /// 结束日期(默认明天),格式 YYYY-MM-DD
210        #[arg(short, long)]
211        end: Option<String>,
212        /// 页码
213        #[arg(short, long, default_value = "1")]
214        page: u32,
215        /// 每页条数
216        #[arg(short = 'n', long, default_value = "10")]
217        limit: u32,
218    },
219    /// 查看本周日程
220    Schedule {
221        /// 起始日期(默认本周一),格式 YYYY-MM-DD
222        #[arg(short, long)]
223        start: Option<String>,
224    },
225
226    /// 手机令牌 (OTP) 管理
227    Otp {
228        #[command(subcommand)]
229        action: OtpAction,
230    },
231}
232
233#[derive(Subcommand)]
234pub enum OtpAction {
235    /// 绑定手机令牌(默认交互式;支持 --send / --verify 两阶段绑定)
236    Bind {
237        #[arg(short, long)]
238        username: Option<String>,
239        /// 只发送短信验证码并保存会话,不等待输入(供 AI Agent 使用)
240        #[arg(long, conflicts_with = "verify")]
241        send: bool,
242        /// 用已保存的会话和指定短信验证码完成绑定
243        #[arg(long, value_name = "CODE", conflicts_with = "send")]
244        verify: Option<String>,
245    },
246    /// 手动设置 TOTP secret
247    Set {
248        secret: String,
249        #[arg(short, long)]
250        username: Option<String>,
251    },
252    /// 查看当前 OTP 码
253    Show,
254    /// 清除已保存的 OTP 配置
255    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        // ── Auth ──
284        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        // ── Browse ──
304        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        // ── Create ──
313        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        // ── Interact ──
329        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        // ── Messages ──
338        Commands::Msg { page, limit } => commands::cmd_msg(page, limit).await?,
339        Commands::Read { ids } => commands::cmd_msg_read(ids).await?,
340
341        // ── User ──
342        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        // ── 洞天 & 成绩 ──
346        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        // ── OTP ──
362        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");