Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -80,10 +81,21 @@ wechat-cli account delete --account <index>

### 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 <index>
```

Using explicit credentials:

```bash
wechat-cli get-context-token [--user-id <user_id>]
wechat-cli get-context-token \
--bot-token <bot_token> \
--user-id <user_id> \
[--route-tag <route_tag>]
```

### Send
Expand All @@ -97,6 +109,23 @@ wechat-cli send \
--text "hello"
```

Send text from stdin (pipe):

```bash
echo "hello from pipe" | wechat-cli send \
--account <index> \
--context-token <token>
```

Send text from a file via stdin redirection:

```bash
wechat-cli send \
--account <index> \
--context-token <token> \
< message.txt
```

Send an image using a saved account:

```bash
Expand Down Expand Up @@ -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:
Expand Down
23 changes: 22 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <index> \
--context-token <token>
```

Or redirect from a file:

```bash
wechat-cli send \
--account <index> \
--context-token <token> \
< message.txt
```

### Send Image (Saved Account)

```bash
Expand Down Expand Up @@ -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

Expand All @@ -132,4 +153,4 @@ wechat-cli qrcode --help
wechat-cli qrcode-status --help
wechat-cli account --help
wechat-cli send --help
```
```
21 changes: 14 additions & 7 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,12 @@ pub struct AccountDeleteArgs {
#[command(after_help = "Authentication Modes:
1. Saved Account Mode:
--account <index>

2. Explicit Credentials Mode:
--bot-token <token> --user-id <user_id> [--route-tag <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 {
Expand All @@ -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 <index>

2. Explicit Credentials Mode:
--bot-token <token> --user-id <user_id> [--route-tag <tag>]

Message Sources:
- --text <text> Plain text message body
- --file <path> 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,
Expand Down
111 changes: 105 additions & 6 deletions src/commands/send.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::io::{self, IsTerminal, Read};
use std::path::Path;

use anyhow::{Context, Result, anyhow, bail};
Expand All @@ -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<SendContent> {
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<usize>,
Expand All @@ -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"),
}
}

Expand Down Expand Up @@ -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
);
}
}
Loading