feat(add-wechat): personal WeChat channel via Tencent iLink Bot API

New channel skill for personal WeChat, using Tencent's official iLink
Bot API (the same protocol @tencent-weixin/openclaw-weixin uses).
Region-restricted to mainland 微信 accounts — international WeChat
clients can't complete the QR flow.

Skill contents:
- Install steps copy the adapter from the `channels` branch (same
  pattern as other /add-<channel> skills) and register it in
  src/channels/index.ts.
- Post-login wiring helper at scripts/wire-dm.ts — lists unwired
  WeChat messaging groups, prompts for an agent group, and inserts the
  messaging_group_agents row with sender policy `request_approval` by
  default (matches the router auto-create default so the admin gets an
  approval card on the next unknown-sender DM).
- Channel Info documents how /new-setup Claude captures the
  operator's user_id (from data/wechat/auth.json.operatorUserId) and
  the first DM's platform_id (from the adapter's "WeChat inbound" log).

Also adds WeChat as option 15 in /new-setup's channel list so setup
wires into the existing /add-<channel> flow automatically.

Addresses https://github.com/qwibitai/nanoclaw/issues/1901.

Co-Authored-By: ythx-101 <226337373+ythx-101@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gabi Simons
2026-04-21 17:21:50 +00:00
parent 010722803f
commit 52a9ab5179
4 changed files with 392 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
# Remove WeChat Channel
Undo `/add-wechat`.
### 1. Remove credentials
Delete WeChat lines from `.env`:
```bash
sed -i.bak '/^WECHAT_ENABLED=/d' .env && rm -f .env.bak
cp .env data/env/env
```
### 2. Remove adapter and import
```bash
rm -f src/channels/wechat.ts
sed -i.bak "/import '\.\/wechat\.js';/d" src/channels/index.ts && rm -f src/channels/index.ts.bak
```
### 3. Uninstall the package
```bash
pnpm remove wechat-ilink-client
```
### 4. Remove saved auth + sync state
```bash
rm -rf data/wechat
```
### 5. Remove DB wiring
```sql
-- Remove any sessions first (foreign key)
DELETE FROM sessions WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
DELETE FROM messaging_groups WHERE channel_type = 'wechat';
```
### 6. Rebuild and restart
```bash
pnpm run build
systemctl --user restart nanoclaw # Linux
# or
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```

View File

