From 4dad239d524ba2ef7876c2077e52ec26a6c4b7a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=9E=97=E4=BC=9F?= Date: Fri, 24 Apr 2026 15:23:09 +0800 Subject: [PATCH 1/2] feat: support reading message text from stdin pipe When neither --text nor --file is provided, the send command now reads text from stdin if it is a pipe or redirected file. Behavior: - --text takes priority over stdin when explicitly provided - --file takes priority over stdin when explicitly provided - If neither --text nor --file is given and stdin is a tty, a clear error is returned instead of waiting for interactive input - Empty piped stdin produces a clear error message Changes: - Extract resolve_send_content() with injectable stdin reader for testability - Add SendContent enum to represent text vs file content - Update CLI ArgGroup to make text/file optional (still mutually exclusive) - Expand send help text with Message Sources section - Add 6 unit tests covering all stdin behavior combinations - Update README.md and SKILL.md with stdin examples and priority rules --- README.md | 42 +++++++++++++++- SKILL.md | 23 ++++++++- src/cli.rs | 18 +++++-- src/commands/send.rs | 113 ++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 182 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d746a44..9745f7f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A CLI tool to interact with a Wechat iLink bot. - Manage multi accounts - Get `context_token` - Send text, images, and files to WeChat users +- Read message text from stdin (pipe) when `--text` is omitted ## Installation @@ -80,10 +81,21 @@ wechat-cli account delete --account ### Get Context Token -Wait for the next incoming message and print the `context_token`: +Wait for the next incoming message and print the `context_token`. + +Using a saved account: + +```bash +wechat-cli get-context-token --account +``` + +Using explicit credentials: ```bash -wechat-cli get-context-token [--user-id ] +wechat-cli get-context-token \ + --bot-token \ + --user-id \ + [--route-tag ] ``` ### Send @@ -97,6 +109,23 @@ wechat-cli send \ --text "hello" ``` +Send text from stdin (pipe): + +```bash +echo "hello from pipe" | wechat-cli send \ + --account \ + --context-token +``` + +Send text from a file via stdin redirection: + +```bash +wechat-cli send \ + --account \ + --context-token \ + < message.txt +``` + Send an image using a saved account: ```bash @@ -127,6 +156,15 @@ wechat-cli send \ --text "hello" ``` +#### Message Source Priority + +When sending, the CLI resolves the message source in this order: + +1. If `--text` is provided, it is used regardless of stdin. +2. If `--file` is provided, it is used regardless of stdin. +3. If neither `--text` nor `--file` is provided, stdin is read when it is a pipe or redirected file. +4. If stdin is an interactive terminal and neither `--text` nor `--file` is provided, an error is returned. + ## Storage Local files are stored under: diff --git a/SKILL.md b/SKILL.md index a5bd25c..626edd4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -67,6 +67,25 @@ wechat-cli send \ --text "hello" ``` +### Send Text from Stdin (Pipe) + +When `--text` is omitted and stdin is a pipe or redirected file, the CLI reads the message body from stdin: + +```bash +echo "hello from pipe" | wechat-cli send \ + --account \ + --context-token +``` + +Or redirect from a file: + +```bash +wechat-cli send \ + --account \ + --context-token \ + < message.txt +``` + ### Send Image (Saved Account) ```bash @@ -116,6 +135,8 @@ wechat-cli send \ - `--context-token` is always required for `send`. - `--text` and `--file` are mutually exclusive. - `--caption` only works with `--file`. +- When neither `--text` nor `--file` is provided, stdin is read if it is a pipe or redirected file. +- When `--text` is explicitly provided, stdin is ignored even if a pipe is present. ## Local Storage @@ -132,4 +153,4 @@ wechat-cli qrcode --help wechat-cli qrcode-status --help wechat-cli account --help wechat-cli send --help -``` \ No newline at end of file +``` diff --git a/src/cli.rs b/src/cli.rs index 3887c9b..ba41f10 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -80,7 +80,7 @@ pub struct AccountDeleteArgs { #[command(after_help = "Authentication Modes: 1. Saved Account Mode: --account - + 2. Explicit Credentials Mode: --bot-token --user-id [--route-tag ] @@ -113,21 +113,29 @@ pub struct GetContextTokenArgs { #[command(group( ArgGroup::new("message") .args(["text", "file"]) - .required(true) + .required(false) .multiple(false) ))] #[command(after_help = "Authentication Modes: 1. Saved Account Mode: --account - + 2. Explicit Credentials Mode: --bot-token --user-id [--route-tag ] +Message Sources: + - --text Plain text message body + - --file File or image to send + - piped stdin Read text from a pipe when neither --text nor --file is given + Usage Rules: - - You must use exactly one of the modes above. + - You must use exactly one of the auth modes above. - --account cannot be combined with --bot-token or --user-id. - In Explicit mode, both --bot-token and --user-id are required. - - --context-token is always required for sending and must be provided explicitly.")] + - --context-token is always required for sending and must be provided explicitly. + - --text and --file are mutually exclusive. + - If neither --text nor --file is provided, the CLI reads from stdin. + This only works when stdin is a pipe or redirected file, not an interactive terminal.")] pub struct SendArgs { #[arg( long, diff --git a/src/commands/send.rs b/src/commands/send.rs index ef1de1c..1531e90 100644 --- a/src/commands/send.rs +++ b/src/commands/send.rs @@ -1,3 +1,4 @@ +use std::io::{self, IsTerminal, Read}; use std::path::Path; use anyhow::{Context, Result, anyhow, bail}; @@ -12,6 +13,38 @@ use crate::{ }, }; +#[derive(Debug, PartialEq)] +pub enum SendContent { + Text(String), + File(std::path::PathBuf), +} + +pub fn resolve_send_content( + text: Option<&str>, + file_path: Option<&Path>, + stdin_reader: &mut dyn Read, + stdin_is_pipe: bool, +) -> Result { + match (text, file_path) { + (Some(text), None) => Ok(SendContent::Text(text.to_string())), + (None, Some(file_path)) => Ok(SendContent::File(file_path.to_path_buf())), + (Some(_), Some(_)) => bail!("`--text` and `--file` cannot be used together"), + (None, None) => { + if !stdin_is_pipe { + bail!( + "one of `--text`, `--file`, or piped stdin is required" + ); + } + let mut buf = String::new(); + stdin_reader.read_to_string(&mut buf)?; + if buf.trim().is_empty() { + bail!("stdin is empty; provide text via `--text` or pipe content"); + } + Ok(SendContent::Text(buf)) + } + } +} + #[allow(clippy::too_many_arguments)] pub async fn run( account: Option, @@ -25,13 +58,14 @@ pub async fn run( ) -> Result<()> { let send_target = resolve_send_target(account, user_id, bot_token, route_tag)?; - match (text, file_path) { - (Some(text), None) => send_text(&send_target, context_token, text).await, - (None, Some(file_path)) => { - send_media(&send_target, context_token, file_path, caption).await + let stdin_is_pipe = !io::stdin().is_terminal(); + let content = resolve_send_content(text, file_path, &mut io::stdin(), stdin_is_pipe)?; + + match content { + SendContent::Text(text) => send_text(&send_target, context_token, &text).await, + SendContent::File(file_path) => { + send_media(&send_target, context_token, &file_path, caption).await } - (Some(_), Some(_)) => bail!("`--text` and `--file` cannot be used together"), - (None, None) => bail!("one of `--text` or `--file` is required"), } } @@ -246,4 +280,71 @@ mod tests { err_msg ); } + + // Tests for resolve_send_content + + #[test] + fn test_text_takes_priority_over_stdin() { + let mut stdin = "stdin content".as_bytes(); + let result = resolve_send_content(Some("hello"), None, &mut stdin, true); + assert_eq!(result.unwrap(), SendContent::Text("hello".to_string())); + } + + #[test] + fn test_file_takes_priority_over_stdin() { + let mut stdin = "stdin content".as_bytes(); + let path = Path::new("/tmp/file.txt"); + let result = resolve_send_content(None, Some(path), &mut stdin, true); + assert_eq!(result.unwrap(), SendContent::File(path.to_path_buf())); + } + + #[test] + fn test_stdin_used_when_no_text_or_file_and_pipe() { + let mut stdin = "hello from stdin".as_bytes(); + let result = resolve_send_content(None, None, &mut stdin, true); + assert_eq!( + result.unwrap(), + SendContent::Text("hello from stdin".to_string()) + ); + } + + #[test] + fn test_error_when_no_text_or_file_and_terminal_stdin() { + let mut stdin = "".as_bytes(); + let result = resolve_send_content(None, None, &mut stdin, false); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("one of `--text`, `--file`, or piped stdin"), + "Expected error about requiring text/file/stdin, got: {}", + err_msg + ); + } + + #[test] + fn test_error_when_both_text_and_file() { + let mut stdin = "".as_bytes(); + let path = Path::new("/tmp/file.txt"); + let result = resolve_send_content(Some("hello"), Some(path), &mut stdin, false); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("cannot be used together"), + "Expected error about mutually exclusive, got: {}", + err_msg + ); + } + + #[test] + fn test_error_when_stdin_empty() { + let mut stdin = " ".as_bytes(); + let result = resolve_send_content(None, None, &mut stdin, true); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("stdin is empty"), + "Expected error about empty stdin, got: {}", + err_msg + ); + } } From d49574470559c70f1caa90d9cbb275f8ed69a4fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=9E=97=E4=BC=9F?= Date: Fri, 24 Apr 2026 15:48:47 +0800 Subject: [PATCH 2/2] style: fix cargo fmt check failure; remove outdated get-context-token help text --- src/cli.rs | 3 +-- src/commands/send.rs | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index ba41f10..682f4a3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -85,8 +85,7 @@ pub struct AccountDeleteArgs { --bot-token --user-id [--route-tag ] Usage Rules: - - Omit all auth params to use the first saved account. - - Or use exactly one of the modes above. + - You must use exactly one of the modes above. - --account cannot be combined with --bot-token or --user-id. - In Explicit mode, both --bot-token and --user-id are required.")] pub struct GetContextTokenArgs { diff --git a/src/commands/send.rs b/src/commands/send.rs index 1531e90..0d8ed49 100644 --- a/src/commands/send.rs +++ b/src/commands/send.rs @@ -31,9 +31,7 @@ pub fn resolve_send_content( (Some(_), Some(_)) => bail!("`--text` and `--file` cannot be used together"), (None, None) => { if !stdin_is_pipe { - bail!( - "one of `--text`, `--file`, or piped stdin is required" - ); + bail!("one of `--text`, `--file`, or piped stdin is required"); } let mut buf = String::new(); stdin_reader.read_to_string(&mut buf)?;