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..682f4a3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -80,13 +80,12 @@ pub struct AccountDeleteArgs { #[command(after_help = "Authentication Modes: 1. Saved Account Mode: --account - + 2. Explicit Credentials Mode: --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 { @@ -113,21 +112,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..0d8ed49 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,36 @@ 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 +56,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 +278,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 + ); + } }