@@ -0,0 +1,170 @@
---
name: add-wechat
description: Add WeChat (personal) channel integration via Tencent's official iLink Bot API. Uses long-polling and QR scan — no webhook, no ToS risk, no paid token.
---
# Add WeChat Channel
Adds WeChat support via **iLink Bot API** — the first-party Tencent API for personal WeChat bots (different from WeCom / Official Account).
**Why this is different from wechaty/PadLocal:**
- Official Tencent API — no ToS violation, no ban risk
- Free — no PadLocal token required
- No public webhook URL needed — uses long-poll
- Works with any personal WeChat account
## Prerequisites
- A **personal WeChat account** with the mobile app installed
- A phone to scan the QR code for login
- Node.js >= 20 (already required by NanoClaw)
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the WeChat adapter in from the `channels` branch.
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `src/channels/wechat.ts` exists
- `src/channels/index.ts` contains `import './wechat.js';`
- `wechat-ilink-client` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter
```bash
git show origin/channels:src/channels/wechat.ts > src/channels/wechat.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './wechat.js';
```
### 4. Install the library (pinned)
```bash
pnpm install wechat-ilink-client@0.1.0
```
### 5. Build
```bash
pnpm run build
```
## Credentials
Unlike most channels, WeChat requires **no pre-configured API keys**. Auth happens via QR code scan from your phone.
### 1. Enable the channel
Add to `.env`:
```bash
WECHAT_ENABLED=true
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### 2. Start the service and scan the QR
Restart NanoClaw:
```bash
systemctl --user restart nanoclaw # Linux
# or
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```
The adapter will print a **QR URL** to the logs and save it to `data/wechat/qr.txt`:
```bash
tail -f logs/nanoclaw.log | grep WeChat
# or
cat data/wechat/qr.txt
```
Open the URL in a browser (it renders a QR code), then:
1. Open WeChat on your phone
2. Use its built-in QR scanner (top-right "+" → Scan)
3. Approve the authorization on your phone
4. Auth credentials are saved to `data/wechat/auth.json` — do not commit this file
The bot is now connected as your WeChat account.
## Wire your first DM
A successful QR login alone isn't enough — the adapter still needs to be wired to an agent group before it can respond.
### 1. Trigger the first inbound message
Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id`.
### 2. Run the wire script
```bash
pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts
```
Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and creates the `messaging_group_agents` row with sensible defaults (sender policy `request_approval`, session mode `shared`).
With `request_approval`, the next DM from the stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent.
Non-interactive:
```bash
pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts \
--platform-id wechat:wxid_xxxxx \
--agent-group ag-xxxxx \
--non-interactive
```
Flags:
- `--platform-id <id>` — wire a specific messaging group (default: most recent unwired)
- `--agent-group <id>` — target agent group (default: prompt; or solo admin group in non-interactive)
- `--sender-policy public|strict|request_approval` — default `request_approval` (fires an admin approval card on unknown-sender DMs)
- `--session-mode shared|per-thread` — default `shared`
### 3. Test
Have the sender message the bot again — the agent should respond.
## Operational notes
- **Only one instance can use a given token at a time.** Don't run multiple NanoClaw instances pointing to the same `data/wechat/auth.json`.
- **Re-login on session expiry:** if you see `WeChat: session expired` in logs, delete `data/wechat/auth.json` and restart — you'll be asked to re-scan.
- **Sync cursor persistence:** `data/wechat/sync-buf.txt` holds the long-poll cursor. Deleting it replays recent history on next start; don't delete it in normal operation.
- **Account safety:** this uses the official Tencent API, so account bans for bot automation aren't a risk. That said, don't spam — normal rate limits still apply.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, restart the service to pick up the new channel and wiring.
## Channel Info
- **type**: `wechat`
- **terminology**: WeChat has "contacts" (DMs) and "group chats" (rooms). Each DM or group is a separate messaging group.
- **how-to-find-id**: Send a message to the bot from the target account; the adapter auto-creates a messaging group and logs `WeChat inbound platformId=wechat:<id>`. Use `wechat:<user_id>` for DMs, `wechat:<group_id>` for rooms.
- **admin-user-id**: The operator's WeChat user_id (for `init-first-agent.ts --admin-user-id`) is saved to `data/wechat/auth.json` as `operatorUserId` after the QR scan. Read it with `cat data/wechat/auth.json | jq -r .operatorUserId` and prefix with `wechat:` (i.e. `wechat:<operatorUserId>`).
- **supports-threads**: no (WeChat has no reply threads)
- **typical-use**: Long-poll — the adapter holds a persistent connection to Tencent's iLink API and receives messages in real time. No webhook URL needed.
- **default-isolation**: `shared` session mode per messaging group (DM or room). Use `strict` sender policy if you want only specific users to reach the agent; `public` opens it to anyone who messages the bot.
- **post-install-wiring**: Use the `wire-dm.ts` helper (see the "Wire your first DM" section above) if running this skill standalone. If running inside `/new-setup`, `init-first-agent.ts` handles wiring — just pass the `platform-id` and `admin-user-id` captured above.

View File

