mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
feat(channels/wechat): personal WeChat adapter via Tencent iLink Bot API
Native adapter (no Chat SDK bridge) for personal WeChat, using Tencent's official iLink Bot API at ilinkai.weixin.qq.com — the same protocol @tencent-weixin/openclaw-weixin uses. No webhook, no ban risk, no paid tokens. Lifecycle: - Factory gated on WECHAT_ENABLED=true in .env. - On setup, resume from data/wechat/auth.json if present; otherwise run QR login (URL written to data/wechat/qr.txt and logged) and persist botToken, accountId, baseUrl, operatorUserId on success. - Long-poll via WeChatClient.start() with sync-buf persistence so no messages are dropped across restarts. - Inbound routes to setupConfig.onInbound with platform_id = wechat:<from_user_id|group_id> and a log hint pointing at the /add-wechat wire-dm.ts helper for post-login wiring. - Outbound via sendText (context_token auto-cached by the client). Region-restricted to mainland 微信 accounts — the iLink QR flow doesn't complete from international WeChat clients. This is a platform-side restriction, not an adapter bug. Pairs with the /add-wechat skill on v2 (installs this file, adds the self-registration import on the user's install, pins wechat-ilink-client@0.1.0). 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:
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* WeChat channel adapter — uses Tencent's official iLink Bot API.
|
||||
*
|
||||
* Unlike puppet-based libraries (wechaty/PadLocal) this uses the first-party
|
||||
* Tencent API. No ban risk. Free. Works with any personal WeChat account.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Factory gated on WECHAT_ENABLED=true in .env.
|
||||
* 2. On setup, load saved auth if present; otherwise run QR login.
|
||||
* The QR URL is written to data/wechat/qr.txt and logged.
|
||||
* 3. Long-poll for messages via WeChatClient, cursor persisted between
|
||||
* restarts so no messages are dropped.
|
||||
* 4. Outbound via sendText — context_token auto-cached by the client.
|
||||
*
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { WeChatClient, MessageType, type WeixinMessage } from 'wechat-ilink-client';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { DATA_DIR } from '../config.js';
|
||||
import { log } from '../log.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
|
||||
|
||||
const DATA_SUBDIR = path.join(DATA_DIR, 'wechat');
|
||||
const AUTH_FILE = path.join(DATA_SUBDIR, 'auth.json');
|
||||
const SYNC_BUF_FILE = path.join(DATA_SUBDIR, 'sync-buf.txt');
|
||||
const QR_FILE = path.join(DATA_SUBDIR, 'qr.txt');
|
||||
|
||||
interface SavedAuth {
|
||||
botToken: string;
|
||||
accountId: string;
|
||||
baseUrl?: string;
|
||||
/** The WeChat user_id of whoever scanned the QR — i.e. the operator. */
|
||||
operatorUserId?: string;
|
||||
}
|
||||
|
||||
function loadAuth(): SavedAuth | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8')) as SavedAuth;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveAuth(auth: SavedAuth): void {
|
||||
fs.mkdirSync(DATA_SUBDIR, { recursive: true });
|
||||
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
|
||||
}
|
||||
|
||||
function loadSyncBuf(): string | undefined {
|
||||
try {
|
||||
return fs.readFileSync(SYNC_BUF_FILE, 'utf8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSyncBuf(buf: string): void {
|
||||
fs.mkdirSync(DATA_SUBDIR, { recursive: true });
|
||||
fs.writeFileSync(SYNC_BUF_FILE, buf);
|
||||
}
|
||||
|
||||
function writeQr(url: string): void {
|
||||
fs.mkdirSync(DATA_SUBDIR, { recursive: true });
|
||||
fs.writeFileSync(QR_FILE, url);
|
||||
}
|
||||
|
||||
function messageText(msg: OutboundMessage): string {
|
||||
if (typeof msg.content === 'string') return msg.content;
|
||||
const c = msg.content as Record<string, unknown>;
|
||||
return (c.text as string) || (c.markdown as string) || JSON.stringify(msg.content);
|
||||
}
|
||||
|
||||
registerChannelAdapter('wechat', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['WECHAT_ENABLED']);
|
||||
if (env.WECHAT_ENABLED !== 'true') return null;
|
||||
|
||||
let client: WeChatClient | null = null;
|
||||
let setupConfig: ChannelSetup;
|
||||
let connected = false;
|
||||
let accountId: string | undefined;
|
||||
|
||||
async function ensureLoggedIn(): Promise<WeChatClient> {
|
||||
const saved = loadAuth();
|
||||
if (saved) {
|
||||
const c = new WeChatClient({
|
||||
token: saved.botToken,
|
||||
baseUrl: saved.baseUrl,
|
||||
accountId: saved.accountId,
|
||||
});
|
||||
accountId = saved.accountId;
|
||||
log.info('WeChat: resumed from saved auth', { accountId });
|
||||
return c;
|
||||
}
|
||||
|
||||
const c = new WeChatClient();
|
||||
const result = await c.login({
|
||||
onQRCode: (url) => {
|
||||
writeQr(url);
|
||||
log.info('WeChat QR ready — open this URL in a browser and scan with the WeChat app', { url });
|
||||
},
|
||||
});
|
||||
if (!result.connected || !result.botToken || !result.accountId) {
|
||||
throw new Error(`WeChat login failed: ${result.message}`);
|
||||
}
|
||||
saveAuth({
|
||||
botToken: result.botToken,
|
||||
accountId: result.accountId,
|
||||
baseUrl: result.baseUrl,
|
||||
operatorUserId: result.userId,
|
||||
});
|
||||
accountId = result.accountId;
|
||||
log.info('WeChat: login complete', { accountId, operatorUserId: result.userId });
|
||||
return c;
|
||||
}
|
||||
|
||||
function onMessage(msg: WeixinMessage): void {
|
||||
if (msg.message_type !== MessageType.USER) return;
|
||||
|
||||
const isGroup = !!msg.group_id;
|
||||
const platformIdRaw = isGroup ? msg.group_id! : msg.from_user_id!;
|
||||
const platformId = `wechat:${platformIdRaw}`;
|
||||
const senderId = `wechat:${msg.from_user_id ?? 'unknown'}`;
|
||||
const text = WeChatClient.extractText(msg);
|
||||
|
||||
log.info('WeChat inbound', {
|
||||
platformId,
|
||||
senderId,
|
||||
isGroup,
|
||||
hint: 'if not wired yet, run: pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts',
|
||||
});
|
||||
|
||||
setupConfig.onMetadata(platformId, undefined, isGroup);
|
||||
|
||||
const inbound: InboundMessage = {
|
||||
id: String(msg.message_id ?? msg.seq ?? Date.now()),
|
||||
kind: 'chat',
|
||||
content: {
|
||||
text,
|
||||
senderId,
|
||||
sender: msg.from_user_id,
|
||||
senderName: msg.from_user_id,
|
||||
isGroup,
|
||||
},
|
||||
timestamp: new Date(msg.create_time_ms ?? Date.now()).toISOString(),
|
||||
};
|
||||
|
||||
setupConfig.onInbound(platformId, null, inbound);
|
||||
}
|
||||
|
||||
const adapter: ChannelAdapter = {
|
||||
name: 'wechat',
|
||||
channelType: 'wechat',
|
||||
supportsThreads: false,
|
||||
|
||||
async setup(config: ChannelSetup) {
|
||||
setupConfig = config;
|
||||
|
||||
client = await ensureLoggedIn();
|
||||
|
||||
client.on('message', (msg) => {
|
||||
try {
|
||||
onMessage(msg);
|
||||
} catch (err) {
|
||||
log.warn('WeChat: onMessage error', { err });
|
||||
}
|
||||
});
|
||||
client.on('error', (err) => log.warn('WeChat: poll error', { err }));
|
||||
client.on('sessionExpired', () => {
|
||||
log.error('WeChat: session expired — delete data/wechat/auth.json and restart to re-scan');
|
||||
connected = false;
|
||||
});
|
||||
|
||||
client.start({
|
||||
loadSyncBuf,
|
||||
saveSyncBuf,
|
||||
}).catch((err) => log.error('WeChat: monitor loop crashed', { err }));
|
||||
|
||||
connected = true;
|
||||
log.info('WeChat adapter ready', { accountId });
|
||||
},
|
||||
|
||||
async teardown() {
|
||||
connected = false;
|
||||
client?.stop();
|
||||
client = null;
|
||||
},
|
||||
|
||||
isConnected() {
|
||||
return connected;
|
||||
},
|
||||
|
||||
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
|
||||
if (!client) return undefined;
|
||||
const to = platformId.replace(/^wechat:/, '');
|
||||
const text = messageText(message);
|
||||
if (!text) return undefined;
|
||||
try {
|
||||
const msgId = await client.sendText(to, text);
|
||||
return msgId;
|
||||
} catch (err) {
|
||||
log.error('WeChat deliver failed', { platformId, err });
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user