@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.
@@ -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
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "wechat": {
4
+ "command": "bun",
5
+ "args": ["run", "--cwd", "${CLAUDE_PLUGIN_ROOT}", "--shell=bun", "--silent", "start"]
6
+ }
7
+ }
8
+ }
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
+ }