claude-wechat-channel 0.1.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 +88 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +24 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.js +10 -0
- package/dist/monitor.d.ts +10 -0
- package/dist/monitor.js +126 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +179 -0
- package/dist/weixin/api.d.ts +26 -0
- package/dist/weixin/api.js +161 -0
- package/dist/weixin/auth.d.ts +29 -0
- package/dist/weixin/auth.js +205 -0
- package/dist/weixin/cdn/aes-ecb.d.ts +3 -0
- package/dist/weixin/cdn/aes-ecb.js +12 -0
- package/dist/weixin/cdn/cdn-upload.d.ts +10 -0
- package/dist/weixin/cdn/cdn-upload.js +53 -0
- package/dist/weixin/cdn/cdn-url.d.ts +6 -0
- package/dist/weixin/cdn/cdn-url.js +6 -0
- package/dist/weixin/cdn/pic-decrypt.d.ts +2 -0
- package/dist/weixin/cdn/pic-decrypt.js +48 -0
- package/dist/weixin/cdn/upload.d.ts +28 -0
- package/dist/weixin/cdn/upload.js +73 -0
- package/dist/weixin/inbound.d.ts +4 -0
- package/dist/weixin/inbound.js +53 -0
- package/dist/weixin/media/media-download.d.ts +14 -0
- package/dist/weixin/media/media-download.js +91 -0
- package/dist/weixin/media/mime.d.ts +3 -0
- package/dist/weixin/media/mime.js +69 -0
- package/dist/weixin/media/silk-transcode.d.ts +1 -0
- package/dist/weixin/media/silk-transcode.js +51 -0
- package/dist/weixin/send-media.d.ts +12 -0
- package/dist/weixin/send-media.js +24 -0
- package/dist/weixin/send.d.ts +46 -0
- package/dist/weixin/send.js +166 -0
- package/dist/weixin/session-guard.d.ts +5 -0
- package/dist/weixin/session-guard.js +36 -0
- package/dist/weixin/types.d.ts +162 -0
- package/dist/weixin/types.js +33 -0
- package/dist/weixin/util/random.d.ts +2 -0
- package/dist/weixin/util/random.js +7 -0
- package/dist/weixin/util/redact.d.ts +4 -0
- package/dist/weixin/util/redact.js +33 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# claude-wechat-channel
|
|
2
|
+
|
|
3
|
+
微信 Channel for [Claude Code](https://docs.anthropic.com/en/docs/claude-code)。
|
|
4
|
+
|
|
5
|
+
让 Claude Code 直接通过微信收发消息 — 微信消息到达后,Claude Code 在当前 session 中处理并回复,完整保留上下文和工具能力。
|
|
6
|
+
|
|
7
|
+
## 工作原理
|
|
8
|
+
|
|
9
|
+
本项目是一个 MCP server,声明了 `claude/channel` capability([Claude Code Channel](https://docs.anthropic.com/en/docs/claude-code) v2.1.80+ 的官方扩展机制)。三方各司其职:
|
|
10
|
+
|
|
11
|
+
| 角色 | 职责 |
|
|
12
|
+
|------|------|
|
|
13
|
+
| **微信** | 用户发送/接收消息的 IM 平台 |
|
|
14
|
+
| **claude-wechat-channel** | 轮询微信 API 获取新消息,推送 MCP notification 给 Claude Code;接收 Claude Code 的 reply tool 调用,将回复发送到微信(自动分段、markdown 转纯文本) |
|
|
15
|
+
| **Claude Code** | 接收 channel 推送的消息,在当前 session 中处理(可使用所有工具能力),通过 reply tool 回复,原生管理会话上下文 |
|
|
16
|
+
|
|
17
|
+
## 快速开始
|
|
18
|
+
|
|
19
|
+
### 前置条件
|
|
20
|
+
|
|
21
|
+
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) v2.1.80+
|
|
22
|
+
- [Bun](https://bun.sh/) 运行时
|
|
23
|
+
|
|
24
|
+
### 1. 注册 MCP server
|
|
25
|
+
|
|
26
|
+
在你想让 Claude Code 工作的目录下,创建或编辑 `.mcp.json`:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"wechat": {
|
|
32
|
+
"command": "npx",
|
|
33
|
+
"args": ["claude-wechat-channel"]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 2. 启动 Claude Code
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
claude --dangerously-load-development-channels server:wechat
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
首次启动会在终端显示微信二维码,用微信扫码登录。登录凭证会保存到 `~/.wechat-claude/`,后续启动自动恢复。
|
|
46
|
+
|
|
47
|
+
### 3. 开始使用
|
|
48
|
+
|
|
49
|
+
从另一个微信账号给登录的账号发消息,Claude Code 会自动接收并回复。
|
|
50
|
+
|
|
51
|
+
## 配置
|
|
52
|
+
|
|
53
|
+
通过环境变量配置,在 `.mcp.json` 中传入:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"wechat": {
|
|
59
|
+
"command": "npx",
|
|
60
|
+
"args": ["claude-wechat-channel"],
|
|
61
|
+
"env": {
|
|
62
|
+
"DATA_DIR": "~/.wechat-claude",
|
|
63
|
+
"DEBUG": "1"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| 变量 | 默认值 | 说明 |
|
|
71
|
+
|------|--------|------|
|
|
72
|
+
| `DATA_DIR` | `~/.wechat-claude` | 数据持久化目录(账号凭证、同步状态) |
|
|
73
|
+
| `DEBUG` | 未设置 | 设置任意值开启调试日志 |
|
|
74
|
+
|
|
75
|
+
## 内置处理
|
|
76
|
+
|
|
77
|
+
- **自动分段**:微信单条消息限制 4000 字符,超长回复会自动拆分为多条发送
|
|
78
|
+
- **Markdown 转纯文本**:Claude 的回复会自动去除 markdown 格式(微信不支持渲染)
|
|
79
|
+
- **凭证持久化**:微信登录凭证保存在 `DATA_DIR` 目录下,重启自动恢复登录状态
|
|
80
|
+
|
|
81
|
+
## 注意事项
|
|
82
|
+
|
|
83
|
+
- Channel 功能目前是 Claude Code 的实验性特性,需要 `--dangerously-load-development-channels` 标志
|
|
84
|
+
- `DATA_DIR` 下的凭证文件请妥善保管
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
const configSchema = z.object({
|
|
5
|
+
WEIXIN_BASE_URL: z.string().default("https://ilinkai.weixin.qq.com"),
|
|
6
|
+
WEIXIN_CDN_BASE_URL: z.string().default("https://novac2c.cdn.weixin.qq.com/c2c"),
|
|
7
|
+
DATA_DIR: z.string().default("~/.wechat-claude"),
|
|
8
|
+
DEBUG: z.string().optional(),
|
|
9
|
+
});
|
|
10
|
+
function expandHome(p) {
|
|
11
|
+
if (p.startsWith("~/")) {
|
|
12
|
+
return path.join(os.homedir(), p.slice(2));
|
|
13
|
+
}
|
|
14
|
+
return p;
|
|
15
|
+
}
|
|
16
|
+
export function loadConfig() {
|
|
17
|
+
const raw = configSchema.parse(process.env);
|
|
18
|
+
const dataDir = expandHome(raw.DATA_DIR);
|
|
19
|
+
return {
|
|
20
|
+
weixinBaseUrl: raw.WEIXIN_BASE_URL,
|
|
21
|
+
weixinCdnBaseUrl: raw.WEIXIN_CDN_BASE_URL,
|
|
22
|
+
dataDir,
|
|
23
|
+
};
|
|
24
|
+
}
|
package/dist/logger.d.ts
ADDED
package/dist/logger.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Simple logger — all output to stderr to avoid conflicting with MCP stdio transport on stdout. */
|
|
2
|
+
export const logger = {
|
|
3
|
+
info: (msg) => console.error(`[INFO] ${msg}`),
|
|
4
|
+
warn: (msg) => console.error(`[WARN] ${msg}`),
|
|
5
|
+
error: (msg) => console.error(`[ERROR] ${msg}`),
|
|
6
|
+
debug: (msg) => {
|
|
7
|
+
if (process.env.DEBUG)
|
|
8
|
+
console.error(`[DEBUG] ${msg}`);
|
|
9
|
+
},
|
|
10
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import type { Config } from "./config.js";
|
|
3
|
+
export type MonitorOpts = {
|
|
4
|
+
config: Config;
|
|
5
|
+
token: string;
|
|
6
|
+
accountId: string;
|
|
7
|
+
mcp: Server;
|
|
8
|
+
abortSignal?: AbortSignal;
|
|
9
|
+
};
|
|
10
|
+
export declare function startMonitor(opts: MonitorOpts): Promise<void>;
|
package/dist/monitor.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { getUpdates } from "./weixin/api.js";
|
|
2
|
+
import { SESSION_EXPIRED_ERRCODE, pauseSession, getRemainingPauseMs } from "./weixin/session-guard.js";
|
|
3
|
+
import { bodyFromItemList, setContextToken } from "./weixin/inbound.js";
|
|
4
|
+
import { loadSyncBuf, saveSyncBuf } from "./weixin/auth.js";
|
|
5
|
+
import { logger } from "./logger.js";
|
|
6
|
+
import { MessageType } from "./weixin/types.js";
|
|
7
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
8
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
9
|
+
const BACKOFF_DELAY_MS = 30_000;
|
|
10
|
+
const RETRY_DELAY_MS = 2_000;
|
|
11
|
+
export async function startMonitor(opts) {
|
|
12
|
+
const { config, token, accountId, mcp, abortSignal } = opts;
|
|
13
|
+
const baseUrl = config.weixinBaseUrl;
|
|
14
|
+
logger.info(`monitor started: baseUrl=${baseUrl} accountId=${accountId}`);
|
|
15
|
+
let getUpdatesBuf = loadSyncBuf();
|
|
16
|
+
if (getUpdatesBuf) {
|
|
17
|
+
logger.info(`resuming from previous sync buf (${getUpdatesBuf.length} bytes)`);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
logger.info(`no previous sync buf, starting fresh`);
|
|
21
|
+
}
|
|
22
|
+
let nextTimeoutMs = DEFAULT_LONG_POLL_TIMEOUT_MS;
|
|
23
|
+
let consecutiveFailures = 0;
|
|
24
|
+
while (!abortSignal?.aborted) {
|
|
25
|
+
try {
|
|
26
|
+
const resp = await getUpdates({
|
|
27
|
+
baseUrl,
|
|
28
|
+
token,
|
|
29
|
+
get_updates_buf: getUpdatesBuf,
|
|
30
|
+
timeoutMs: nextTimeoutMs,
|
|
31
|
+
});
|
|
32
|
+
if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
|
|
33
|
+
nextTimeoutMs = resp.longpolling_timeout_ms;
|
|
34
|
+
}
|
|
35
|
+
const isApiError = (resp.ret !== undefined && resp.ret !== 0) ||
|
|
36
|
+
(resp.errcode !== undefined && resp.errcode !== 0);
|
|
37
|
+
if (isApiError) {
|
|
38
|
+
const isSessionExpired = resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
|
|
39
|
+
if (isSessionExpired) {
|
|
40
|
+
pauseSession(accountId);
|
|
41
|
+
const pauseMs = getRemainingPauseMs(accountId);
|
|
42
|
+
logger.error(`getUpdates: session expired (errcode=${resp.errcode}), pausing for ${Math.ceil(pauseMs / 60_000)} min`);
|
|
43
|
+
consecutiveFailures = 0;
|
|
44
|
+
await sleep(pauseMs, abortSignal);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
consecutiveFailures += 1;
|
|
48
|
+
logger.error(`getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg ?? ""} (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`);
|
|
49
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
50
|
+
logger.error(`${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`);
|
|
51
|
+
consecutiveFailures = 0;
|
|
52
|
+
await sleep(BACKOFF_DELAY_MS, abortSignal);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
await sleep(RETRY_DELAY_MS, abortSignal);
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
consecutiveFailures = 0;
|
|
60
|
+
if (resp.get_updates_buf != null && resp.get_updates_buf !== "") {
|
|
61
|
+
saveSyncBuf(resp.get_updates_buf);
|
|
62
|
+
getUpdatesBuf = resp.get_updates_buf;
|
|
63
|
+
}
|
|
64
|
+
const msgs = resp.msgs ?? [];
|
|
65
|
+
for (const msg of msgs) {
|
|
66
|
+
// Skip bot's own messages
|
|
67
|
+
if (msg.message_type === MessageType.BOT)
|
|
68
|
+
continue;
|
|
69
|
+
const fromUserId = msg.from_user_id ?? "";
|
|
70
|
+
if (!fromUserId)
|
|
71
|
+
continue;
|
|
72
|
+
// Cache context token
|
|
73
|
+
if (msg.context_token) {
|
|
74
|
+
setContextToken(fromUserId, msg.context_token);
|
|
75
|
+
}
|
|
76
|
+
// Extract text
|
|
77
|
+
const text = bodyFromItemList(msg.item_list);
|
|
78
|
+
if (!text) {
|
|
79
|
+
logger.debug(`skipping non-text message from ${fromUserId}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
logger.info(`inbound: from=${fromUserId} text="${text.substring(0, 100)}"`);
|
|
83
|
+
// Push notification to Claude Code via MCP channel
|
|
84
|
+
try {
|
|
85
|
+
await mcp.notification({
|
|
86
|
+
method: "notifications/claude/channel",
|
|
87
|
+
params: {
|
|
88
|
+
content: text,
|
|
89
|
+
meta: { sender: fromUserId, user_id: fromUserId },
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
logger.info(`notification pushed for ${fromUserId}`);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
logger.error(`failed to push notification for ${fromUserId}: ${String(err)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (abortSignal?.aborted) {
|
|
101
|
+
logger.info("monitor stopped (aborted)");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
consecutiveFailures += 1;
|
|
105
|
+
logger.error(`getUpdates error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${String(err)}`);
|
|
106
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
107
|
+
logger.error(`${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`);
|
|
108
|
+
consecutiveFailures = 0;
|
|
109
|
+
await sleep(BACKOFF_DELAY_MS, abortSignal);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
await sleep(RETRY_DELAY_MS, abortSignal);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
logger.info("monitor ended");
|
|
117
|
+
}
|
|
118
|
+
function sleep(ms, signal) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const t = setTimeout(resolve, ms);
|
|
121
|
+
signal?.addEventListener("abort", () => {
|
|
122
|
+
clearTimeout(t);
|
|
123
|
+
reject(new Error("aborted"));
|
|
124
|
+
}, { once: true });
|
|
125
|
+
});
|
|
126
|
+
}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { ListToolsRequestSchema, CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { loadConfig } from "./config.js";
|
|
8
|
+
import { logger } from "./logger.js";
|
|
9
|
+
import { setDataDir, loadAccount, saveAccount, loginWithQR } from "./weixin/auth.js";
|
|
10
|
+
import { getContextToken } from "./weixin/inbound.js";
|
|
11
|
+
import { sendMessageWeixin, markdownToPlainText } from "./weixin/send.js";
|
|
12
|
+
import { startMonitor } from "./monitor.js";
|
|
13
|
+
// ── .env loading ──────────────────────────────────────────────────────────────
|
|
14
|
+
try {
|
|
15
|
+
const envPath = path.resolve(process.cwd(), ".env");
|
|
16
|
+
if (fs.existsSync(envPath)) {
|
|
17
|
+
const content = fs.readFileSync(envPath, "utf-8");
|
|
18
|
+
for (const line of content.split("\n")) {
|
|
19
|
+
const trimmed = line.trim();
|
|
20
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
21
|
+
continue;
|
|
22
|
+
const eqIdx = trimmed.indexOf("=");
|
|
23
|
+
if (eqIdx <= 0)
|
|
24
|
+
continue;
|
|
25
|
+
const key = trimmed.substring(0, eqIdx).trim();
|
|
26
|
+
const value = trimmed.substring(eqIdx + 1).trim();
|
|
27
|
+
if (!process.env[key]) {
|
|
28
|
+
process.env[key] = value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* ignore */
|
|
35
|
+
}
|
|
36
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
37
|
+
const config = loadConfig();
|
|
38
|
+
fs.mkdirSync(config.dataDir, { recursive: true });
|
|
39
|
+
setDataDir(config.dataDir);
|
|
40
|
+
// ── MCP Server ────────────────────────────────────────────────────────────────
|
|
41
|
+
const WEIXIN_MAX_CHARS = 4000;
|
|
42
|
+
function splitMessage(text) {
|
|
43
|
+
if (text.length <= WEIXIN_MAX_CHARS)
|
|
44
|
+
return [text];
|
|
45
|
+
const chunks = [];
|
|
46
|
+
let remaining = text;
|
|
47
|
+
while (remaining.length > 0) {
|
|
48
|
+
if (remaining.length <= WEIXIN_MAX_CHARS) {
|
|
49
|
+
chunks.push(remaining);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
let splitAt = remaining.lastIndexOf("\n", WEIXIN_MAX_CHARS);
|
|
53
|
+
if (splitAt <= 0 || splitAt < WEIXIN_MAX_CHARS * 0.5) {
|
|
54
|
+
splitAt = WEIXIN_MAX_CHARS;
|
|
55
|
+
}
|
|
56
|
+
chunks.push(remaining.substring(0, splitAt));
|
|
57
|
+
remaining = remaining.substring(splitAt).trimStart();
|
|
58
|
+
}
|
|
59
|
+
return chunks;
|
|
60
|
+
}
|
|
61
|
+
const mcp = new Server({ name: "wechat", version: "0.1.0" }, {
|
|
62
|
+
capabilities: {
|
|
63
|
+
experimental: { "claude/channel": {} },
|
|
64
|
+
tools: {},
|
|
65
|
+
},
|
|
66
|
+
instructions: `微信消息以 <channel source="wechat" sender="..." user_id="..."> 格式到达。
|
|
67
|
+
用 reply tool 回复,传入 user_id 参数。回复内容用纯文本,不要用 markdown 格式。
|
|
68
|
+
超过 ${WEIXIN_MAX_CHARS} 字的回复会自动分段发送。`,
|
|
69
|
+
});
|
|
70
|
+
// ── Tools ─────────────────────────────────────────────────────────────────────
|
|
71
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
72
|
+
tools: [
|
|
73
|
+
{
|
|
74
|
+
name: "reply",
|
|
75
|
+
description: "回复微信消息。text 会自动从 markdown 转换为纯文本,超长消息会自动分段。",
|
|
76
|
+
inputSchema: {
|
|
77
|
+
type: "object",
|
|
78
|
+
properties: {
|
|
79
|
+
user_id: { type: "string", description: "接收消息的微信用户 ID" },
|
|
80
|
+
text: { type: "string", description: "回复内容" },
|
|
81
|
+
},
|
|
82
|
+
required: ["user_id", "text"],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
}));
|
|
87
|
+
let weixinToken = "";
|
|
88
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
89
|
+
if (req.params.name === "reply") {
|
|
90
|
+
const { user_id, text } = req.params.arguments;
|
|
91
|
+
const contextToken = getContextToken(user_id);
|
|
92
|
+
if (!contextToken) {
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: "text", text: `错误: 没有 user_id=${user_id} 的 contextToken,无法发送` }],
|
|
95
|
+
isError: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const chunks = splitMessage(markdownToPlainText(text));
|
|
99
|
+
for (const chunk of chunks) {
|
|
100
|
+
await sendMessageWeixin({
|
|
101
|
+
to: user_id,
|
|
102
|
+
text: chunk,
|
|
103
|
+
opts: {
|
|
104
|
+
baseUrl: config.weixinBaseUrl,
|
|
105
|
+
token: weixinToken,
|
|
106
|
+
contextToken,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
logger.info(`reply sent to ${user_id}: ${chunks.length} chunk(s)`);
|
|
111
|
+
return { content: [{ type: "text", text: "sent" }] };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
content: [{ type: "text", text: `未知工具: ${req.params.name}` }],
|
|
115
|
+
isError: true,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
|
119
|
+
async function bootstrap() {
|
|
120
|
+
// Connect MCP transport first (stdio)
|
|
121
|
+
const transport = new StdioServerTransport();
|
|
122
|
+
await mcp.connect(transport);
|
|
123
|
+
logger.info("MCP server connected via stdio");
|
|
124
|
+
// WeChat login
|
|
125
|
+
let account = loadAccount();
|
|
126
|
+
if (!account?.token || !account?.accountId) {
|
|
127
|
+
logger.info("未找到已保存的账号,开始微信登录...");
|
|
128
|
+
const result = await loginWithQR({ apiBaseUrl: config.weixinBaseUrl });
|
|
129
|
+
if (!result.connected || !result.botToken || !result.accountId) {
|
|
130
|
+
logger.error(`登录失败: ${result.message}`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
saveAccount(result.accountId, {
|
|
134
|
+
token: result.botToken,
|
|
135
|
+
baseUrl: result.baseUrl,
|
|
136
|
+
userId: result.userId,
|
|
137
|
+
});
|
|
138
|
+
logger.info(result.message);
|
|
139
|
+
account = loadAccount();
|
|
140
|
+
}
|
|
141
|
+
if (!account?.token || !account?.accountId) {
|
|
142
|
+
logger.error("无法加载账号信息");
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
weixinToken = account.token;
|
|
146
|
+
logger.info(`已登录账号: ${account.accountId}`);
|
|
147
|
+
// Graceful shutdown
|
|
148
|
+
const abortController = new AbortController();
|
|
149
|
+
const shutdown = () => {
|
|
150
|
+
logger.info("正在退出...");
|
|
151
|
+
abortController.abort();
|
|
152
|
+
setTimeout(() => process.exit(0), 2000);
|
|
153
|
+
};
|
|
154
|
+
process.on("SIGINT", shutdown);
|
|
155
|
+
process.on("SIGTERM", shutdown);
|
|
156
|
+
// Start monitor (pushes notifications to Claude via mcp)
|
|
157
|
+
try {
|
|
158
|
+
await startMonitor({
|
|
159
|
+
config,
|
|
160
|
+
token: account.token,
|
|
161
|
+
accountId: account.accountId,
|
|
162
|
+
mcp,
|
|
163
|
+
abortSignal: abortController.signal,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (abortController.signal.aborted) {
|
|
168
|
+
logger.info("程序已退出");
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
logger.error(`monitor 异常退出: ${String(err)}`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
bootstrap().catch((err) => {
|
|
177
|
+
logger.error(`Fatal error: ${String(err)}`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { BaseInfo, GetUploadUrlReq, GetUploadUrlResp, GetUpdatesReq, GetUpdatesResp, SendMessageReq, SendTypingReq, GetConfigResp } from "./types.js";
|
|
2
|
+
export type WeixinApiOptions = {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
token?: string;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
longPollTimeoutMs?: number;
|
|
7
|
+
routeTag?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function buildBaseInfo(): BaseInfo;
|
|
10
|
+
export declare function getUpdates(params: GetUpdatesReq & {
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
token?: string;
|
|
13
|
+
routeTag?: string;
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
}): Promise<GetUpdatesResp>;
|
|
16
|
+
export declare function getUploadUrl(params: GetUploadUrlReq & WeixinApiOptions): Promise<GetUploadUrlResp>;
|
|
17
|
+
export declare function sendMessage(params: WeixinApiOptions & {
|
|
18
|
+
body: SendMessageReq;
|
|
19
|
+
}): Promise<void>;
|
|
20
|
+
export declare function getConfig(params: WeixinApiOptions & {
|
|
21
|
+
ilinkUserId: string;
|
|
22
|
+
contextToken?: string;
|
|
23
|
+
}): Promise<GetConfigResp>;
|
|
24
|
+
export declare function sendTyping(params: WeixinApiOptions & {
|
|
25
|
+
body: SendTypingReq;
|
|
26
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { logger } from "../logger.js";
|
|
6
|
+
import { redactBody, redactUrl } from "./util/redact.js";
|
|
7
|
+
function readChannelVersion() {
|
|
8
|
+
try {
|
|
9
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const pkgPath = path.resolve(dir, "..", "..", "package.json");
|
|
11
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
12
|
+
return pkg.version ?? "unknown";
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return "unknown";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const CHANNEL_VERSION = readChannelVersion();
|
|
19
|
+
export function buildBaseInfo() {
|
|
20
|
+
return { channel_version: CHANNEL_VERSION };
|
|
21
|
+
}
|
|
22
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
23
|
+
const DEFAULT_API_TIMEOUT_MS = 15_000;
|
|
24
|
+
const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;
|
|
25
|
+
function ensureTrailingSlash(url) {
|
|
26
|
+
return url.endsWith("/") ? url : `${url}/`;
|
|
27
|
+
}
|
|
28
|
+
function randomWechatUin() {
|
|
29
|
+
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
|
|
30
|
+
return Buffer.from(String(uint32), "utf-8").toString("base64");
|
|
31
|
+
}
|
|
32
|
+
function buildHeaders(opts) {
|
|
33
|
+
const headers = {
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
AuthorizationType: "ilink_bot_token",
|
|
36
|
+
"Content-Length": String(Buffer.byteLength(opts.body, "utf-8")),
|
|
37
|
+
"X-WECHAT-UIN": randomWechatUin(),
|
|
38
|
+
};
|
|
39
|
+
if (opts.token?.trim()) {
|
|
40
|
+
headers.Authorization = `Bearer ${opts.token.trim()}`;
|
|
41
|
+
}
|
|
42
|
+
if (opts.routeTag) {
|
|
43
|
+
headers.SKRouteTag = opts.routeTag;
|
|
44
|
+
}
|
|
45
|
+
return headers;
|
|
46
|
+
}
|
|
47
|
+
async function apiFetch(params) {
|
|
48
|
+
const base = ensureTrailingSlash(params.baseUrl);
|
|
49
|
+
const url = new URL(params.endpoint, base);
|
|
50
|
+
const hdrs = buildHeaders({ token: params.token, body: params.body, routeTag: params.routeTag });
|
|
51
|
+
logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const t = setTimeout(() => controller.abort(), params.timeoutMs);
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(url.toString(), {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: hdrs,
|
|
58
|
+
body: params.body,
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
});
|
|
61
|
+
clearTimeout(t);
|
|
62
|
+
const rawText = await res.text();
|
|
63
|
+
logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
throw new Error(`${params.label} ${res.status}: ${rawText}`);
|
|
66
|
+
}
|
|
67
|
+
return rawText;
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
clearTimeout(t);
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export async function getUpdates(params) {
|
|
75
|
+
const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
|
|
76
|
+
try {
|
|
77
|
+
const rawText = await apiFetch({
|
|
78
|
+
baseUrl: params.baseUrl,
|
|
79
|
+
endpoint: "ilink/bot/getupdates",
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
get_updates_buf: params.get_updates_buf ?? "",
|
|
82
|
+
base_info: buildBaseInfo(),
|
|
83
|
+
}),
|
|
84
|
+
token: params.token,
|
|
85
|
+
routeTag: params.routeTag,
|
|
86
|
+
timeoutMs: timeout,
|
|
87
|
+
label: "getUpdates",
|
|
88
|
+
});
|
|
89
|
+
return JSON.parse(rawText);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
93
|
+
logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);
|
|
94
|
+
return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf };
|
|
95
|
+
}
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export async function getUploadUrl(params) {
|
|
100
|
+
const rawText = await apiFetch({
|
|
101
|
+
baseUrl: params.baseUrl,
|
|
102
|
+
endpoint: "ilink/bot/getuploadurl",
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
filekey: params.filekey,
|
|
105
|
+
media_type: params.media_type,
|
|
106
|
+
to_user_id: params.to_user_id,
|
|
107
|
+
rawsize: params.rawsize,
|
|
108
|
+
rawfilemd5: params.rawfilemd5,
|
|
109
|
+
filesize: params.filesize,
|
|
110
|
+
thumb_rawsize: params.thumb_rawsize,
|
|
111
|
+
thumb_rawfilemd5: params.thumb_rawfilemd5,
|
|
112
|
+
thumb_filesize: params.thumb_filesize,
|
|
113
|
+
no_need_thumb: params.no_need_thumb,
|
|
114
|
+
aeskey: params.aeskey,
|
|
115
|
+
base_info: buildBaseInfo(),
|
|
116
|
+
}),
|
|
117
|
+
token: params.token,
|
|
118
|
+
routeTag: params.routeTag,
|
|
119
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
120
|
+
label: "getUploadUrl",
|
|
121
|
+
});
|
|
122
|
+
return JSON.parse(rawText);
|
|
123
|
+
}
|
|
124
|
+
export async function sendMessage(params) {
|
|
125
|
+
await apiFetch({
|
|
126
|
+
baseUrl: params.baseUrl,
|
|
127
|
+
endpoint: "ilink/bot/sendmessage",
|
|
128
|
+
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
|
129
|
+
token: params.token,
|
|
130
|
+
routeTag: params.routeTag,
|
|
131
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
132
|
+
label: "sendMessage",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
export async function getConfig(params) {
|
|
136
|
+
const rawText = await apiFetch({
|
|
137
|
+
baseUrl: params.baseUrl,
|
|
138
|
+
endpoint: "ilink/bot/getconfig",
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
ilink_user_id: params.ilinkUserId,
|
|
141
|
+
context_token: params.contextToken,
|
|
142
|
+
base_info: buildBaseInfo(),
|
|
143
|
+
}),
|
|
144
|
+
token: params.token,
|
|
145
|
+
routeTag: params.routeTag,
|
|
146
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
147
|
+
label: "getConfig",
|
|
148
|
+
});
|
|
149
|
+
return JSON.parse(rawText);
|
|
150
|
+
}
|
|
151
|
+
export async function sendTyping(params) {
|
|
152
|
+
await apiFetch({
|
|
153
|
+
baseUrl: params.baseUrl,
|
|
154
|
+
endpoint: "ilink/bot/sendtyping",
|
|
155
|
+
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
|
156
|
+
token: params.token,
|
|
157
|
+
routeTag: params.routeTag,
|
|
158
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
159
|
+
label: "sendTyping",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type WeixinAccountData = {
|
|
2
|
+
token?: string;
|
|
3
|
+
savedAt?: string;
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
userId?: string;
|
|
6
|
+
accountId?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function setDataDir(dir: string): void;
|
|
9
|
+
export declare function loadAccount(): WeixinAccountData | null;
|
|
10
|
+
export declare function saveAccount(accountId: string, data: Partial<WeixinAccountData>): void;
|
|
11
|
+
export declare function loadSyncBuf(): string;
|
|
12
|
+
export declare function saveSyncBuf(buf: string): void;
|
|
13
|
+
export declare const DEFAULT_ILINK_BOT_TYPE = "3";
|
|
14
|
+
export type LoginResult = {
|
|
15
|
+
connected: boolean;
|
|
16
|
+
botToken?: string;
|
|
17
|
+
accountId?: string;
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
userId?: string;
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Full QR login flow: fetch QR → display in terminal → poll status → return credentials.
|
|
24
|
+
*/
|
|
25
|
+
export declare function loginWithQR(opts: {
|
|
26
|
+
apiBaseUrl: string;
|
|
27
|
+
botType?: string;
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
}): Promise<LoginResult>;
|