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`
|
||||
> 13. **Resend (email)** — `/add-resend`
|
||||
> 14. **Emacs** — `/add-emacs`
|
||||
> 15. **WeChat** — `/add-wechat`
|
||||
>
|
||||
> Or say "skip" to leave this for later.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user