Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey.svg)](#安装)
[![Rust](https://img.shields.io/badge/built%20with-Rust-orange.svg)](https://www.rust-lang.org)

会话 · 聊天记录 · 搜索 · 联系人 · 群成员 · 收藏 · 统计 · 导出
会话 · 聊天记录 · 搜索 · 发送 · 联系人 · 群成员 · 收藏 · 统计 · 导出

</div>

Expand Down Expand Up @@ -38,6 +38,7 @@ npx skills add jackwener/wx-cli -g
- **毫秒级响应** — 后台 daemon 持久缓存解密数据库,mtime 不变则复用
- **AI 友好** — 默认 YAML 输出,更省 token & 易读;`--json` 可切换为 JSON(方便 `jq` 处理等)
- **完全本地** — 数据不出本机,实时解密,无需全量预解密
- **macOS 发送** — 通过微信搜索快捷键打开聊天并发送文本消息(需辅助功能权限)

---

Expand Down Expand Up @@ -150,10 +151,13 @@ wx history "AI群" --since 2026-04-01 --until 2026-04-15
wx search "关键词" # 全库搜索
wx search "关键词" -n 500 # 放宽搜索结果条数
wx search "会议" --in "工作群" --since 2026-01-01
wx send "张三" "你好,今晚 8 点见" # 发送消息(macOS)
```

`history` / `search` / `export` 都支持 `-n` / `--limit` 指定条数。默认值只是为了避免一次性输出过多消息,不是硬上限。

`send` 是 macOS 屏幕自动化命令:它会激活微信,用 `⌘F` 聚焦搜索框,打开目标聊天后发送文本。使用前需保持微信已登录,并给当前终端或 agent 应用开启"辅助功能"权限;发送过程中会临时使用剪贴板。

会话/消息输出里都带 `chat_type` 字段,取值为 `private` / `group` / `official_account` / `folded`。`official_account` 涵盖公众号、订阅号、服务号及 `mphelper` / `qqsafe` 等系统通知;`folded` 对应微信里的"订阅号折叠"和"折叠群聊"两个聚合入口。

### 朋友圈(SNS)
Expand Down
10 changes: 10 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod init;
pub mod sessions;
pub mod history;
pub mod search;
pub mod send;
pub mod contacts;
pub mod export;
pub mod daemon_cmd;
Expand Down Expand Up @@ -92,6 +93,14 @@ enum Commands {
#[arg(long)]
json: bool,
},
/// 发送微信消息(macOS 屏幕自动化)
Send {
/// 聊天对象名称
chat: String,
/// 要发送的消息
#[arg(allow_hyphen_values = true)]
message: String,
},
/// 查看联系人
Contacts {
/// 按名字过滤
Expand Down Expand Up @@ -282,6 +291,7 @@ fn dispatch(cli: Cli) -> Result<()> {
Commands::Search { keyword, chats, limit, since, until, msg_type, json } => {
search::cmd_search(keyword, chats, limit, since, until, msg_type, json)
}
Commands::Send { chat, message } => send::cmd_send(chat, message),
Commands::Contacts { query, limit, json } => contacts::cmd_contacts(query, limit, json),
Commands::Export { chat, since, until, limit, format, output } => {
export::cmd_export(chat, since, until, limit, format, output)
Expand Down
94 changes: 94 additions & 0 deletions src/cli/send.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
use anyhow::Result;

#[cfg(target_os = "macos")]
use anyhow::{bail, Context};
#[cfg(target_os = "macos")]
use std::process::Command;

#[cfg(target_os = "macos")]
const SEND_SCRIPT: &str = r#"
on run argv
if (count of argv) < 2 then error "chat and message are required"
set chatName to item 1 of argv
set messageText to item 2 of argv
set previousClipboard to missing value
try
set previousClipboard to the clipboard as text
end try
try
tell application id "com.tencent.xinWeChat" to activate
delay 0.3
tell application "System Events"
set wxProc to first application process whose bundle identifier is "com.tencent.xinWeChat"
set frontmost of wxProc to true
delay 0.2
keystroke "f" using command down
delay 0.1
keystroke "a" using command down
delay 0.05
my pasteText(chatName)
delay 1.5
key code 36
delay 0.8
my pasteText(messageText)
delay 0.1
key code 36
end tell
my restoreClipboard(previousClipboard)
on error errorMessage number errorNumber
my restoreClipboard(previousClipboard)
error errorMessage number errorNumber
end try
end run

on pasteText(textValue)
tell application "System Events"
set the clipboard to textValue
delay 0.05
keystroke "v" using command down
end tell
end pasteText

on restoreClipboard(previousClipboard)
if previousClipboard is not missing value then set the clipboard to previousClipboard
end restoreClipboard
"#;

#[cfg(target_os = "macos")]
pub fn cmd_send(chat: String, message: String) -> Result<()> {
if chat.trim().is_empty() {
bail!("聊天对象名称不能为空");
}
if message.is_empty() {
bail!("消息不能为空");
}

let output = Command::new("osascript")
.arg("-e")
.arg(SEND_SCRIPT)
.arg(&chat)
.arg(&message)
Comment on lines +59 to +70

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

message 只用 is_empty() 校验会放过仅包含空格/换行的内容,与 PR 描述里“验证空消息”不一致。建议改为基于 message.trim() 判断空白,并(可选)像 chat 一样将 chat.trim() 的结果用于传给脚本/打印,避免用户输入前后空格导致搜索不到会话。

Copilot uses AI. Check for mistakes.
.output()
.context("无法运行 osascript")?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let reason = if stderr.is_empty() {
format!("osascript exited with status {}", output.status)

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

osascript 失败且 stderr 为空时,这里会生成类似 "osascript exited with status exit status: 1" 的重复文案(ExitStatus 的 Display 已包含 "exit status:")。建议改为基于 output.status.code() 组装更清晰的错误原因,或直接格式化 output.status 而不要再额外加 "exited with status" 前缀。

Suggested change
format!("osascript exited with status {}", output.status)
if let Some(code) = output.status.code() {
format!("osascript exited with code {}", code)
} else {
format!("osascript failed with status {}", output.status)
}

Copilot uses AI. Check for mistakes.
} else {
stderr
};
bail!(
"发送微信消息失败:{}。请确认微信已登录,并已给当前终端/应用开启“辅助功能”权限",
reason
);
}

println!("已发送到 {}", chat);
Ok(())
}

#[cfg(not(target_os = "macos"))]
pub fn cmd_send(_chat: String, _message: String) -> Result<()> {
anyhow::bail!("send 命令目前只支持 macOS");
}
Loading