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:
49
.claude/skills/add-wechat/REMOVE.md
Normal file
49
.claude/skills/add-wechat/REMOVE.md
Normal 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
|
||||||
|
```
|
||||||
170
.claude/skills/add-wechat/SKILL.md
Normal file
170
.claude/skills/add-wechat/SKILL.md
Normal 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.
|
||||||
172
.claude/skills/add-wechat/scripts/wire-dm.ts
Normal file
172
.claude/skills/add-wechat/scripts/wire-dm.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -189,6 +189,7 @@ Print the list as a numbered plain-prose list (too many options for `AskUserQues
|
|||||||
> 12. **Webex** — `/add-webex`
|
> 12. **Webex** — `/add-webex`
|
||||||
> 13. **Resend (email)** — `/add-resend`
|
> 13. **Resend (email)** — `/add-resend`
|
||||||
> 14. **Emacs** — `/add-emacs`
|
> 14. **Emacs** — `/add-emacs`
|
||||||
|
> 15. **WeChat** — `/add-wechat`
|
||||||
>
|
>
|
||||||
> Or say "skip" to leave this for later.
|
> Or say "skip" to leave this for later.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user