@spritesensei/wechat-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -0
- package/bin/wechat-cli.js +3 -0
- package/package.json +52 -0
- package/skills/wechat-cli/SKILL.md +78 -0
- package/src/commands/login.js +56 -0
- package/src/commands/logout.js +26 -0
- package/src/commands/messages.js +69 -0
- package/src/commands/send.js +67 -0
- package/src/commands/status.js +39 -0
- package/src/core/bot.js +27 -0
- package/src/main.js +30 -0
- package/src/utils/output.js +23 -0
- package/src/utils/qr.js +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# wechat-cli
|
|
2
|
+
|
|
3
|
+
基于 `@wechatbot/wechatbot` SDK 的微信命令行工具,类似 lark-cli 风格。
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g @spritesensei/wechat-cli
|
|
7
|
+
wechat-cli --help
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 安装
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# 全局安装(推荐)
|
|
16
|
+
npm install -g @spritesensei/wechat-cli
|
|
17
|
+
|
|
18
|
+
# 或从源码
|
|
19
|
+
git clone https://github.com/snowball-dev/wechat-cli.git
|
|
20
|
+
cd wechat-cli
|
|
21
|
+
npm install
|
|
22
|
+
npm link
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## AI Agent Skill
|
|
26
|
+
|
|
27
|
+
wechat-cli 提供 AI Agent Skill,支持 Claude Code、Cursor、Windsurf、Gemini CLI 等 70+ AI 工具安装后通过 `/wechat-cli` 调出用法。
|
|
28
|
+
|
|
29
|
+
skill 源文件在项目的 `skills/wechat-cli/SKILL.md`,可通过 npm 包分发安装。
|
|
30
|
+
|
|
31
|
+
### 安装 Agent Skill
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# 全局安装(推荐,本机所有对话可用)
|
|
35
|
+
npx skills add @spritesensei/wechat-cli -g
|
|
36
|
+
|
|
37
|
+
# 项目级安装(仅该项目目录下可用)
|
|
38
|
+
npx skills add @spritesensei/wechat-cli
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`skills` CLI 的两种模式:
|
|
42
|
+
|
|
43
|
+
| 模式 | 安装位置 | 作用范围 |
|
|
44
|
+
|------|---------|---------|
|
|
45
|
+
| 默认(不加 `-g`) | `项目目录/.agents/skills/` | 仅该项目 |
|
|
46
|
+
| `-g`(全局) | `~/.agents/skills/` | 本机所有会话 |
|
|
47
|
+
|
|
48
|
+
安装后,在对话中输入 `/wechat-cli` 即可获取使用指南。
|
|
49
|
+
|
|
50
|
+
## 使用
|
|
51
|
+
|
|
52
|
+
### 登录(只需一次)
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
wechat-cli login
|
|
56
|
+
# 生成二维码 PNG → 自动打开图片 → 手机微信扫码
|
|
57
|
+
# 凭证自动保存到 ~/.wechat-cli/
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
加 `--force` 强制重新登录(已有凭证时)。
|
|
61
|
+
|
|
62
|
+
### 发消息
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# 文本
|
|
66
|
+
wechat-cli send <用户ID> text "你好"
|
|
67
|
+
|
|
68
|
+
# 图片
|
|
69
|
+
wechat-cli send <用户ID> image ./照片.png
|
|
70
|
+
|
|
71
|
+
# 视频
|
|
72
|
+
wechat-cli send <用户ID> video ./视频.mp4
|
|
73
|
+
|
|
74
|
+
# 文件(音频也当文件发)
|
|
75
|
+
wechat-cli send <用户ID> file ./文档.pdf
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
可选的 `--caption "说明文字"` 参数。
|
|
79
|
+
|
|
80
|
+
**注意:** 必须先收到过对方消息才能主动发(SDK 需要 context_token)。如果遇到 `NO_CONTEXT` 错误,先跑:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
wechat-cli messages --count 1
|
|
84
|
+
# 让对方发一条消息过来 → context 缓存 → 之后就能发了
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 拉取消息
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
wechat-cli messages --count 5
|
|
91
|
+
# 启动长轮询,收集到 5 条消息后自动停止
|
|
92
|
+
# --timeout 60 超时(秒),超时未收满也返回已收集的
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
默认输出 JSON,`--pretty` 美化。
|
|
96
|
+
|
|
97
|
+
### 其他
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
wechat-cli status # 查看登录状态
|
|
101
|
+
wechat-cli logout # 清除凭证
|
|
102
|
+
wechat-cli --help # 查看所有命令
|
|
103
|
+
wechat-cli <命令> --help # 查看子命令帮助
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## 目录结构
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
wechat-cli/
|
|
110
|
+
├── bin/wechat-cli.js # 入口
|
|
111
|
+
├── src/main.js # Commander 配置
|
|
112
|
+
├── src/core/bot.js # createBot 工厂
|
|
113
|
+
├── src/commands/
|
|
114
|
+
│ ├── login.js # 登录
|
|
115
|
+
│ ├── send.js # 发送(text/image/video/file)
|
|
116
|
+
│ ├── messages.js # 拉取消息
|
|
117
|
+
│ ├── status.js # 状态查询
|
|
118
|
+
│ └── logout.js # 登出
|
|
119
|
+
├── src/utils/
|
|
120
|
+
│ ├── output.js # JSON 格式化输出
|
|
121
|
+
│ └── qr.js # 二维码生成
|
|
122
|
+
├── skills/
|
|
123
|
+
│ └── wechat-cli/
|
|
124
|
+
│ └── SKILL.md # AI Agent Skill
|
|
125
|
+
├── package.json
|
|
126
|
+
└── README.md
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## 常见问题
|
|
130
|
+
|
|
131
|
+
**Q: 发消息报 `NO_CONTEXT`?**
|
|
132
|
+
A: 先 `wechat-cli messages --count 1`,让对方发条消息过来,刷新 context token。
|
|
133
|
+
|
|
134
|
+
**Q: 凭证存在哪里?**
|
|
135
|
+
A: `~/.wechat-cli/credentials.json`。SDK 默认是 `~/.wechatbot/`,可以复制过去复用。
|
|
136
|
+
|
|
137
|
+
**Q: 语音消息?**
|
|
138
|
+
A: iLink Bot API 不支持机器人发语音类型消息。音频当文件发:`send <uid> file ./audio.wav`。
|
|
139
|
+
|
|
140
|
+
**Q: 报 `ret=-2` 错误?**
|
|
141
|
+
A: 通常是会话过期或频率限制。重新 `login` 即可。
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spritesensei/wechat-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "WeChat CLI — send/receive WeChat messages via @wechatbot/wechatbot SDK",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"wechat-cli": "bin/wechat-cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"skills",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"preferGlobal": true,
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"wechat",
|
|
21
|
+
"weixin",
|
|
22
|
+
"cli",
|
|
23
|
+
"chatbot",
|
|
24
|
+
"ilink"
|
|
25
|
+
],
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/spritesensei/wechat-cli.git"
|
|
29
|
+
},
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/snowball-dev/wechat-cli/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/snowball-dev/wechat-cli#readme",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"agents": {
|
|
36
|
+
"skills": {
|
|
37
|
+
"wechat-cli": {
|
|
38
|
+
"description": "WeChat CLI tool — send/receive messages via @wechatbot/wechatbot SDK",
|
|
39
|
+
"path": "./skills/wechat-cli/SKILL.md"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@wechatbot/wechatbot": "^2.1.1",
|
|
48
|
+
"commander": "^13.0.0",
|
|
49
|
+
"open": "^10.0.0",
|
|
50
|
+
"qrcode": "^1.5.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wechat-cli
|
|
3
|
+
description: Use when the user asks to send/receive WeChat messages, or when the project is wechat-cli — a CLI tool built on @wechatbot/wechatbot SDK for WeChat iLink Bot messaging
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# wechat-cli
|
|
7
|
+
|
|
8
|
+
基于 `@wechatbot/wechatbot` SDK 的微信命令行工具,类似 lark-cli 风格。
|
|
9
|
+
|
|
10
|
+
## 安装
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# 全局安装(推荐,安装后直接 wechat-cli 命令)
|
|
14
|
+
npm install -g @spritesensei/wechat-cli
|
|
15
|
+
|
|
16
|
+
# 或从源码
|
|
17
|
+
git clone https://github.com/snowball-dev/wechat-cli.git
|
|
18
|
+
cd wechat-cli
|
|
19
|
+
npm install
|
|
20
|
+
npm link
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 登录
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
wechat-cli login
|
|
27
|
+
# 自动生成二维码图片并打开,手机微信扫码即可
|
|
28
|
+
# 凭证自动保存到 ~/.wechat-cli/
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
加 `--force` 可强制重新登录。
|
|
32
|
+
|
|
33
|
+
## 发消息
|
|
34
|
+
|
|
35
|
+
必须先收到过对方消息(context token),否则会报 `NO_CONTEXT`。
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# 先收一条消息刷新 context
|
|
39
|
+
wechat-cli messages --count 1
|
|
40
|
+
|
|
41
|
+
# 文本
|
|
42
|
+
wechat-cli send <userId> text "内容"
|
|
43
|
+
|
|
44
|
+
# 图片
|
|
45
|
+
wechat-cli send <userId> image ./图片.png
|
|
46
|
+
|
|
47
|
+
# 视频
|
|
48
|
+
wechat-cli send <userId> video ./视频.mp4
|
|
49
|
+
|
|
50
|
+
# 文件(音频也当文件发)
|
|
51
|
+
wechat-cli send <userId> file ./文件.pdf --caption "说明"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 拉取消息
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
wechat-cli messages --count 5 --timeout 30
|
|
58
|
+
# 长轮询收集,到 count 或超时后自动停止,输出 JSON
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 其他命令
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
wechat-cli status # 查看登录状态
|
|
65
|
+
wechat-cli logout # 清除凭证
|
|
66
|
+
wechat-cli --help # 所有命令
|
|
67
|
+
wechat-cli <cmd> --help # 子命令详情
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 注意事项
|
|
71
|
+
|
|
72
|
+
- **context_token:** 需要先收消息才能主动发。`messages --count 1` 先让对方发一条过来
|
|
73
|
+
- **会话过期:** `ret=-2` 错误通常表示会话过期或频率限制,重新 login
|
|
74
|
+
- **语音:** iLink Bot API 不支持机器人发语音类型消息,音频请当文件发
|
|
75
|
+
|
|
76
|
+
## 全局选项
|
|
77
|
+
|
|
78
|
+
所有命令支持 `--pretty` 美化 JSON 输出。
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createBot } from '../core/bot.js';
|
|
2
|
+
import { renderQrCode } from '../utils/qr.js';
|
|
3
|
+
import { outputJson, outputError } from '../utils/output.js';
|
|
4
|
+
|
|
5
|
+
let qrShown = false;
|
|
6
|
+
|
|
7
|
+
export function register(program) {
|
|
8
|
+
program
|
|
9
|
+
.command('login')
|
|
10
|
+
.description('Scan QR code to log in to WeChat')
|
|
11
|
+
.option('--force', 'Force re-login even if credentials exist')
|
|
12
|
+
.action(async (options, cmd) => {
|
|
13
|
+
try {
|
|
14
|
+
const loginCallbacks = {
|
|
15
|
+
onQrUrl: async (url) => {
|
|
16
|
+
if (qrShown) return;
|
|
17
|
+
qrShown = true;
|
|
18
|
+
try {
|
|
19
|
+
process.stderr.write('Opening QR code in image viewer...\n');
|
|
20
|
+
await renderQrCode(url);
|
|
21
|
+
process.stderr.write('Please scan the QR code with WeChat.\n');
|
|
22
|
+
} catch (err) {
|
|
23
|
+
process.stderr.write(`Failed to render QR code: ${err.message}\n`);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
onScanned: () => {
|
|
27
|
+
process.stderr.write('QR code scanned! Confirm on your phone...\n');
|
|
28
|
+
},
|
|
29
|
+
onExpired: () => {
|
|
30
|
+
process.stderr.write('QR code expired, refreshing...\n');
|
|
31
|
+
qrShown = false;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const bot = await createBot({
|
|
36
|
+
loginCallbacks,
|
|
37
|
+
force: !!options.force,
|
|
38
|
+
});
|
|
39
|
+
const creds = bot.getCredentials();
|
|
40
|
+
await bot.stop();
|
|
41
|
+
|
|
42
|
+
if (!creds) {
|
|
43
|
+
outputError('Login failed — no credentials returned', 'LOGIN_FAILED');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
outputJson({
|
|
48
|
+
success: true,
|
|
49
|
+
userId: creds.userId,
|
|
50
|
+
accountId: creds.accountId,
|
|
51
|
+
}, { pretty: cmd.optsWithGlobals().pretty });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
outputError(err.message, 'LOGIN_FAILED');
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { outputJson } from '../utils/output.js';
|
|
4
|
+
import { DEFAULT_STORAGE_DIR } from '../core/bot.js';
|
|
5
|
+
|
|
6
|
+
export function register(program) {
|
|
7
|
+
program
|
|
8
|
+
.command('logout')
|
|
9
|
+
.description('Clear stored credentials and log out')
|
|
10
|
+
.action(async (options, cmd) => {
|
|
11
|
+
let removed = 0;
|
|
12
|
+
|
|
13
|
+
if (fs.existsSync(DEFAULT_STORAGE_DIR)) {
|
|
14
|
+
for (const file of fs.readdirSync(DEFAULT_STORAGE_DIR)) {
|
|
15
|
+
fs.rmSync(path.join(DEFAULT_STORAGE_DIR, file), { force: true, recursive: true });
|
|
16
|
+
removed++;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
outputJson({
|
|
21
|
+
success: true,
|
|
22
|
+
removedFiles: removed,
|
|
23
|
+
storageDir: DEFAULT_STORAGE_DIR,
|
|
24
|
+
}, { pretty: cmd.optsWithGlobals().pretty });
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createBot } from '../core/bot.js';
|
|
2
|
+
import { outputJson, outputError } from '../utils/output.js';
|
|
3
|
+
|
|
4
|
+
export function register(program) {
|
|
5
|
+
program
|
|
6
|
+
.command('messages')
|
|
7
|
+
.description('Pull recent messages (listens for N new messages, then stops)')
|
|
8
|
+
.option('-c, --count <number>', 'Number of messages to collect', '5')
|
|
9
|
+
.option('-t, --timeout <seconds>', 'Max wait time in seconds', '60')
|
|
10
|
+
.action(async (options, cmd) => {
|
|
11
|
+
const count = parseInt(options.count, 10);
|
|
12
|
+
const timeoutMs = parseInt(options.timeout, 10) * 1000;
|
|
13
|
+
|
|
14
|
+
if (isNaN(count) || count < 1) {
|
|
15
|
+
outputError('--count must be a positive integer', 'INVALID_COUNT');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (isNaN(timeoutMs) || timeoutMs < 0) {
|
|
19
|
+
outputError('--timeout must be a non-negative integer', 'INVALID_TIMEOUT');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const bot = await createBot({});
|
|
25
|
+
const messages = [];
|
|
26
|
+
let finished = false;
|
|
27
|
+
|
|
28
|
+
bot.onMessage((msg) => {
|
|
29
|
+
messages.push({
|
|
30
|
+
userId: msg.userId,
|
|
31
|
+
text: msg.text,
|
|
32
|
+
type: msg.type,
|
|
33
|
+
timestamp: msg.timestamp,
|
|
34
|
+
images: msg.images?.map(i => ({ url: i.url, width: i.width, height: i.height })) ?? [],
|
|
35
|
+
files: msg.files?.map(f => ({ fileName: f.fileName, size: f.size, md5: f.md5 })) ?? [],
|
|
36
|
+
videos: msg.videos?.map(v => ({ durationMs: v.durationMs, width: v.width, height: v.height })) ?? [],
|
|
37
|
+
voices: msg.voices?.map(v => ({ durationMs: v.durationMs, text: v.text })) ?? [],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (!finished && messages.length >= count) {
|
|
41
|
+
stopAndOutput();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
stopAndOutput();
|
|
47
|
+
}, timeoutMs);
|
|
48
|
+
|
|
49
|
+
function stopAndOutput() {
|
|
50
|
+
if (finished) return;
|
|
51
|
+
finished = true;
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
try { bot.stop(); } catch {}
|
|
54
|
+
outputJson({
|
|
55
|
+
messages,
|
|
56
|
+
total: messages.length,
|
|
57
|
+
requested: count,
|
|
58
|
+
timedOut: messages.length < count,
|
|
59
|
+
}, { pretty: cmd.optsWithGlobals().pretty });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await bot.start();
|
|
63
|
+
// bot.start() resolves when bot.stop() is called
|
|
64
|
+
|
|
65
|
+
} catch (err) {
|
|
66
|
+
outputError(err.message, 'MESSAGES_FAILED');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createBot } from '../core/bot.js';
|
|
4
|
+
import { outputJson, outputError } from '../utils/output.js';
|
|
5
|
+
|
|
6
|
+
export function register(program) {
|
|
7
|
+
program
|
|
8
|
+
.command('send')
|
|
9
|
+
.description('Send a message to a user')
|
|
10
|
+
.argument('<userId>', 'Target user ID (e.g. xxx@im.wechat)')
|
|
11
|
+
.argument('<type>', 'Message type: text | image | video | file')
|
|
12
|
+
.argument('<content>', 'Text content (for text type) or file path (for image/video/file)')
|
|
13
|
+
.option('-c, --caption <text>', 'Caption for image/video/file')
|
|
14
|
+
.action(async (userId, type, content, options, cmd) => {
|
|
15
|
+
try {
|
|
16
|
+
const validTypes = ['text', 'image', 'video', 'file'];
|
|
17
|
+
if (!validTypes.includes(type)) {
|
|
18
|
+
outputError(`Invalid type "${type}". Must be one of: ${validTypes.join(', ')}`, 'INVALID_TYPE');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const bot = await createBot({});
|
|
23
|
+
await bot.contextStore.load();
|
|
24
|
+
|
|
25
|
+
if (type === 'text') {
|
|
26
|
+
await bot.send(userId, { text: content });
|
|
27
|
+
await bot.stop();
|
|
28
|
+
outputJson({ success: true, userId, type }, { pretty: cmd.optsWithGlobals().pretty });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// For media types: read file
|
|
33
|
+
const filePath = path.resolve(content);
|
|
34
|
+
if (!fs.existsSync(filePath)) {
|
|
35
|
+
outputError(`File not found: ${filePath}`, 'FILE_NOT_FOUND');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const data = fs.readFileSync(filePath);
|
|
39
|
+
const fileName = path.basename(filePath);
|
|
40
|
+
|
|
41
|
+
if (type === 'image') {
|
|
42
|
+
await bot.send(userId, { image: data, caption: options.caption });
|
|
43
|
+
} else if (type === 'video') {
|
|
44
|
+
await bot.send(userId, { video: data, caption: options.caption });
|
|
45
|
+
} else {
|
|
46
|
+
await bot.send(userId, { file: data, fileName, caption: options.caption });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await bot.stop();
|
|
50
|
+
|
|
51
|
+
outputJson({
|
|
52
|
+
success: true,
|
|
53
|
+
userId,
|
|
54
|
+
type,
|
|
55
|
+
}, { pretty: cmd.optsWithGlobals().pretty });
|
|
56
|
+
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (err.name === 'NoContextError' || err.code === 'NO_CONTEXT') {
|
|
59
|
+
outputError(
|
|
60
|
+
`No context token for user "${userId}". First receive a message from them via "wechat-cli messages"`,
|
|
61
|
+
'NO_CONTEXT', 2);
|
|
62
|
+
} else {
|
|
63
|
+
outputError(err.message, 'SEND_FAILED');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { outputJson } from '../utils/output.js';
|
|
4
|
+
import { DEFAULT_STORAGE_DIR } from '../core/bot.js';
|
|
5
|
+
|
|
6
|
+
export function register(program) {
|
|
7
|
+
program
|
|
8
|
+
.command('status')
|
|
9
|
+
.description('Show login status')
|
|
10
|
+
.action(async (options, cmd) => {
|
|
11
|
+
const credentialsPath = path.join(DEFAULT_STORAGE_DIR, 'credentials.json');
|
|
12
|
+
const credsExist = fs.existsSync(credentialsPath);
|
|
13
|
+
|
|
14
|
+
let credentials = null;
|
|
15
|
+
let contextCount = 0;
|
|
16
|
+
|
|
17
|
+
if (credsExist) {
|
|
18
|
+
try {
|
|
19
|
+
const raw = fs.readFileSync(credentialsPath, 'utf-8');
|
|
20
|
+
credentials = JSON.parse(raw);
|
|
21
|
+
// Count cached context tokens
|
|
22
|
+
const tokensPath = path.join(DEFAULT_STORAGE_DIR, 'context_tokens.json');
|
|
23
|
+
if (fs.existsSync(tokensPath)) {
|
|
24
|
+
const tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
|
|
25
|
+
contextCount = Object.keys(tokens).length;
|
|
26
|
+
}
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
outputJson({
|
|
31
|
+
loggedIn: !!credentials,
|
|
32
|
+
userId: credentials?.userId ?? null,
|
|
33
|
+
accountId: credentials?.accountId ?? null,
|
|
34
|
+
savedAt: credentials?.savedAt ?? null,
|
|
35
|
+
storageDir: DEFAULT_STORAGE_DIR,
|
|
36
|
+
cachedUsers: contextCount,
|
|
37
|
+
}, { pretty: cmd.optsWithGlobals().pretty });
|
|
38
|
+
});
|
|
39
|
+
}
|
package/src/core/bot.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { WeChatBot } from '@wechatbot/wechatbot';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_STORAGE_DIR = path.join(os.homedir(), '.wechat-cli');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a WeChatBot instance, auto-load stored credentials.
|
|
9
|
+
* Returns the bot ready to use (logged in, not yet started).
|
|
10
|
+
* Does NOT call bot.start() — caller decides whether to poll.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} [options]
|
|
13
|
+
* @param {string} [options.storageDir] - Storage directory (default: ~/.wechat-cli)
|
|
14
|
+
* @param {object} [options.loginCallbacks] - QR login callbacks
|
|
15
|
+
* @param {boolean} [options.force] - Force re-login even if credentials exist
|
|
16
|
+
*/
|
|
17
|
+
export async function createBot({ storageDir = DEFAULT_STORAGE_DIR, loginCallbacks = {}, force } = {}) {
|
|
18
|
+
const bot = new WeChatBot({
|
|
19
|
+
storage: 'file',
|
|
20
|
+
storageDir,
|
|
21
|
+
logLevel: 'warn',
|
|
22
|
+
loginCallbacks,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
await bot.login({ force, callbacks: loginCallbacks });
|
|
26
|
+
return bot;
|
|
27
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { register as registerLogin } from './commands/login.js';
|
|
7
|
+
import { register as registerSend } from './commands/send.js';
|
|
8
|
+
import { register as registerMessages } from './commands/messages.js';
|
|
9
|
+
import { register as registerStatus } from './commands/status.js';
|
|
10
|
+
import { register as registerLogout } from './commands/logout.js';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
|
|
14
|
+
|
|
15
|
+
const program = new Command();
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.name('wechat-cli')
|
|
19
|
+
.description('WeChat CLI — send/receive messages via @wechatbot/wechatbot')
|
|
20
|
+
.version(pkg.version)
|
|
21
|
+
.option('--pretty', 'Pretty-print JSON output');
|
|
22
|
+
|
|
23
|
+
// Register all commands
|
|
24
|
+
registerLogin(program);
|
|
25
|
+
registerSend(program);
|
|
26
|
+
registerMessages(program);
|
|
27
|
+
registerStatus(program);
|
|
28
|
+
registerLogout(program);
|
|
29
|
+
|
|
30
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write JSON result to stdout.
|
|
3
|
+
* @param {*} data - data to serialize
|
|
4
|
+
* @param {{ pretty?: boolean }} options
|
|
5
|
+
*/
|
|
6
|
+
export function outputJson(data, options = {}) {
|
|
7
|
+
const space = options.pretty ? 2 : undefined;
|
|
8
|
+
const text = JSON.stringify(data, null, space) + '\n';
|
|
9
|
+
process.stdout.write(text);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Write error JSON to stderr and exit.
|
|
14
|
+
* @param {string} message
|
|
15
|
+
* @param {string} [code]
|
|
16
|
+
* @param {number} [exitCode]
|
|
17
|
+
*/
|
|
18
|
+
export function outputError(message, code = 'UNKNOWN_ERROR', exitCode = 1) {
|
|
19
|
+
const error = { error: true, code, message };
|
|
20
|
+
const text = JSON.stringify(error) + '\n';
|
|
21
|
+
process.stderr.write(text);
|
|
22
|
+
process.exit(exitCode);
|
|
23
|
+
}
|
package/src/utils/qr.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import QRCode from 'qrcode';
|
|
5
|
+
import open from 'open';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate a QR code PNG from a URL, save to temp dir, and open it.
|
|
9
|
+
* Falls back to printing the URL if the image viewer cannot be launched.
|
|
10
|
+
* @param {string} url - the QR URL to encode
|
|
11
|
+
* @returns {Promise<{ filePath: string }>}
|
|
12
|
+
*/
|
|
13
|
+
export async function renderQrCode(url) {
|
|
14
|
+
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'wechat-cli-qr-'));
|
|
15
|
+
const filePath = path.join(tmpDir, 'wechat-login.png');
|
|
16
|
+
|
|
17
|
+
await QRCode.toFile(filePath, url, {
|
|
18
|
+
type: 'png',
|
|
19
|
+
width: 400,
|
|
20
|
+
margin: 2,
|
|
21
|
+
color: { dark: '#000000', light: '#ffffff' },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
await open(filePath);
|
|
26
|
+
} catch {
|
|
27
|
+
// Fallback: print the QR URL if open fails (e.g. headless/CI)
|
|
28
|
+
process.stderr.write(`QR code image saved to: ${filePath}\n`);
|
|
29
|
+
process.stderr.write(`QR code URL: ${url}\n`);
|
|
30
|
+
process.stderr.write('Please open this URL in a browser to scan with WeChat.\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { filePath };
|
|
34
|
+
}
|