@jaxonchenjc/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/.claude-plugin/marketplace.json +27 -0
- package/.claude-plugin/plugin.json +12 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/bin/install.js +73 -0
- package/biome.json +26 -0
- package/package.json +39 -0
- package/server.ts +641 -0
- package/skills/configure/SKILL.md +64 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-wechat-channel",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "jaxxjj"
|
|
5
|
+
},
|
|
6
|
+
"metadata": {
|
|
7
|
+
"description": "WeChat channel plugin for Claude Code — two-way messaging bridge via iLink Bot API"
|
|
8
|
+
},
|
|
9
|
+
"plugins": [
|
|
10
|
+
{
|
|
11
|
+
"name": "wechat",
|
|
12
|
+
"source": {
|
|
13
|
+
"source": "github",
|
|
14
|
+
"repo": "jaxxjj/claude-wechat-channel"
|
|
15
|
+
},
|
|
16
|
+
"description": "WeChat channel for Claude Code — login with QR code, receive and reply to WeChat messages",
|
|
17
|
+
"version": "0.1.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "jaxxjj"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/jaxxjj/claude-wechat-channel",
|
|
22
|
+
"repository": "https://github.com/jaxxjj/claude-wechat-channel",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"keywords": ["wechat", "weixin", "channel", "messaging"]
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wechat",
|
|
3
|
+
"description": "WeChat channel for Claude Code \u2014 two-way messaging bridge via iLink Bot API. Login with QR code, receive and reply to WeChat messages from your Claude Code session.",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"wechat",
|
|
7
|
+
"weixin",
|
|
8
|
+
"messaging",
|
|
9
|
+
"channel",
|
|
10
|
+
"mcp"
|
|
11
|
+
]
|
|
12
|
+
}
|
package/.mcp.json
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jaxxjj
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# WeChat Channel for Claude Code
|
|
2
|
+
|
|
3
|
+
Two-way messaging bridge between WeChat and Claude Code via the iLink Bot API. Send a message from WeChat, Claude processes it and replies back — all while running against your local files.
|
|
4
|
+
|
|
5
|
+
**Background**: Tencent released [`@tencent-weixin/openclaw-weixin`](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin) — a WeChat channel for [OpenClaw](https://docs.openclaw.ai). This project brings the same capability to Claude Code, following the official [Telegram](https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/telegram) and [Discord](https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/discord) channel plugin patterns.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- QR code login — scan with WeChat to connect
|
|
10
|
+
- Text, image, voice, file, and video message support
|
|
11
|
+
- Voice messages auto-transcribed (WeChat server-side STT)
|
|
12
|
+
- Image/file/video download + AES-128-ECB decryption from CDN
|
|
13
|
+
- File/image/video sending via CDN upload
|
|
14
|
+
- Typing indicator ("对方正在输入...")
|
|
15
|
+
- Long message auto-chunking (4000 char limit)
|
|
16
|
+
|
|
17
|
+
## Prerequisites
|
|
18
|
+
|
|
19
|
+
- [Claude Code](https://claude.com/claude-code) v2.1.80+, authenticated with claude.ai
|
|
20
|
+
- [Bun](https://bun.sh) runtime
|
|
21
|
+
- A WeChat account
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
### Option A: Plugin marketplace (recommended)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# In Claude Code:
|
|
29
|
+
/plugin marketplace add jaxxjj/claude-wechat-channel
|
|
30
|
+
/plugin install wechat@claude-wechat-channel
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Option B: npm
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx claude-wechat-channel install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This auto-configures the MCP server in `~/.claude.json`.
|
|
40
|
+
|
|
41
|
+
### Option C: Manual (clone)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
git clone https://github.com/jaxxjj/claude-wechat-channel.git
|
|
45
|
+
cd claude-wechat-channel && bun install
|
|
46
|
+
# Add MCP server to ~/.claude.json manually (see below)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick Start
|
|
50
|
+
|
|
51
|
+
### 1. Start Claude Code with the channel
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# If installed via marketplace:
|
|
55
|
+
claude --dangerously-load-development-channels plugin:wechat@claude-wechat-channel
|
|
56
|
+
|
|
57
|
+
# If installed via npm or manual:
|
|
58
|
+
claude --dangerously-load-development-channels server:wechat
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 2. Log in with QR code
|
|
62
|
+
|
|
63
|
+
Inside Claude Code, run:
|
|
64
|
+
```
|
|
65
|
+
/wechat:configure login
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Scan the QR code with WeChat on your phone. Credentials are saved to `~/.claude/channels/wechat/account.json`.
|
|
69
|
+
|
|
70
|
+
### 3. Restart and use
|
|
71
|
+
|
|
72
|
+
Exit Claude Code, run the same start command again. Now any WeChat contact who messages you will have their message appear in your Claude Code session. Claude processes it and replies through WeChat.
|
|
73
|
+
|
|
74
|
+
## How It Works
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
WeChat contact sends you a message
|
|
78
|
+
→ iLink Bot API (long-polling)
|
|
79
|
+
→ server.ts receives message
|
|
80
|
+
→ MCP notification to Claude Code
|
|
81
|
+
→ Claude processes, calls reply tool
|
|
82
|
+
→ server.ts sends via iLink API
|
|
83
|
+
→ Reply appears in WeChat
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Access is gated by WeChat's QR code login — only contacts of the logged-in account can send messages. No separate pairing or allowlist needed (unlike Telegram/Discord bots which are publicly discoverable).
|
|
87
|
+
|
|
88
|
+
## Supported Message Types
|
|
89
|
+
|
|
90
|
+
| Type | Inbound | Outbound |
|
|
91
|
+
|------|---------|----------|
|
|
92
|
+
| Text | Direct text extraction | Auto-chunked at 4000 chars |
|
|
93
|
+
| Image | CDN download + AES decrypt → `image_path` in meta | CDN upload + encrypt |
|
|
94
|
+
| Voice | Server-side speech-to-text (`voice_item.text`) | — |
|
|
95
|
+
| File (PDF, etc) | CDN download + AES decrypt → `attachment_path` in meta | CDN upload + encrypt |
|
|
96
|
+
| Video | CDN download + AES decrypt → `attachment_path` in meta | CDN upload + encrypt |
|
|
97
|
+
|
|
98
|
+
## Tools Available to Claude
|
|
99
|
+
|
|
100
|
+
| Tool | Description |
|
|
101
|
+
|------|-------------|
|
|
102
|
+
| `reply` | Send text + optional file attachments back to WeChat |
|
|
103
|
+
| `send_typing` | Show "typing..." indicator in WeChat |
|
|
104
|
+
|
|
105
|
+
## Plugin Structure
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
claude-wechat-channel/
|
|
109
|
+
├── .claude-plugin/plugin.json # Plugin manifest
|
|
110
|
+
├── .mcp.json # MCP server config
|
|
111
|
+
├── package.json # Dependencies
|
|
112
|
+
├── server.ts # Channel server
|
|
113
|
+
├── skills/
|
|
114
|
+
│ └── configure/SKILL.md # /wechat:configure (QR login)
|
|
115
|
+
├── README.md
|
|
116
|
+
└── LICENSE
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## State Files
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
~/.claude/channels/wechat/
|
|
123
|
+
├── account.json # Login credentials (token + base URL), chmod 600
|
|
124
|
+
└── inbox/ # Downloaded media files (images, files, videos)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Environment Variables
|
|
128
|
+
|
|
129
|
+
| Variable | Default | Description |
|
|
130
|
+
|----------|---------|-------------|
|
|
131
|
+
| `WECHAT_STATE_DIR` | `~/.claude/channels/wechat` | Override state directory |
|
|
132
|
+
|
|
133
|
+
## Differences from Telegram/Discord Plugins
|
|
134
|
+
|
|
135
|
+
| Aspect | Telegram/Discord | WeChat |
|
|
136
|
+
|--------|-----------------|--------|
|
|
137
|
+
| Authentication | Paste bot token | Scan QR code with phone |
|
|
138
|
+
| Access control | Pairing codes + allowlist | QR code login (contacts-only) |
|
|
139
|
+
| Bot identity | Separate bot account | Your own WeChat account |
|
|
140
|
+
| Message history | Telegram: none; Discord: fetch_messages | None |
|
|
141
|
+
| Media handling | Telegram: direct URL; Discord: attachment download | CDN + AES-128-ECB encryption |
|
|
142
|
+
|
|
143
|
+
## Limitations
|
|
144
|
+
|
|
145
|
+
- **Session expiry**: WeChat sessions may expire; re-run `/wechat:configure login`
|
|
146
|
+
- **Single session**: Only one Claude Code session can poll at a time
|
|
147
|
+
- **No message history**: Real-time only, no lookback
|
|
148
|
+
- **Context tokens**: Stored in-memory; restart loses conversation context (next message restores it)
|
|
149
|
+
|
|
150
|
+
## Credits
|
|
151
|
+
|
|
152
|
+
- WeChat iLink Bot API by Tencent
|
|
153
|
+
- Channel architecture inspired by [claude-plugins-official](https://github.com/anthropics/claude-plugins-official) (Telegram/Discord)
|
|
154
|
+
- CDN encryption protocol referenced from [`@tencent-weixin/openclaw-weixin`](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin)
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
package/bin/install.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Auto-configure the WeChat MCP server in ~/.claude.json.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx claude-wechat-channel install
|
|
7
|
+
* npx claude-wechat-channel uninstall
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
11
|
+
import { homedir } from 'os'
|
|
12
|
+
import { dirname, join } from 'path'
|
|
13
|
+
import { fileURLToPath } from 'url'
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const PLUGIN_ROOT = join(__dirname, '..')
|
|
17
|
+
const CLAUDE_JSON = join(homedir(), '.claude.json')
|
|
18
|
+
const SERVER_NAME = 'wechat'
|
|
19
|
+
|
|
20
|
+
const MCP_CONFIG = {
|
|
21
|
+
command: 'bun',
|
|
22
|
+
args: ['run', '--cwd', PLUGIN_ROOT, '--shell=bun', '--silent', 'start'],
|
|
23
|
+
type: 'stdio',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function loadClaudeJson() {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(CLAUDE_JSON, 'utf8'))
|
|
29
|
+
} catch {
|
|
30
|
+
return {}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function saveClaudeJson(data) {
|
|
35
|
+
writeFileSync(CLAUDE_JSON, JSON.stringify(data, null, 2) + '\n')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const cmd = process.argv[2]
|
|
39
|
+
|
|
40
|
+
if (cmd === 'install' || !cmd) {
|
|
41
|
+
const config = loadClaudeJson()
|
|
42
|
+
if (!config.mcpServers) config.mcpServers = {}
|
|
43
|
+
config.mcpServers[SERVER_NAME] = MCP_CONFIG
|
|
44
|
+
saveClaudeJson(config)
|
|
45
|
+
|
|
46
|
+
// Create state directory
|
|
47
|
+
const stateDir = join(homedir(), '.claude', 'channels', 'wechat')
|
|
48
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 })
|
|
49
|
+
|
|
50
|
+
console.log(`✅ WeChat MCP server configured in ${CLAUDE_JSON}`)
|
|
51
|
+
console.log(` Plugin root: ${PLUGIN_ROOT}`)
|
|
52
|
+
console.log('')
|
|
53
|
+
console.log('Next steps:')
|
|
54
|
+
console.log(' 1. Start Claude Code:')
|
|
55
|
+
console.log(' claude --dangerously-load-development-channels server:wechat')
|
|
56
|
+
console.log('')
|
|
57
|
+
console.log(' 2. Log in:')
|
|
58
|
+
console.log(' /wechat:configure login')
|
|
59
|
+
console.log('')
|
|
60
|
+
console.log(' 3. Scan QR code with WeChat, restart Claude Code, done.')
|
|
61
|
+
} else if (cmd === 'uninstall') {
|
|
62
|
+
const config = loadClaudeJson()
|
|
63
|
+
if (config.mcpServers?.[SERVER_NAME]) {
|
|
64
|
+
delete config.mcpServers[SERVER_NAME]
|
|
65
|
+
saveClaudeJson(config)
|
|
66
|
+
console.log(`✅ WeChat MCP server removed from ${CLAUDE_JSON}`)
|
|
67
|
+
} else {
|
|
68
|
+
console.log('WeChat MCP server not found in config.')
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
console.log('Usage: npx claude-wechat-channel [install|uninstall]')
|
|
72
|
+
process.exit(1)
|
|
73
|
+
}
|
package/biome.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
|
3
|
+
"linter": {
|
|
4
|
+
"enabled": true,
|
|
5
|
+
"rules": {
|
|
6
|
+
"recommended": true,
|
|
7
|
+
"suspicious": {
|
|
8
|
+
"noExplicitAny": "off"
|
|
9
|
+
},
|
|
10
|
+
"style": {
|
|
11
|
+
"noNonNullAssertion": "off",
|
|
12
|
+
"useNodejsImportProtocol": "off",
|
|
13
|
+
"useTemplate": "off"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"formatter": {
|
|
18
|
+
"enabled": false
|
|
19
|
+
},
|
|
20
|
+
"javascript": {
|
|
21
|
+
"formatter": {
|
|
22
|
+
"quoteStyle": "single",
|
|
23
|
+
"semicolons": "asNeeded"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jaxonchenjc/claude-wechat-channel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "WeChat channel plugin for Claude Code — two-way messaging bridge via iLink Bot API",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"claude-wechat-channel": "./bin/install.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "bun install --no-summary && bun server.ts",
|
|
12
|
+
"lint": "biome check .",
|
|
13
|
+
"lint:fix": "biome check --fix .",
|
|
14
|
+
"typecheck": "bun x tsc --noEmit"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/jaxxjj/claude-wechat-channel.git"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"wechat",
|
|
22
|
+
"weixin",
|
|
23
|
+
"claude",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"channel",
|
|
26
|
+
"mcp",
|
|
27
|
+
"messaging"
|
|
28
|
+
],
|
|
29
|
+
"author": "jaxxjj",
|
|
30
|
+
"homepage": "https://github.com/jaxxjj/claude-wechat-channel",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
33
|
+
"qrcode-terminal": "^0.12.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@biomejs/biome": "^2.4.8",
|
|
37
|
+
"bun-types": "^1.3.11"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/server.ts
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* WeChat channel for Claude Code.
|
|
4
|
+
*
|
|
5
|
+
* Self-contained MCP server. Access is gated by WeChat's QR code login —
|
|
6
|
+
* only contacts of the logged-in account can send messages. No separate
|
|
7
|
+
* pairing or allowlist needed (unlike Telegram/Discord bots).
|
|
8
|
+
*
|
|
9
|
+
* State lives in ~/.claude/channels/wechat/.
|
|
10
|
+
* Uses the iLink Bot API for WeChat messaging (long-polling + HTTP).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
14
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
15
|
+
import {
|
|
16
|
+
CallToolRequestSchema,
|
|
17
|
+
ListToolsRequestSchema,
|
|
18
|
+
} from '@modelcontextprotocol/sdk/types.js'
|
|
19
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
|
|
20
|
+
import {
|
|
21
|
+
chmodSync, mkdirSync, readFileSync,
|
|
22
|
+
realpathSync, statSync, writeFileSync,
|
|
23
|
+
} from 'fs'
|
|
24
|
+
import { homedir } from 'os'
|
|
25
|
+
import { basename, extname, join, sep } from 'path'
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// State directories & env loading
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const STATE_DIR = process.env.WECHAT_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'wechat')
|
|
32
|
+
const ENV_FILE = join(STATE_DIR, '.env')
|
|
33
|
+
const ACCOUNT_FILE = join(STATE_DIR, 'account.json')
|
|
34
|
+
const INBOX_DIR = join(STATE_DIR, 'inbox')
|
|
35
|
+
|
|
36
|
+
// Load .env — plugin-spawned servers don't get an env block.
|
|
37
|
+
try {
|
|
38
|
+
chmodSync(ENV_FILE, 0o600)
|
|
39
|
+
for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) {
|
|
40
|
+
const m = line.match(/^(\w+)=(.*)$/)
|
|
41
|
+
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2]
|
|
42
|
+
}
|
|
43
|
+
} catch {}
|
|
44
|
+
|
|
45
|
+
// Last-resort safety net — keep the process alive on unhandled errors.
|
|
46
|
+
process.on('unhandledRejection', err => {
|
|
47
|
+
process.stderr.write(`wechat channel: unhandled rejection: ${err}\n`)
|
|
48
|
+
})
|
|
49
|
+
process.on('uncaughtException', err => {
|
|
50
|
+
process.stderr.write(`wechat channel: uncaught exception: ${err}\n`)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// iLink Bot API — WeChat HTTP protocol
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
interface WechatAccount {
|
|
58
|
+
token: string
|
|
59
|
+
baseUrl: string
|
|
60
|
+
cdnBaseUrl?: string
|
|
61
|
+
userId?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface WechatMessage {
|
|
65
|
+
seq?: number
|
|
66
|
+
message_id?: number
|
|
67
|
+
from_user_id?: string
|
|
68
|
+
to_user_id?: string
|
|
69
|
+
create_time_ms?: number
|
|
70
|
+
session_id?: string
|
|
71
|
+
message_type?: number // 1=USER, 2=BOT
|
|
72
|
+
message_state?: number // 0=NEW, 1=GENERATING, 2=FINISH
|
|
73
|
+
item_list?: MessageItem[]
|
|
74
|
+
context_token?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface MessageItem {
|
|
78
|
+
type: number // 1=TEXT, 2=IMAGE, 3=VOICE, 4=FILE, 5=VIDEO
|
|
79
|
+
text_item?: { text: string }
|
|
80
|
+
image_item?: { media?: CdnMedia; aeskey?: string }
|
|
81
|
+
voice_item?: { media?: CdnMedia; text?: string }
|
|
82
|
+
file_item?: { media?: CdnMedia; file_name?: string }
|
|
83
|
+
video_item?: { media?: CdnMedia }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface CdnMedia {
|
|
87
|
+
encrypt_query_param?: string
|
|
88
|
+
aes_key?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function loadAccount(): WechatAccount | null {
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(readFileSync(ACCOUNT_FILE, 'utf8'))
|
|
94
|
+
} catch {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function apiHeaders(token: string): Record<string, string> {
|
|
100
|
+
const uin = Buffer.from(String(Math.floor(Math.random() * 0xFFFFFFFF))).toString('base64')
|
|
101
|
+
return {
|
|
102
|
+
'Content-Type': 'application/json',
|
|
103
|
+
'AuthorizationType': 'ilink_bot_token',
|
|
104
|
+
'Authorization': `Bearer ${token}`,
|
|
105
|
+
'X-WECHAT-UIN': uin,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function apiCall(baseUrl: string, endpoint: string, body: unknown, token: string, timeoutMs = 40000): Promise<unknown> {
|
|
110
|
+
const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
|
|
111
|
+
const url = new URL(endpoint, base).toString()
|
|
112
|
+
const controller = new AbortController()
|
|
113
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(url, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: apiHeaders(token),
|
|
118
|
+
body: JSON.stringify(body),
|
|
119
|
+
signal: controller.signal,
|
|
120
|
+
})
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
const text = await res.text().catch(() => '')
|
|
123
|
+
throw new Error(`HTTP ${res.status}: ${text}`)
|
|
124
|
+
}
|
|
125
|
+
return res.json()
|
|
126
|
+
} finally {
|
|
127
|
+
clearTimeout(timer)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Context token management — WeChat requires echoing context_token in replies
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
const contextTokens = new Map<string, string>()
|
|
136
|
+
|
|
137
|
+
function setContextToken(userId: string, token: string): void {
|
|
138
|
+
contextTokens.set(userId, token)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getContextToken(userId: string): string | undefined {
|
|
142
|
+
return contextTokens.get(userId)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Typing indicator
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
async function sendTyping(account: WechatAccount, userId: string, status: 1 | 2 = 1): Promise<void> {
|
|
150
|
+
try {
|
|
151
|
+
const configRes = await apiCall(account.baseUrl, 'ilink/bot/getconfig', {
|
|
152
|
+
ilink_user_id: userId,
|
|
153
|
+
context_token: getContextToken(userId),
|
|
154
|
+
}, account.token, 10000) as Record<string, unknown>
|
|
155
|
+
|
|
156
|
+
const typingTicket = configRes.typing_ticket as string | undefined
|
|
157
|
+
if (!typingTicket) return
|
|
158
|
+
|
|
159
|
+
await apiCall(account.baseUrl, 'ilink/bot/sendtyping', {
|
|
160
|
+
ilink_user_id: userId,
|
|
161
|
+
typing_ticket: typingTicket,
|
|
162
|
+
status,
|
|
163
|
+
}, account.token, 10000)
|
|
164
|
+
} catch {
|
|
165
|
+
// Typing is best-effort — swallow errors.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Send message
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
async function sendMessage(account: WechatAccount, toUserId: string, text: string): Promise<{ messageId: string }> {
|
|
174
|
+
const contextToken = getContextToken(toUserId)
|
|
175
|
+
if (!contextToken) {
|
|
176
|
+
throw new Error(`No context_token for ${toUserId} — the user must message first`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const clientId = `claude-wechat-${randomBytes(8).toString('hex')}`
|
|
180
|
+
const res = await apiCall(account.baseUrl, 'ilink/bot/sendmessage', {
|
|
181
|
+
msg: {
|
|
182
|
+
from_user_id: '',
|
|
183
|
+
to_user_id: toUserId,
|
|
184
|
+
client_id: clientId,
|
|
185
|
+
context_token: contextToken,
|
|
186
|
+
message_type: 2, // BOT
|
|
187
|
+
message_state: 2, // FINISH
|
|
188
|
+
item_list: [{ type: 1, text_item: { text } }],
|
|
189
|
+
},
|
|
190
|
+
base_info: { channel_version: '0.0.1' },
|
|
191
|
+
}, account.token) as Record<string, unknown>
|
|
192
|
+
|
|
193
|
+
return { messageId: String(res.message_id ?? clientId) }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// CDN — AES-128-ECB crypto + upload/download
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
const DEFAULT_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
|
|
201
|
+
const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
|
|
202
|
+
const MAX_CHUNK_LIMIT = 4000
|
|
203
|
+
|
|
204
|
+
function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
|
|
205
|
+
const cipher = createCipheriv('aes-128-ecb', key, null)
|
|
206
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()])
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
|
|
210
|
+
const decipher = createDecipheriv('aes-128-ecb', key, null)
|
|
211
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseAesKey(aesKeyBase64: string): Buffer {
|
|
215
|
+
const decoded = Buffer.from(aesKeyBase64, 'base64')
|
|
216
|
+
if (decoded.length === 16) return decoded
|
|
217
|
+
if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString('ascii'))) {
|
|
218
|
+
return Buffer.from(decoded.toString('ascii'), 'hex')
|
|
219
|
+
}
|
|
220
|
+
throw new Error(`aes_key decode failed: expected 16 or 32 bytes, got ${decoded.length}`)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function aesEcbPaddedSize(plaintextSize: number): number {
|
|
224
|
+
return Math.ceil((plaintextSize + 1) / 16) * 16
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function downloadAndDecrypt(encryptQueryParam: string, aesKeyBase64: string, cdnBaseUrl: string): Promise<Buffer> {
|
|
228
|
+
const key = parseAesKey(aesKeyBase64)
|
|
229
|
+
const url = `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptQueryParam)}`
|
|
230
|
+
const res = await fetch(url)
|
|
231
|
+
if (!res.ok) throw new Error(`CDN download failed: HTTP ${res.status}`)
|
|
232
|
+
const encrypted = Buffer.from(await res.arrayBuffer())
|
|
233
|
+
return decryptAesEcb(encrypted, key)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function downloadMediaToInbox(cdnMedia: CdnMedia, cdnBaseUrl: string, ext: string): Promise<string | undefined> {
|
|
237
|
+
if (!cdnMedia.encrypt_query_param || !cdnMedia.aes_key) return undefined
|
|
238
|
+
try {
|
|
239
|
+
const buf = await downloadAndDecrypt(cdnMedia.encrypt_query_param, cdnMedia.aes_key, cdnBaseUrl)
|
|
240
|
+
mkdirSync(INBOX_DIR, { recursive: true })
|
|
241
|
+
const filePath = join(INBOX_DIR, `${Date.now()}-${randomBytes(4).toString('hex')}.${ext}`)
|
|
242
|
+
writeFileSync(filePath, buf)
|
|
243
|
+
return filePath
|
|
244
|
+
} catch (err) {
|
|
245
|
+
process.stderr.write(`wechat channel: media download failed: ${err}\n`)
|
|
246
|
+
return undefined
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function inferMediaType(filePath: string): number {
|
|
251
|
+
const ext = extname(filePath).toLowerCase()
|
|
252
|
+
if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'].includes(ext)) return 1
|
|
253
|
+
if (['.mp4', '.mov', '.avi', '.mkv', '.webm'].includes(ext)) return 2
|
|
254
|
+
return 3
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function sendFileMessage(
|
|
258
|
+
account: WechatAccount,
|
|
259
|
+
toUserId: string,
|
|
260
|
+
filePath: string,
|
|
261
|
+
caption?: string,
|
|
262
|
+
): Promise<{ messageId: string }> {
|
|
263
|
+
const contextToken = getContextToken(toUserId)
|
|
264
|
+
if (!contextToken) {
|
|
265
|
+
throw new Error(`No context_token for ${toUserId} — the user must message first`)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const plaintext = readFileSync(filePath)
|
|
269
|
+
const rawsize = plaintext.length
|
|
270
|
+
const rawfilemd5 = createHash('md5').update(plaintext).digest('hex')
|
|
271
|
+
const filesize = aesEcbPaddedSize(rawsize)
|
|
272
|
+
const filekey = randomBytes(16).toString('hex')
|
|
273
|
+
const aeskey = randomBytes(16)
|
|
274
|
+
const mediaType = inferMediaType(filePath)
|
|
275
|
+
const cdnBaseUrl = account.cdnBaseUrl ?? DEFAULT_CDN_BASE_URL
|
|
276
|
+
|
|
277
|
+
const uploadRes = await apiCall(account.baseUrl, 'ilink/bot/getuploadurl', {
|
|
278
|
+
filekey, media_type: mediaType, to_user_id: toUserId,
|
|
279
|
+
rawsize, rawfilemd5, filesize,
|
|
280
|
+
}, account.token) as Record<string, unknown>
|
|
281
|
+
|
|
282
|
+
const uploadParam = uploadRes.upload_param as string | undefined
|
|
283
|
+
if (!uploadParam) throw new Error(`getuploadurl returned no upload_param: ${JSON.stringify(uploadRes)}`)
|
|
284
|
+
|
|
285
|
+
const ciphertext = encryptAesEcb(plaintext, aeskey)
|
|
286
|
+
const cdnUrl = `${cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`
|
|
287
|
+
const cdnRes = await fetch(cdnUrl, {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
290
|
+
body: new Uint8Array(ciphertext),
|
|
291
|
+
})
|
|
292
|
+
if (!cdnRes.ok) throw new Error(`CDN upload failed: HTTP ${cdnRes.status}`)
|
|
293
|
+
const downloadParam = cdnRes.headers.get('x-encrypted-param')
|
|
294
|
+
if (!downloadParam) throw new Error('CDN upload response missing x-encrypted-param header')
|
|
295
|
+
|
|
296
|
+
const aesKeyBase64 = aeskey.toString('base64')
|
|
297
|
+
const cdnMedia = { encrypt_query_param: downloadParam, aes_key: aesKeyBase64 }
|
|
298
|
+
const fileName = basename(filePath)
|
|
299
|
+
|
|
300
|
+
let mediaItem: Record<string, unknown>
|
|
301
|
+
if (mediaType === 1) {
|
|
302
|
+
mediaItem = { type: 2, image_item: { media: cdnMedia, mid_size: filesize } }
|
|
303
|
+
} else if (mediaType === 2) {
|
|
304
|
+
mediaItem = { type: 5, video_item: { media: cdnMedia, video_size: filesize } }
|
|
305
|
+
} else {
|
|
306
|
+
mediaItem = { type: 4, file_item: { media: cdnMedia, file_name: fileName, len: String(rawsize) } }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const items: Record<string, unknown>[] = []
|
|
310
|
+
if (caption) items.push({ type: 1, text_item: { text: caption } })
|
|
311
|
+
items.push(mediaItem)
|
|
312
|
+
|
|
313
|
+
let lastMessageId = '0'
|
|
314
|
+
for (const item of items) {
|
|
315
|
+
const itemClientId = `claude-wechat-${randomBytes(8).toString('hex')}`
|
|
316
|
+
const res = await apiCall(account.baseUrl, 'ilink/bot/sendmessage', {
|
|
317
|
+
msg: {
|
|
318
|
+
from_user_id: '', to_user_id: toUserId, client_id: itemClientId,
|
|
319
|
+
context_token: contextToken, message_type: 2, message_state: 2,
|
|
320
|
+
item_list: [item],
|
|
321
|
+
},
|
|
322
|
+
base_info: { channel_version: '0.0.1' },
|
|
323
|
+
}, account.token) as Record<string, unknown>
|
|
324
|
+
lastMessageId = String(res.message_id ?? res.msg_id ?? '0')
|
|
325
|
+
}
|
|
326
|
+
return { messageId: lastMessageId }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Refuse to send channel state files (except inbox).
|
|
330
|
+
function assertSendable(f: string): void {
|
|
331
|
+
let real: string, stateReal: string
|
|
332
|
+
try {
|
|
333
|
+
real = realpathSync(f)
|
|
334
|
+
stateReal = realpathSync(STATE_DIR)
|
|
335
|
+
} catch { return }
|
|
336
|
+
const inbox = join(stateReal, 'inbox')
|
|
337
|
+
if (real.startsWith(stateReal + sep) && !real.startsWith(inbox + sep)) {
|
|
338
|
+
throw new Error(`refusing to send channel state: ${f}`)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// Text chunking
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
function chunk(text: string, limit: number): string[] {
|
|
347
|
+
if (text.length <= limit) return [text]
|
|
348
|
+
const out: string[] = []
|
|
349
|
+
let rest = text
|
|
350
|
+
while (rest.length > limit) {
|
|
351
|
+
const para = rest.lastIndexOf('\n\n', limit)
|
|
352
|
+
const line = rest.lastIndexOf('\n', limit)
|
|
353
|
+
const space = rest.lastIndexOf(' ', limit)
|
|
354
|
+
const cut = para > limit / 2 ? para : line > limit / 2 ? line : space > 0 ? space : limit
|
|
355
|
+
out.push(rest.slice(0, cut))
|
|
356
|
+
rest = rest.slice(cut).replace(/^\n+/, '')
|
|
357
|
+
}
|
|
358
|
+
if (rest) out.push(rest)
|
|
359
|
+
return out
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Extract text from WeChat message items
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
function safeName(s: string | undefined): string | undefined {
|
|
367
|
+
return s?.replace(/[<>[\]\r\n;]/g, '_')
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function extractText(msg: WechatMessage): string {
|
|
371
|
+
if (!msg.item_list || msg.item_list.length === 0) return ''
|
|
372
|
+
const parts: string[] = []
|
|
373
|
+
for (const item of msg.item_list) {
|
|
374
|
+
if (item.type === 1 && item.text_item?.text) {
|
|
375
|
+
parts.push(item.text_item.text)
|
|
376
|
+
} else if (item.type === 2) {
|
|
377
|
+
parts.push('(image)')
|
|
378
|
+
} else if (item.type === 3) {
|
|
379
|
+
if (item.voice_item?.text) {
|
|
380
|
+
parts.push(item.voice_item.text)
|
|
381
|
+
} else {
|
|
382
|
+
parts.push('(voice)')
|
|
383
|
+
}
|
|
384
|
+
} else if (item.type === 4) {
|
|
385
|
+
const name = safeName(item.file_item?.file_name) ?? 'unknown'
|
|
386
|
+
parts.push(`(file: ${name})`)
|
|
387
|
+
} else if (item.type === 5) {
|
|
388
|
+
parts.push('(video)')
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return parts.join('\n') || ''
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// MCP Server — channel declaration + tools
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
const mcp = new Server(
|
|
399
|
+
{ name: 'wechat', version: '1.0.0' },
|
|
400
|
+
{
|
|
401
|
+
capabilities: { tools: {}, experimental: { 'claude/channel': {} } },
|
|
402
|
+
instructions: [
|
|
403
|
+
'The sender reads WeChat, not this session. Anything you want them to see must go through the reply tool — your transcript output never reaches their chat.',
|
|
404
|
+
'',
|
|
405
|
+
'Messages from WeChat arrive as <channel source="wechat" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file — it is a photo the sender attached. If the tag has attachment_path, Read that file — it is a document (PDF, etc) or video the sender sent. Reply with the reply tool — pass chat_id back.',
|
|
406
|
+
'',
|
|
407
|
+
'reply accepts file paths (files: ["/abs/path.png"]) for attachments. Images send as photos; videos as video messages; other types as file attachments. Use send_typing before long operations to show "typing..." in WeChat.',
|
|
408
|
+
'',
|
|
409
|
+
'WeChat has no history or search API — you only see messages as they arrive. If you need earlier context, ask the user to paste it or summarize.',
|
|
410
|
+
].join('\n'),
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
415
|
+
tools: [
|
|
416
|
+
{
|
|
417
|
+
name: 'reply',
|
|
418
|
+
description:
|
|
419
|
+
'Reply on WeChat. Pass chat_id from the inbound message. Optionally pass files (absolute paths) to attach images, videos, or documents.',
|
|
420
|
+
inputSchema: {
|
|
421
|
+
type: 'object',
|
|
422
|
+
properties: {
|
|
423
|
+
chat_id: { type: 'string', description: 'WeChat user ID from the inbound <channel> tag' },
|
|
424
|
+
text: { type: 'string', description: 'Message text to send' },
|
|
425
|
+
files: {
|
|
426
|
+
type: 'array',
|
|
427
|
+
items: { type: 'string' },
|
|
428
|
+
description: 'Absolute file paths to attach. Images send as photos; videos as video messages; others as file attachments. Max 50MB each.',
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
required: ['chat_id', 'text'],
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
name: 'send_typing',
|
|
436
|
+
description: 'Show typing indicator in WeChat. Call before long operations.',
|
|
437
|
+
inputSchema: {
|
|
438
|
+
type: 'object',
|
|
439
|
+
properties: {
|
|
440
|
+
chat_id: { type: 'string', description: 'WeChat user ID' },
|
|
441
|
+
},
|
|
442
|
+
required: ['chat_id'],
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
],
|
|
446
|
+
}))
|
|
447
|
+
|
|
448
|
+
mcp.setRequestHandler(CallToolRequestSchema, async req => {
|
|
449
|
+
const args = (req.params.arguments ?? {}) as Record<string, unknown>
|
|
450
|
+
const account = loadAccount()
|
|
451
|
+
if (!account) {
|
|
452
|
+
return {
|
|
453
|
+
content: [{ type: 'text', text: 'WeChat not configured. Run /wechat:configure to log in.' }],
|
|
454
|
+
isError: true,
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
switch (req.params.name) {
|
|
460
|
+
case 'reply': {
|
|
461
|
+
const chat_id = args.chat_id as string
|
|
462
|
+
const text = args.text as string
|
|
463
|
+
const files = (args.files as string[] | undefined) ?? []
|
|
464
|
+
|
|
465
|
+
for (const f of files) {
|
|
466
|
+
assertSendable(f)
|
|
467
|
+
const st = statSync(f)
|
|
468
|
+
if (st.size > MAX_ATTACHMENT_BYTES) {
|
|
469
|
+
throw new Error(`file too large: ${f} (${(st.size / 1024 / 1024).toFixed(1)}MB, max 50MB)`)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const chunks = chunk(text, MAX_CHUNK_LIMIT)
|
|
474
|
+
const sentIds: string[] = []
|
|
475
|
+
|
|
476
|
+
await sendTyping(account, chat_id, 2).catch(() => {})
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
for (const c of chunks) {
|
|
480
|
+
const result = await sendMessage(account, chat_id, c)
|
|
481
|
+
sentIds.push(result.messageId)
|
|
482
|
+
}
|
|
483
|
+
} catch (err) {
|
|
484
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
485
|
+
throw new Error(`reply failed after ${sentIds.length} of ${chunks.length} chunk(s) sent: ${msg}`)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
for (const f of files) {
|
|
489
|
+
const result = await sendFileMessage(account, chat_id, f)
|
|
490
|
+
sentIds.push(result.messageId)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const result = sentIds.length === 1
|
|
494
|
+
? `sent (id: ${sentIds[0]})`
|
|
495
|
+
: `sent ${sentIds.length} parts (ids: ${sentIds.join(', ')})`
|
|
496
|
+
return { content: [{ type: 'text', text: result }] }
|
|
497
|
+
}
|
|
498
|
+
case 'send_typing': {
|
|
499
|
+
await sendTyping(account, args.chat_id as string, 1)
|
|
500
|
+
return { content: [{ type: 'text', text: 'typing indicator sent' }] }
|
|
501
|
+
}
|
|
502
|
+
default:
|
|
503
|
+
return {
|
|
504
|
+
content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }],
|
|
505
|
+
isError: true,
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
} catch (err) {
|
|
509
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
510
|
+
return {
|
|
511
|
+
content: [{ type: 'text', text: `${req.params.name} failed: ${msg}` }],
|
|
512
|
+
isError: true,
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
// Connect MCP over stdio
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
await mcp.connect(new StdioServerTransport())
|
|
522
|
+
|
|
523
|
+
let shuttingDown = false
|
|
524
|
+
function shutdown(): void {
|
|
525
|
+
if (shuttingDown) return
|
|
526
|
+
shuttingDown = true
|
|
527
|
+
process.stderr.write('wechat channel: shutting down\n')
|
|
528
|
+
setTimeout(() => process.exit(0), 2000)
|
|
529
|
+
}
|
|
530
|
+
process.stdin.on('end', shutdown)
|
|
531
|
+
process.stdin.on('close', shutdown)
|
|
532
|
+
process.on('SIGTERM', shutdown)
|
|
533
|
+
process.on('SIGINT', shutdown)
|
|
534
|
+
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// Long-polling loop — getupdates
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
|
|
539
|
+
void (async () => {
|
|
540
|
+
const account = loadAccount()
|
|
541
|
+
if (!account) {
|
|
542
|
+
process.stderr.write(
|
|
543
|
+
`wechat channel: not logged in\n` +
|
|
544
|
+
` run /wechat:configure to scan QR code and log in\n`,
|
|
545
|
+
)
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
process.stderr.write(`wechat channel: starting message polling\n`)
|
|
550
|
+
|
|
551
|
+
let syncBuf = ''
|
|
552
|
+
|
|
553
|
+
for (let attempt = 1; !shuttingDown; ) {
|
|
554
|
+
try {
|
|
555
|
+
const res = await apiCall(account.baseUrl, 'ilink/bot/getupdates', {
|
|
556
|
+
get_updates_buf: syncBuf,
|
|
557
|
+
}, account.token, 40000) as Record<string, unknown>
|
|
558
|
+
|
|
559
|
+
const errcode = res.errcode as number | undefined
|
|
560
|
+
if (errcode === -14) {
|
|
561
|
+
process.stderr.write('wechat channel: session expired (errcode -14). Run /wechat:configure to re-login.\n')
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (res.ret !== 0 && res.ret !== undefined) {
|
|
566
|
+
process.stderr.write(`wechat channel: getupdates returned ret=${res.ret} errmsg=${res.errmsg ?? ''}\n`)
|
|
567
|
+
await new Promise(r => setTimeout(r, 5000))
|
|
568
|
+
continue
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (res.get_updates_buf) syncBuf = res.get_updates_buf as string
|
|
572
|
+
|
|
573
|
+
const msgs = (res.msgs ?? []) as WechatMessage[]
|
|
574
|
+
attempt = 1
|
|
575
|
+
|
|
576
|
+
for (const msg of msgs) {
|
|
577
|
+
if (msg.message_type !== 1) continue // skip BOT echoes
|
|
578
|
+
if (msg.message_state === 1) continue // skip GENERATING
|
|
579
|
+
|
|
580
|
+
const senderId = msg.from_user_id
|
|
581
|
+
if (!senderId) continue
|
|
582
|
+
|
|
583
|
+
if (msg.context_token) setContextToken(senderId, msg.context_token)
|
|
584
|
+
|
|
585
|
+
const text = extractText(msg)
|
|
586
|
+
if (!text) continue
|
|
587
|
+
|
|
588
|
+
const ts = msg.create_time_ms
|
|
589
|
+
? new Date(msg.create_time_ms).toISOString()
|
|
590
|
+
: new Date().toISOString()
|
|
591
|
+
|
|
592
|
+
void sendTyping(account, senderId, 1).catch(() => {})
|
|
593
|
+
|
|
594
|
+
// Download media — defer until after we know the message is valid
|
|
595
|
+
const cdnBaseUrl = account.cdnBaseUrl ?? DEFAULT_CDN_BASE_URL
|
|
596
|
+
let imagePath: string | undefined
|
|
597
|
+
let attachmentPath: string | undefined
|
|
598
|
+
let attachmentName: string | undefined
|
|
599
|
+
if (msg.item_list) {
|
|
600
|
+
for (const item of msg.item_list) {
|
|
601
|
+
if (item.type === 2 && item.image_item?.media) {
|
|
602
|
+
imagePath = await downloadMediaToInbox(item.image_item.media, cdnBaseUrl, 'jpg')
|
|
603
|
+
} else if (item.type === 4 && item.file_item?.media) {
|
|
604
|
+
const fname = item.file_item.file_name ?? 'file'
|
|
605
|
+
const ext = fname.includes('.') ? fname.split('.').pop()! : 'bin'
|
|
606
|
+
attachmentPath = await downloadMediaToInbox(item.file_item.media, cdnBaseUrl, ext)
|
|
607
|
+
attachmentName = safeName(fname)
|
|
608
|
+
} else if (item.type === 5 && item.video_item?.media) {
|
|
609
|
+
attachmentPath = await downloadMediaToInbox(item.video_item.media, cdnBaseUrl, 'mp4')
|
|
610
|
+
attachmentName = 'video.mp4'
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const meta: Record<string, string> = {
|
|
616
|
+
chat_id: senderId,
|
|
617
|
+
user: senderId,
|
|
618
|
+
user_id: senderId,
|
|
619
|
+
ts,
|
|
620
|
+
}
|
|
621
|
+
if (msg.message_id != null) meta.message_id = String(msg.message_id)
|
|
622
|
+
if (imagePath) meta.image_path = imagePath
|
|
623
|
+
if (attachmentPath) meta.attachment_path = attachmentPath
|
|
624
|
+
if (attachmentName) meta.attachment_name = attachmentName
|
|
625
|
+
|
|
626
|
+
mcp.notification({
|
|
627
|
+
method: 'notifications/claude/channel',
|
|
628
|
+
params: { content: text, meta },
|
|
629
|
+
}).catch(err => {
|
|
630
|
+
process.stderr.write(`wechat channel: failed to deliver inbound to Claude: ${err}\n`)
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
} catch (err) {
|
|
634
|
+
if (shuttingDown) break
|
|
635
|
+
const delay = Math.min(1000 * attempt, 15000)
|
|
636
|
+
process.stderr.write(`wechat channel: polling error: ${err}, retrying in ${delay / 1000}s\n`)
|
|
637
|
+
await new Promise(r => setTimeout(r, delay))
|
|
638
|
+
attempt++
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
})()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: configure
|
|
3
|
+
description: Set up the WeChat channel — scan QR code to log in. Use when the user asks to configure WeChat, log in, check status, or reconnect.
|
|
4
|
+
user-invocable: true
|
|
5
|
+
allowed-tools:
|
|
6
|
+
- Read
|
|
7
|
+
- Write
|
|
8
|
+
- Bash(ls *)
|
|
9
|
+
- Bash(mkdir *)
|
|
10
|
+
- Bash(chmod *)
|
|
11
|
+
- Bash(curl *)
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# /wechat:configure — WeChat Channel Setup
|
|
15
|
+
|
|
16
|
+
Handles QR code login. WeChat access is gated by the QR code scan itself —
|
|
17
|
+
only contacts of the logged-in account can send messages. No separate
|
|
18
|
+
pairing or allowlist needed.
|
|
19
|
+
|
|
20
|
+
Arguments passed: `$ARGUMENTS`
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Dispatch on arguments
|
|
25
|
+
|
|
26
|
+
### No args — status
|
|
27
|
+
|
|
28
|
+
1. **Account** — check `~/.claude/channels/wechat/account.json`. Show
|
|
29
|
+
logged-in/not-logged-in. If logged in, show the base URL (masked).
|
|
30
|
+
2. **What next**:
|
|
31
|
+
- Not logged in → *"Run `/wechat:configure login` to scan QR code."*
|
|
32
|
+
- Logged in → *"Ready. Messages from your WeChat contacts will reach
|
|
33
|
+
this session."*
|
|
34
|
+
|
|
35
|
+
### `login` — QR code login
|
|
36
|
+
|
|
37
|
+
1. `mkdir -p ~/.claude/channels/wechat`
|
|
38
|
+
2. GET `https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3`
|
|
39
|
+
3. The response has `qrcode` (ID) and `qrcode_img_content` (URL).
|
|
40
|
+
Display the `qrcode_img_content` URL as a QR code in the terminal
|
|
41
|
+
using `npx qrcode-terminal "<url>"`, or show the URL directly.
|
|
42
|
+
4. Tell the user: *"Scan this QR code with WeChat on your phone."*
|
|
43
|
+
5. Poll status every 2-3 seconds until confirmed or timeout (5 minutes):
|
|
44
|
+
```
|
|
45
|
+
GET https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode=<qrcode>
|
|
46
|
+
```
|
|
47
|
+
Statuses: `wait`, `scaned`, `confirmed`, `expired`.
|
|
48
|
+
6. On `confirmed`, save to `~/.claude/channels/wechat/account.json`:
|
|
49
|
+
```json
|
|
50
|
+
{"token": "<bot_token>", "baseUrl": "<baseurl>"}
|
|
51
|
+
```
|
|
52
|
+
`chmod 600` the file.
|
|
53
|
+
7. Confirm: *"Logged in! Restart Claude Code to start receiving messages."*
|
|
54
|
+
|
|
55
|
+
### `logout` — remove credentials
|
|
56
|
+
|
|
57
|
+
Delete `~/.claude/channels/wechat/account.json`. Confirm.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Implementation notes
|
|
62
|
+
|
|
63
|
+
- The server reads `account.json` at boot. Login changes need a restart.
|
|
64
|
+
- The QR code expires after a few minutes. If it times out, re-run login.
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"types": ["bun-types"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"resolveJsonModule": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["*.ts"]
|
|
14
|
+
}
|