@jaxonchenjc/claude-wechat-channel 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,7 @@
14
14
  "repo": "jaxxjj/claude-wechat-channel"
15
15
  },
16
16
  "description": "WeChat channel for Claude Code — login with QR code, receive and reply to WeChat messages",
17
- "version": "0.1.0",
17
+ "version": "1.0.0",
18
18
  "author": {
19
19
  "name": "jaxxjj"
20
20
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "wechat",
3
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",
4
+ "version": "1.0.0",
5
5
  "keywords": [
6
6
  "wechat",
7
7
  "weixin",
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # WeChat Channel for Claude Code
2
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.
3
+ Two-way messaging bridge between WeChat and Claude Code via the iLink Bot API.
4
4
 
5
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
6
 
@@ -9,67 +9,51 @@ Two-way messaging bridge between WeChat and Claude Code via the iLink Bot API. S
9
9
  - QR code login — scan with WeChat to connect
10
10
  - Text, image, voice, file, and video message support
11
11
  - Voice messages auto-transcribed (WeChat server-side STT)
12
- - Image/file/video download + AES-128-ECB decryption from CDN
12
+ - Image/file/video CDN download with AES-128-ECB decryption
13
13
  - File/image/video sending via CDN upload
14
- - Typing indicator ("对方正在输入...")
15
- - Long message auto-chunking (4000 char limit)
14
+ - Typing indicator
15
+ - Long message auto-chunking
16
+ - Poll cursor persistence (no message replay on restart)
16
17
 
17
18
  ## Prerequisites
18
19
 
19
- - [Claude Code](https://claude.com/claude-code) v2.1.80+, authenticated with claude.ai
20
+ - [Claude Code](https://claude.com/claude-code) v2.1.80+
20
21
  - [Bun](https://bun.sh) runtime
21
22
  - A WeChat account
22
23
 
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
- ```
24
+ ## Quick Start
32
25
 
33
- ### Option B: npm
26
+ ### 1. Install
34
27
 
35
28
  ```bash
36
- npx claude-wechat-channel install
29
+ claude --dangerously-skip-permissions --dangerously-load-development-channels plugin:wechat@claude-wechat-channel
37
30
  ```
38
31
 
39
- This auto-configures the MCP server in `~/.claude.json`.
32
+ Then inside Claude Code:
40
33
 
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
34
  ```
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
35
+ /plugin marketplace add jaxxjj/claude-wechat-channel
36
+ /plugin install wechat@claude-wechat-channel
37
+ /reload-plugins
59
38
  ```
60
39
 
61
- ### 2. Log in with QR code
40
+ ### 2. Login
62
41
 
63
- Inside Claude Code, run:
64
42
  ```
65
43
  /wechat:configure login
66
44
  ```
67
45
 
68
- Scan the QR code with WeChat on your phone. Credentials are saved to `~/.claude/channels/wechat/account.json`.
46
+ Opens a QR code link scan with WeChat. Credentials saved automatically.
47
+
48
+ ### 3. Restart
49
+
50
+ Exit Claude Code, then:
69
51
 
70
- ### 3. Restart and use
52
+ ```bash
53
+ claude --dangerously-skip-permissions --dangerously-load-development-channels plugin:wechat@claude-wechat-channel
54
+ ```
71
55
 
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.
56
+ Done. WeChat messages now arrive in your Claude Code session. Claude processes them and replies back through WeChat.
73
57
 
74
58
  ## How It Works
75
59
 
@@ -83,74 +67,66 @@ WeChat contact sends you a message
83
67
  → Reply appears in WeChat
84
68
  ```
85
69
 
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).
70
+ Access is gated by WeChat's QR code login — only contacts of the logged-in account can send messages.
87
71
 
88
72
  ## Supported Message Types
89
73
 
90
74
  | Type | Inbound | Outbound |
91
75
  |------|---------|----------|
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 |
76
+ | Text | Direct | Auto-chunked |
77
+ | Image | CDN download + AES decrypt | CDN upload + encrypt |
78
+ | Voice | Server-side speech-to-text | — |
79
+ | File (PDF, etc) | CDN download + AES decrypt | CDN upload + encrypt |
80
+ | Video | CDN download + AES decrypt | CDN upload + encrypt |
97
81
 
98
- ## Tools Available to Claude
82
+ ## Alternative Install Methods
99
83
 
100
- | Tool | Description |
101
- |------|-------------|
102
- | `reply` | Send text + optional file attachments back to WeChat |
103
- | `send_typing` | Show "typing..." indicator in WeChat |
84
+ ### npm
104
85
 
105
- ## Plugin Structure
86
+ ```bash
87
+ npx @jaxonchenjc/claude-wechat-channel install
88
+ claude --dangerously-skip-permissions --dangerously-load-development-channels server:wechat
89
+ ```
106
90
 
91
+ ### Manual clone
92
+
93
+ ```bash
94
+ git clone https://github.com/jaxxjj/claude-wechat-channel.git
95
+ cd claude-wechat-channel && bun install
107
96
  ```
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
97
+
98
+ Add to `~/.claude.json` under `mcpServers`:
99
+
100
+ ```json
101
+ {
102
+ "wechat": {
103
+ "command": "bun",
104
+ "args": ["run", "--cwd", "/path/to/claude-wechat-channel", "--shell=bun", "--silent", "start"],
105
+ "type": "stdio"
106
+ }
107
+ }
117
108
  ```
118
109
 
119
110
  ## State Files
120
111
 
121
112
  ```
122
113
  ~/.claude/channels/wechat/
123
- ├── account.json # Login credentials (token + base URL), chmod 600
124
- └── inbox/ # Downloaded media files (images, files, videos)
114
+ ├── account.json # Login credentials (chmod 600)
115
+ ├── poll_cursor # Long-poll cursor (prevents replay on restart)
116
+ └── inbox/ # Downloaded media files
125
117
  ```
126
118
 
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
119
  ## Limitations
144
120
 
145
121
  - **Session expiry**: WeChat sessions may expire; re-run `/wechat:configure login`
146
122
  - **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)
123
+ - **No message history**: Real-time only
124
+ - **Channels research preview**: Requires `--dangerously-load-development-channels` flag
149
125
 
150
126
  ## Credits
151
127
 
152
128
  - WeChat iLink Bot API by Tencent
153
- - Channel architecture inspired by [claude-plugins-official](https://github.com/anthropics/claude-plugins-official) (Telegram/Discord)
129
+ - Channel architecture inspired by [claude-plugins-official](https://github.com/anthropics/claude-plugins-official)
154
130
  - CDN encryption protocol referenced from [`@tencent-weixin/openclaw-weixin`](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin)
155
131
 
156
132
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaxonchenjc/claude-wechat-channel",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "description": "WeChat channel plugin for Claude Code — two-way messaging bridge via iLink Bot API",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,8 +29,7 @@
29
29
  "author": "jaxxjj",
30
30
  "homepage": "https://github.com/jaxxjj/claude-wechat-channel",
31
31
  "dependencies": {
32
- "@modelcontextprotocol/sdk": "^1.0.0",
33
- "qrcode-terminal": "^0.12.0"
32
+ "@modelcontextprotocol/sdk": "^1.0.0"
34
33
  },
35
34
  "devDependencies": {
36
35
  "@biomejs/biome": "^2.4.8",
package/server.ts CHANGED
@@ -32,6 +32,7 @@ const STATE_DIR = process.env.WECHAT_STATE_DIR ?? join(homedir(), '.claude', 'ch
32
32
  const ENV_FILE = join(STATE_DIR, '.env')
33
33
  const ACCOUNT_FILE = join(STATE_DIR, 'account.json')
34
34
  const INBOX_DIR = join(STATE_DIR, 'inbox')
35
+ const CURSOR_FILE = join(STATE_DIR, 'poll_cursor')
35
36
 
36
37
  // Load .env — plugin-spawned servers don't get an env block.
37
38
  try {
@@ -130,6 +131,7 @@ async function apiCall(baseUrl: string, endpoint: string, body: unknown, token:
130
131
 
131
132
  // ---------------------------------------------------------------------------
132
133
  // Context token management — WeChat requires echoing context_token in replies
134
+ // In-memory cache as fallback; primary source is the tool parameter from Claude.
133
135
  // ---------------------------------------------------------------------------
134
136
 
135
137
  const contextTokens = new Map<string, string>()
@@ -142,6 +144,12 @@ function getContextToken(userId: string): string | undefined {
142
144
  return contextTokens.get(userId)
143
145
  }
144
146
 
147
+ function resolveContextToken(explicit: string | undefined, userId: string): string {
148
+ const token = explicit || getContextToken(userId)
149
+ if (!token) throw new Error(`No context_token for ${userId} — the user must message first`)
150
+ return token
151
+ }
152
+
145
153
  // ---------------------------------------------------------------------------
146
154
  // Typing indicator
147
155
  // ---------------------------------------------------------------------------
@@ -170,11 +178,8 @@ async function sendTyping(account: WechatAccount, userId: string, status: 1 | 2
170
178
  // Send message
171
179
  // ---------------------------------------------------------------------------
172
180
 
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
- }
181
+ async function sendMessage(account: WechatAccount, toUserId: string, text: string, explicitContextToken?: string): Promise<{ messageId: string }> {
182
+ const contextToken = resolveContextToken(explicitContextToken, toUserId)
178
183
 
179
184
  const clientId = `claude-wechat-${randomBytes(8).toString('hex')}`
180
185
  const res = await apiCall(account.baseUrl, 'ilink/bot/sendmessage', {
@@ -187,7 +192,7 @@ async function sendMessage(account: WechatAccount, toUserId: string, text: strin
187
192
  message_state: 2, // FINISH
188
193
  item_list: [{ type: 1, text_item: { text } }],
189
194
  },
190
- base_info: { channel_version: '0.0.1' },
195
+ base_info: { channel_version: '1.0.0' },
191
196
  }, account.token) as Record<string, unknown>
192
197
 
193
198
  return { messageId: String(res.message_id ?? clientId) }
@@ -258,12 +263,9 @@ async function sendFileMessage(
258
263
  account: WechatAccount,
259
264
  toUserId: string,
260
265
  filePath: string,
261
- caption?: string,
266
+ explicitContextToken?: string,
262
267
  ): 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
- }
268
+ const contextToken = resolveContextToken(explicitContextToken, toUserId)
267
269
 
268
270
  const plaintext = readFileSync(filePath)
269
271
  const rawsize = plaintext.length
@@ -306,9 +308,7 @@ async function sendFileMessage(
306
308
  mediaItem = { type: 4, file_item: { media: cdnMedia, file_name: fileName, len: String(rawsize) } }
307
309
  }
308
310
 
309
- const items: Record<string, unknown>[] = []
310
- if (caption) items.push({ type: 1, text_item: { text: caption } })
311
- items.push(mediaItem)
311
+ const items: Record<string, unknown>[] = [mediaItem]
312
312
 
313
313
  let lastMessageId = '0'
314
314
  for (const item of items) {
@@ -319,7 +319,7 @@ async function sendFileMessage(
319
319
  context_token: contextToken, message_type: 2, message_state: 2,
320
320
  item_list: [item],
321
321
  },
322
- base_info: { channel_version: '0.0.1' },
322
+ base_info: { channel_version: '1.0.0' },
323
323
  }, account.token) as Record<string, unknown>
324
324
  lastMessageId = String(res.message_id ?? res.msg_id ?? '0')
325
325
  }
@@ -402,9 +402,9 @@ const mcp = new Server(
402
402
  instructions: [
403
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
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.',
405
+ 'Messages from WeChat arrive as <channel source="wechat" chat_id="..." context_token="..." message_id="..." user="..." ts="...">. If the tag has image_path or attachment_path, use the Read tool directly on that file path — it handles images and PDFs natively, no external tools needed. Reply with the reply tool — pass chat_id and context_token back. The context_token is REQUIRED for delivery.',
406
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.',
407
+ 'reply accepts file paths (files: ["/abs/path.png"]) for attachments. Images send as photos; videos as video messages; other types as file attachments.',
408
408
  '',
409
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
410
  ].join('\n'),
@@ -416,30 +416,20 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
416
416
  {
417
417
  name: 'reply',
418
418
  description:
419
- 'Reply on WeChat. Pass chat_id from the inbound message. Optionally pass files (absolute paths) to attach images, videos, or documents.',
419
+ 'Reply on WeChat. Pass chat_id and context_token from the inbound message. context_token is required for delivery.',
420
420
  inputSchema: {
421
421
  type: 'object',
422
422
  properties: {
423
423
  chat_id: { type: 'string', description: 'WeChat user ID from the inbound <channel> tag' },
424
424
  text: { type: 'string', description: 'Message text to send' },
425
+ context_token: { type: 'string', description: 'context_token from the inbound <channel> tag. Required for delivery.' },
425
426
  files: {
426
427
  type: 'array',
427
428
  items: { type: 'string' },
428
429
  description: 'Absolute file paths to attach. Images send as photos; videos as video messages; others as file attachments. Max 50MB each.',
429
430
  },
430
431
  },
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'],
432
+ required: ['chat_id', 'text', 'context_token'],
443
433
  },
444
434
  },
445
435
  ],
@@ -460,6 +450,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
460
450
  case 'reply': {
461
451
  const chat_id = args.chat_id as string
462
452
  const text = args.text as string
453
+ const ct = args.context_token as string | undefined
463
454
  const files = (args.files as string[] | undefined) ?? []
464
455
 
465
456
  for (const f of files) {
@@ -477,7 +468,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
477
468
 
478
469
  try {
479
470
  for (const c of chunks) {
480
- const result = await sendMessage(account, chat_id, c)
471
+ const result = await sendMessage(account, chat_id, c, ct)
481
472
  sentIds.push(result.messageId)
482
473
  }
483
474
  } catch (err) {
@@ -486,7 +477,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
486
477
  }
487
478
 
488
479
  for (const f of files) {
489
- const result = await sendFileMessage(account, chat_id, f)
480
+ const result = await sendFileMessage(account, chat_id, f, ct)
490
481
  sentIds.push(result.messageId)
491
482
  }
492
483
 
@@ -495,10 +486,6 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
495
486
  : `sent ${sentIds.length} parts (ids: ${sentIds.join(', ')})`
496
487
  return { content: [{ type: 'text', text: result }] }
497
488
  }
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
489
  default:
503
490
  return {
504
491
  content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }],
@@ -548,12 +535,17 @@ void (async () => {
548
535
 
549
536
  process.stderr.write(`wechat channel: starting message polling\n`)
550
537
 
551
- let syncBuf = ''
538
+ // Load persisted poll cursor — prevents message replay on restart
539
+ let cursor = ''
540
+ try {
541
+ cursor = readFileSync(CURSOR_FILE, 'utf8').trim()
542
+ process.stderr.write(`wechat channel: resumed poll cursor (${cursor.length} bytes)\n`)
543
+ } catch {}
552
544
 
553
545
  for (let attempt = 1; !shuttingDown; ) {
554
546
  try {
555
547
  const res = await apiCall(account.baseUrl, 'ilink/bot/getupdates', {
556
- get_updates_buf: syncBuf,
548
+ get_updates_buf: cursor,
557
549
  }, account.token, 40000) as Record<string, unknown>
558
550
 
559
551
  const errcode = res.errcode as number | undefined
@@ -568,7 +560,13 @@ void (async () => {
568
560
  continue
569
561
  }
570
562
 
571
- if (res.get_updates_buf) syncBuf = res.get_updates_buf as string
563
+ if (res.get_updates_buf) {
564
+ cursor = res.get_updates_buf as string
565
+ try {
566
+ mkdirSync(STATE_DIR, { recursive: true })
567
+ writeFileSync(CURSOR_FILE, cursor)
568
+ } catch {}
569
+ }
572
570
 
573
571
  const msgs = (res.msgs ?? []) as WechatMessage[]
574
572
  attempt = 1
@@ -618,6 +616,7 @@ void (async () => {
618
616
  user_id: senderId,
619
617
  ts,
620
618
  }
619
+ if (msg.context_token) meta.context_token = msg.context_token
621
620
  if (msg.message_id != null) meta.message_id = String(msg.message_id)
622
621
  if (imagePath) meta.image_path = imagePath
623
622
  if (attachmentPath) meta.attachment_path = attachmentPath
@@ -1,64 +1,83 @@
1
1
  ---
2
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.
3
+ description: Set up the WeChat channel — scan QR code to log in. Use when the user asks to configure WeChat, login, check status, or reconnect.
4
4
  user-invocable: true
5
5
  allowed-tools:
6
6
  - Read
7
7
  - Write
8
- - Bash(ls *)
9
- - Bash(mkdir *)
10
- - Bash(chmod *)
11
8
  - Bash(curl *)
9
+ - Bash(mkdir *)
12
10
  ---
13
11
 
14
12
  # /wechat:configure — WeChat Channel Setup
15
13
 
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
14
  Arguments passed: `$ARGUMENTS`
21
15
 
22
16
  ---
23
17
 
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.
18
+ ## No args — status
58
19
 
59
- ---
20
+ 1. Read `~/.claude/channels/wechat/account.json`. Show logged-in or not.
21
+ 2. If logged in: *"Ready. Restart Claude Code to start receiving messages."*
22
+ 3. If not: *"Run `/wechat:configure login` to connect."*
23
+
24
+ ## `login`
25
+
26
+ ### Step 1: Get QR code
27
+
28
+ ```bash
29
+ curl -s 'https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3'
30
+ ```
31
+
32
+ Response: `{"qrcode":"<token>","qrcode_img_content":"<url>","ret":0}`
33
+
34
+ Generate a QR image URL:
35
+ `https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=<url-encoded qrcode_img_content>`
36
+
37
+ Print ONLY the QR image URL. Tell the user: *"Open this link in a browser, then scan the QR code with WeChat."*
38
+
39
+ Then immediately start polling (don't wait for user to say they scanned).
40
+
41
+ IMPORTANT: In zsh, `status` is a read-only variable. Use `scan_status` or
42
+ another name when parsing the response.
43
+
44
+ ```bash
45
+ for i in $(seq 1 60); do
46
+ result=$(curl -s "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode=<qrcode>")
47
+ scan_status=$(echo "$result" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
48
+ if [ "$scan_status" = "confirmed" ]; then echo "$result"; break; fi
49
+ if [ "$scan_status" = "expired" ]; then echo "expired"; break; fi
50
+ sleep 3
51
+ done
52
+ ```
53
+
54
+ Parse the result:
55
+ - `"status":"confirmed"` — success! Extract `bot_token`, `baseurl`
56
+ - `"status":"expired"` — tell user to run login again
57
+
58
+ ### Step 3: Save credentials
59
+
60
+ ```bash
61
+ mkdir -p ~/.claude/channels/wechat
62
+ ```
63
+
64
+ Write `~/.claude/channels/wechat/account.json`:
65
+ ```json
66
+ {
67
+ "token": "<bot_token>",
68
+ "baseUrl": "<baseurl>"
69
+ }
70
+ ```
71
+
72
+ Then `chmod 600 ~/.claude/channels/wechat/account.json`.
73
+
74
+ Tell user:
75
+ ```
76
+ ✅ Logged in! Restart Claude Code with:
77
+
78
+ claude --dangerously-skip-permissions --dangerously-load-development-channels plugin:wechat@claude-wechat-channel
79
+ ```
60
80
 
61
- ## Implementation notes
81
+ ## `logout`
62
82
 
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.
83
+ Delete `~/.claude/channels/wechat/account.json`.