@@ -0,0 +1,172 @@
#!/usr/bin/env pnpm exec tsx
/**
* Wire a WeChat DM (or group) to an agent group.
*
* After /add-wechat installs the adapter and the user scans the QR login,
* the first inbound message from another WeChat account auto-creates a
* `messaging_groups` row. This script finds that row, asks the operator
* which agent group to wire it to, and inserts the `messaging_group_agents`
* join row with sensible defaults — the "post-login wiring" step /add-wechat
* otherwise requires manual SQL for.
*
* Usage:
* pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts
*
* Flags:
* --platform-id <id> Wire a specific messaging group (default: most recent unwired)
* --agent-group <id> Target agent group (default: interactive pick; or solo admin group)
* --sender-policy <p> public | strict (default: public)
* --session-mode <m> shared | per-thread (default: shared)
* --non-interactive Fail instead of prompting
*/
import Database from 'better-sqlite3';
import path from 'node:path';
import readline from 'node:readline';
const DB_PATH = process.env.NANOCLAW_DB_PATH ?? path.join(process.cwd(), 'data', 'v2.db');
type SenderPolicy = 'public' | 'strict' | 'request_approval';
interface Args {
platformId?: string;
agentGroupId?: string;
senderPolicy: SenderPolicy;
sessionMode: 'shared' | 'per-thread';
interactive: boolean;
}
function parseArgs(argv: string[]): Args {
const args: Args = {
// Default matches the router's auto-create (`request_approval`) so the
// admin gets an approval card on the next unknown-sender DM rather than
// a silent allow. Pass `--sender-policy public` to open the channel to
// anyone, or `strict` to require explicit membership.
senderPolicy: 'request_approval',
sessionMode: 'shared',
interactive: true,
};
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
const val = argv[i + 1];
switch (flag) {
case '--platform-id': args.platformId = val; i++; break;
case '--agent-group': args.agentGroupId = val; i++; break;
case '--sender-policy':
if (val !== 'public' && val !== 'strict' && val !== 'request_approval') {
throw new Error(`bad --sender-policy: ${val} (use public | strict | request_approval)`);
}
args.senderPolicy = val; i++; break;
case '--session-mode':
if (val !== 'shared' && val !== 'per-thread') throw new Error(`bad --session-mode: ${val}`);
args.sessionMode = val; i++; break;
case '--non-interactive': args.interactive = false; break;
case '--help': case '-h':
console.log('See .claude/skills/add-wechat/scripts/wire-dm.ts header for usage.');
process.exit(0);
}
}
return args;
}
async function prompt(q: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => rl.question(q, (a) => { rl.close(); resolve(a.trim()); }));
}
function generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
// 1. Pick the messaging group
let platformId = args.platformId;
if (!platformId) {
const rows = db.prepare(`
SELECT mg.id, mg.platform_id, mg.name, mg.is_group, mg.created_at
FROM messaging_groups mg
LEFT JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id
WHERE mg.channel_type = 'wechat' AND mga.id IS NULL
ORDER BY mg.created_at DESC
`).all() as Array<{ id: string; platform_id: string; name: string | null; is_group: number; created_at: string }>;
if (rows.length === 0) {
console.error('No unwired WeChat messaging groups found.');
console.error('Send a message to the bot first (from another WeChat account), then re-run.');
process.exit(1);
}
if (rows.length === 1 || !args.interactive) {
platformId = rows[0].platform_id;
console.log(`Using most recent unwired group: ${platformId} (${rows[0].is_group ? 'group' : 'DM'})`);
} else {
console.log('Unwired WeChat messaging groups:');
rows.forEach((r, i) => {
console.log(` ${i + 1}. ${r.platform_id} (${r.is_group ? 'group' : 'DM'}, ${r.created_at})`);
});
const pick = await prompt('Pick one [1]: ');
const idx = pick === '' ? 0 : parseInt(pick, 10) - 1;
if (Number.isNaN(idx) || idx < 0 || idx >= rows.length) throw new Error('invalid choice');
platformId = rows[idx].platform_id;
}
}
const mg = db.prepare(
'SELECT id, platform_id, is_group FROM messaging_groups WHERE channel_type = ? AND platform_id = ?'
).get('wechat', platformId) as { id: string; platform_id: string; is_group: number } | undefined;
if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${platformId}`);
// 2. Pick the agent group
let agentGroupId = args.agentGroupId;
if (!agentGroupId) {
const agents = db.prepare('SELECT id, name, is_admin FROM agent_groups ORDER BY is_admin DESC, created_at ASC')
.all() as Array<{ id: string; name: string; is_admin: number }>;
if (agents.length === 0) throw new Error('no agent groups exist — create one first');
const adminAgents = agents.filter((a) => a.is_admin === 1);
if (adminAgents.length === 1 && !args.interactive) {
agentGroupId = adminAgents[0].id;
console.log(`Auto-selected sole admin agent group: ${adminAgents[0].name} (${agentGroupId})`);
} else if (args.interactive) {
console.log('Agent groups:');
agents.forEach((a, i) => {
console.log(` ${i + 1}. ${a.name} (${a.id})${a.is_admin ? ' [admin]' : ''}`);
});
const pick = await prompt('Pick one [1]: ');
const idx = pick === '' ? 0 : parseInt(pick, 10) - 1;
if (Number.isNaN(idx) || idx < 0 || idx >= agents.length) throw new Error('invalid choice');
agentGroupId = agents[idx].id;
} else {
throw new Error('multiple agent groups exist; pass --agent-group <id>');
}
}
const ag = db.prepare('SELECT id, name FROM agent_groups WHERE id = ?').get(agentGroupId) as
{ id: string; name: string } | undefined;
if (!ag) throw new Error(`no agent_group with id = ${agentGroupId}`);
// 3. Update sender policy + wire
const tx = db.transaction(() => {
db.prepare('UPDATE messaging_groups SET unknown_sender_policy = ? WHERE id = ?')
.run(args.senderPolicy, mg.id);
db.prepare(`
INSERT INTO messaging_group_agents
(id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
VALUES (?, ?, ?, '', 'all', ?, 10, datetime('now'))
`).run(generateId('mga'), mg.id, ag.id, args.sessionMode);
});
tx();
console.log('');
console.log(`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} policy=${args.senderPolicy} mode=${args.sessionMode}`);
db.close();
}
main().catch((err) => {
console.error('FAILED:', err.message);
process.exit(1);
});

View File

@@ -189,6 +189,7 @@ Print the list as a numbered plain-prose list (too many options for `AskUserQues
> 12. **Webex** — `/add-webex`
> 13. **Resend (email)** — `/add-resend`
> 14. **Emacs** — `/add-emacs`
> 15. **WeChat** — `/add-wechat`
>
> Or say "skip" to leave this for later.