| name | add-weixin |
| description | Add WeChat (微信) as a channel. Uses ilink bot long-poll API for message receiving and sending. Supports text, image, video, file, and voice messages. No public URL or webhook needed. Works alongside any other channel. Language-agnostic — auto-generates code for the target project's language and framework. |
Add WeChat (微信) Channel
This skill adds WeChat messaging support to any bot/agent/service project. It is language-agnostic — Claude Code will analyze the target project's language, framework, and channel patterns, then generate appropriate implementation code.
The underlying protocol is the Weixin ilink bot HTTP API (long-poll). No public URL, webhook, or ngrok is needed.
Phase 1: Detect Project & Plan
IMPORTANT: Do NOT ask the user any questions during this phase. Detect everything automatically by reading project files. The user expects this to just work.
1.1 Analyze the target project (silently)
Read the project files to determine:
- Language — check which files exist in the project root:
package.json → Node.js / TypeScript
pyproject.toml / requirements.txt / setup.py → Python
go.mod → Go
Cargo.toml → Rust
pom.xml / build.gradle → Java / Kotlin
- If none found, look at file extensions in
src/ or the root directory
- Framework — read config files and source code to identify:
- NanoClaw: check for
src/channels/registry.ts or .nanoclaw/
- OpenClaw: check for
openclaw.json or openclaw in dependencies
- Others: look at imports, directory structure, existing integrations
- Existing channel pattern — find any existing channel implementation (Telegram, Slack, Feishu, etc.) and study how it works:
- How channels register themselves
- The interface/abstract class channels implement
- How messages are dispatched and routed
- How credentials are loaded
- Service manager — check for
LaunchAgents/*.plist (launchd), systemd unit files, docker-compose.yml, PM2 config, etc.
1.2 Plan the implementation (silently)
Decide without asking the user:
- Which files to create (channel implementation, auth script)
- Which files to modify (channel registry, dependencies)
- How credentials are stored (follow the project's existing pattern)
- How to build and restart
Then proceed directly to Phase 2.
NanoClaw fast path: If .nanoclaw/ exists or src/channels/registry.ts exists, use the skills engine:
npx tsx scripts/apply-skill.ts .claude/skills/add-weixin
This copies the TypeScript reference implementation from add/ and handles merges automatically. Then skip to Phase 3.
All other projects with existing channels: Generate code from scratch using the API specification below. Match the project's language, style, and patterns.
Projects with existing web chat / HTTP API but no messaging channels: This is the most common case (e.g. a web chatbot with HTTP endpoints). Generate a WeChat bridge module that:
- Runs the long-poll loop to receive WeChat messages
- Forwards user messages to the project's existing chat API / handler function / endpoint
- Takes the response and sends it back via WeChat
sendMessage
- Detects the project's chat handler by looking for: route handlers (Flask/FastAPI/Express), message handler functions, chat completion calls, or any function that takes user input and returns a response
- Wires itself in WITHOUT modifying the existing chat logic — acts as a new input/output channel alongside the web interface
Projects with NO existing channel/integration AND no chat logic: Generate a standalone WeChat bot module that runs independently. It should include:
- A main entry point that starts the long-poll loop
- A callback/handler function where the user fills in their business logic (e.g.
on_message(from_user, text) -> reply_text)
- QR login script as a separate entry point
- Credential storage in
.env or a config file
- A clear comment like
# TODO: Add your reply logic here at the callback point
- Print a working echo-bot example by default (reply with what the user sent) so the user can verify it works immediately
The standalone bot should be runnable with a single command (e.g. python weixin_bot.py or node weixin-bot.js or go run weixin/main.go).
Empty directory / no language detected: Default to Python. Generate a complete standalone WeChat bot in Python using only the standard library + pycryptodome for AES. Include a requirements.txt and README with run instructions.
Phase 2: Implement
Generate a complete WeChat channel implementation in the target project's language. The implementation MUST support:
- QR code login — fetch QR → user scans → poll status → save token
- Long-poll message receiving —
getUpdates loop with sync buffer persistence
- Text message sending —
sendMessage with context_token
- Media receiving — CDN download + AES-128-ECB decryption for image/video/file/voice (save to local temp dir, pass file path to handler)
- Media sending — via a
send_file(user_id, file_path) function: AES-128-ECB encrypt + CDN upload + sendMessage with media item. Route by MIME type: image/* → IMAGE, video/* → VIDEO, others → FILE
- Typing indicator —
getConfig for ticket, sendTyping to show/cancel
- Token expiry detection — errcode -14, print re-login instructions to terminal
- Credentials persistence — save token to
.env on login, read from .env on startup
Voice messages
Inbound voice messages may include a voice_item.text field (server-side transcription). If present, use the text directly instead of downloading the audio file. If not present, download the raw file — it is in SILK codec format (WeChat proprietary). Processing SILK requires an external decoder like silk-wasm (Node.js) or pilk (Python). For most use cases, preferring the text transcription is sufficient.
Code generation guidelines
- If the project has existing channels/integrations, follow its patterns (naming, error handling, logging, config) and implement the same Channel/Interface
- If the project has an existing chat handler (web API, HTTP endpoint, function), wire WeChat as a bridge to that handler — do NOT rewrite the chat logic
- If the project has NO existing channels and no chat logic, generate a self-contained module with a clear callback entry point
- Use the project's existing HTTP client (requests, httpx, reqwest, net/http, fetch, etc.) if available; otherwise use the language's standard library
- Use the project's existing crypto library for AES-128-ECB. Note: Go's
crypto/aes does not have a built-in ECB mode — you must manually iterate block-by-block with cipher.Block.Encrypt
- For Python projects, use
pip and requirements.txt by default; use poetry only if pyproject.toml already exists
- For QR code display in terminal: use
qrcode-terminal (Node.js), qrcode (Python), or fall back to printing the URL if no QR library is available
- Register the channel the same way other channels register
- Add the credential environment variables to the project's config system
Phase 3: QR Code Login
WeChat authentication requires QR code scanning. Guide the user through:
- Run the auth script/command
- A QR code URL is printed — user opens in browser or scans terminal QR
- User scans with WeChat and confirms
- Credentials are auto-saved
After login, verify credentials are saved (token, baseUrl, accountId).
Phase 4: Build & Verify
- Build the project (compile, lint, type-check)
- Restart the service using the project's service manager
- Verify startup logs show "Weixin bot connected"
- Have the user send a test message via WeChat
- Verify the bot responds
- Test media: send an image, file, and voice message
Weixin ilink Bot API — Complete Specification
All APIs use the base URL https://ilinkai.weixin.qq.com. All POST endpoints accept JSON. All requests must include these headers:
Content-Type: application/json
AuthorizationType: ilink_bot_token
Authorization: Bearer <token>
X-WECHAT-UIN: <base64 of random uint32 decimal string>
The X-WECHAT-UIN header: generate a random 4-byte unsigned integer, convert to decimal string, then base64-encode that string.
Constants
MessageItemType: TEXT=1, IMAGE=2, VOICE=3, FILE=4, VIDEO=5
MessageType: USER=1, BOT=2
MessageState: NEW=0, GENERATING=1, FINISH=2
UploadMediaType: IMAGE=1, VIDEO=2, FILE=3, VOICE=4
TypingStatus: TYPING=1, CANCEL=2
CDN_BASE_URL: https://novac2c.cdn.weixin.qq.com/c2c
API 1: getUpdates (Long-poll receive)
POST /ilink/bot/getupdates
Request:
{
"get_updates_buf": "<string: base64 sync buffer, empty string for first request>"
}
Response:
{
"ret": 0,
"errcode": 0,
"errmsg": "",
"msgs": [<WeixinMessage>, ...],
"get_updates_buf": "<string: save this, send in next request>",
"longpolling_timeout_ms": 35000
}
WeixinMessage:
{
"seq": 1,
"message_id": 12345,
"from_user_id": "xxx@im.wechat",
"to_user_id": "xxx@im.bot",
"client_id": "unique-id",
"create_time_ms": 1700000000000,
"session_id": "...",
"message_type": 1,
"message_state": 2,
"context_token": "<MUST echo this in all replies to this user>",
"item_list": [<MessageItem>, ...]
}
MessageItem variants:
Text:
{ "type": 1, "text_item": { "text": "hello" } }
Image:
{
"type": 2,
"image_item": {
"media": { "encrypt_query_param": "<CDN download param>", "aes_key": "<base64>" },
"aeskey": "<hex string, preferred over media.aes_key for images>"
}
}
Voice:
{
"type": 3,
"voice_item": {
"media": { "encrypt_query_param": "...", "aes_key": "<base64>" },
"text": "<voice-to-text transcription, if available>",
"playtime": 3000
}
}
File:
{
"type": 4,
"file_item": {
"media": { "encrypt_query_param": "...", "aes_key": "<base64>" },
"file_name": "document.pdf",
"len": "12345"
}
}
Video:
{
"type": 5,
"video_item": {
"media": { "encrypt_query_param": "...", "aes_key": "<base64>" }
}
}
Quoted message (ref_msg):
{
"type": 1,
"text_item": { "text": "reply text" },
"ref_msg": {
"title": "summary of quoted message",
"message_item": { "type": 1, "text_item": { "text": "original text" } }
}
}
Error handling:
ret or errcode != 0 → API error
errcode == -14 or ret == -14 → session expired, token invalid, must re-login
- On client-side timeout (AbortError) → return empty response, retry immediately (normal for long-poll)
- Retry with exponential backoff: 2s base, 30s max after 3 consecutive failures
Sync buffer: The get_updates_buf from each response MUST be persisted to disk and sent in the next request. This is the cursor — without it, the server replays old messages.
API 2: sendMessage
POST /ilink/bot/sendmessage
Request:
{
"msg": {
"from_user_id": "",
"to_user_id": "user@im.wechat",
"client_id": "<unique string per message>",
"message_type": 2,
"message_state": 2,
"context_token": "<from the inbound message>",
"item_list": [<MessageItem>]
}
}
CRITICAL: context_token is mandatory for replies. Without it, the message won't be associated with the conversation. Cache the most recent context_token per user from inbound messages.
Send text:
{ "item_list": [{ "type": 1, "text_item": { "text": "Hello!" } }] }
Send image (after CDN upload):
{
"item_list": [{
"type": 2,
"image_item": {
"media": {
"encrypt_query_param": "<from CDN upload x-encrypted-param header>",
"aes_key": "<base64 of hex ASCII bytes — see encoding note>",
"encrypt_type": 1
},
"mid_size": 12345
}
}]
}
Send video:
{
"item_list": [{
"type": 5,
"video_item": {
"media": { "encrypt_query_param": "...", "aes_key": "...", "encrypt_type": 1 },
"video_size": 12345
}
}]
}
Send file:
{
"item_list": [{
"type": 4,
"file_item": {
"media": { "encrypt_query_param": "...", "aes_key": "...", "encrypt_type": 1 },
"file_name": "report.pdf",
"len": "12345"
}
}]
}
When sending media with caption: Send as two separate requests — first the text item, then the media item. Each request should have exactly one item in item_list.
CRITICAL: aes_key encoding for outbound media
The aes_key in outbound sendMessage must be encoded as:
base64( ascii_bytes_of( hex_key_string ) )
NOT:
base64( binary_decode( hex_key_string ) )
Example in different languages:
aes_key = Buffer.from(hexKeyString).toString('base64')
aes_key = Buffer.from(hexKeyString, 'hex').toString('base64')
import base64
aes_key = base64.b64encode(hex_key_string.encode('ascii')).decode()
aes_key = base64.b64encode(bytes.fromhex(hex_key_string)).decode()
aesKey = base64.StdEncoding.EncodeToString([]byte(hexKeyString))
keyBytes, _ := hex.DecodeString(hexKeyString)
aesKey = base64.StdEncoding.EncodeToString(keyBytes)
Getting this wrong causes images/files to display as a gray block on the receiving end.
API 3: sendTyping
POST /ilink/bot/sendtyping
Request:
{
"ilink_user_id": "user@im.wechat",
"typing_ticket": "<from getConfig>",
"status": 1
}
Status: 1 = start typing, 2 = cancel typing.
API 4: getConfig
POST /ilink/bot/getconfig
Request:
{
"ilink_user_id": "user@im.wechat",
"context_token": "<optional>"
}
Response:
{
"ret": 0,
"typing_ticket": "<base64, use in sendTyping>"
}
Cache typing_ticket per user. Fetch once on first message from a user.
API 5: getUploadUrl (CDN upload pre-sign)
POST /ilink/bot/getuploadurl
Request:
{
"filekey": "<random 32-char hex string>",
"media_type": 1,
"to_user_id": "user@im.wechat",
"rawsize": 12345,
"rawfilemd5": "<md5 hex of plaintext file>",
"filesize": 12352,
"no_need_thumb": true,
"aeskey": "<random 32-char hex string>"
}
media_type: 1=IMAGE, 2=VIDEO, 3=FILE, 4=VOICE
filesize: AES-128-ECB padded size = ceil((rawsize + 1) / 16) * 16
aeskey: random 16 bytes as hex string (32 chars)
Response:
{
"upload_param": "<signed upload token>"
}
API 6: CDN Upload
POST {CDN_BASE_URL}/upload?encrypted_query_param={upload_param}&filekey={filekey}
Content-Type: application/octet-stream
Body: <AES-128-ECB encrypted file bytes>
- Encrypt plaintext with AES-128-ECB using the random
aeskey (16 bytes, from hex) and PKCS7 padding
- Response header
x-encrypted-param → use as encrypt_query_param in sendMessage
- Retry up to 3 times on server errors; abort on 4xx client errors
API 7: CDN Download (inbound media decryption)
GET {CDN_BASE_URL}/download?encrypted_query_param={encrypt_query_param}
Returns AES-128-ECB encrypted bytes. To decrypt:
- Parse the
aes_key from the message:
- For images: prefer
image_item.aeskey (hex string) → decode hex to 16 bytes
- For all others: use
media.aes_key (base64) → decode:
- If decoded length == 16: use directly as key
- If decoded length == 32 and all hex chars: parse hex → 16 byte key
- AES-128-ECB decrypt with PKCS7 padding removal
API 8: QR Code Login
Step 1 — Get QR code
GET /ilink/bot/get_bot_qrcode?bot_type=3
Response:
{
"qrcode": "<session identifier>",
"qrcode_img_content": "<URL — render as QR code or open in browser>"
}
Step 2 — Poll status
GET /ilink/bot/get_qrcode_status?qrcode={session}
Header: iLink-App-ClientVersion: 1
Response:
{
"status": "wait|scaned|confirmed|expired",
"bot_token": "<on confirmed>",
"ilink_bot_id": "<on confirmed — this is the accountId>",
"baseurl": "<on confirmed — API base URL>",
"ilink_user_id": "<on confirmed — user who scanned>"
}
- Poll every 1 second
- Client-side timeout per request: 35 seconds (long-poll)
- Overall timeout: ~8 minutes
- On
expired: optionally refresh QR (re-call step 1), up to 3 times
- On
confirmed: save bot_token, ilink_bot_id, baseurl as credentials
AES-128-ECB Reference
All media encryption uses AES-128-ECB with PKCS7 padding.
Encrypt (for upload):
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
cipher = AES.new(key_16_bytes, AES.MODE_ECB)
ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))
const cipher = crypto.createCipheriv('aes-128-ecb', key16bytes, null);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
block, _ := aes.NewCipher(key16bytes)
padded := pkcs7Pad(plaintext, aes.BlockSize)
dst := make([]byte, len(padded))
for i := 0; i < len(padded); i += aes.BlockSize {
block.Encrypt(dst[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])
}
Decrypt (for download):
Same but in reverse — decrypt then remove PKCS7 padding.
Known Limitations
- Only P2P (direct messages) — WeChat bot API does not support group chats
- No message deletion/recall callback
- No "bot removed" event
context_token is required for sending — must receive at least one message from a user first
- Token can expire — user must re-scan QR to refresh
Troubleshooting
| Symptom | Cause | Fix |
|---|
| Bot not responding | Token expired | Re-run login, check for errcode -14 in logs |
| Image shows gray block | Wrong aes_key encoding | Use base64(ascii_hex) not base64(binary_hex) |
| File not delivered | Missing context_token | Ensure inbound message was received first |
| getUpdates keeps returning old messages | Sync buffer not persisted | Save get_updates_buf to disk after each response |
| Login QR expired | Took too long | Re-run login within 5 minutes |