/** 腾讯/ CoreSpeed iLink 多账号 sidecar。 * npm i @wechatbot/wechatbot * Node.js >= 22 required by @wechatbot/wechatbot. * YAWAN_API_URL=https://agent.pducv.cn /opt/homebrew/opt/node@22/bin/node scripts/wechat_ilink_sidecar.mjs */ import http from 'node:http' import crypto from 'node:crypto' import os from 'node:os' import path from 'node:path' import { WeChatBot } from '@wechatbot/wechatbot' const apiUrl = (process.env.YAWAN_API_URL || 'http://127.0.0.1:8010').replace(/\/$/, '') const port = Number(process.env.YAWAN_WECHAT_SIDECAR_PORT || 8765) const accounts = new Map() /** 将 iLink 登录状态同步回主系统。密钥只通过本机 sidecar 到后端传输, * 主系统不会把 bot secret 返回给前端或写入日志。 */ async function notifySystem (account, status, extra = {}) { if (!account?.botSecret || !account?.id) return try { const response = await fetch(`${apiUrl}/api/wechat/bot/accounts/${account.id}/callback`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-wechat-bot-secret': account.botSecret, }, body: JSON.stringify({ status, ...extra }), }) if (!response.ok) { const detail = await response.text().catch(() => '') console.error(`微信状态同步失败 ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`) } } catch (error) { console.error('微信状态同步失败', error.message) } } function resolveStorageDir (value, accountId) { const raw = String(value || path.join(os.homedir(), '.wechatbot')) const expanded = raw === '~' ? os.homedir() : raw.startsWith('~/') ? path.join(os.homedir(), raw.slice(2)) : raw return path.join(path.resolve(expanded), String(accountId)) } async function callSystem (account, text, messageId, conversationId) { const timestamp = String(Math.floor(Date.now() / 1000)) const payload = `${timestamp}.${conversationId}.${messageId}.${text}` const signature = crypto.createHmac('sha256', account.botSecret).update(payload).digest('hex') const response = await fetch(`${apiUrl}/api/wechat/bot/reply`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-wechat-bot-secret': account.botSecret, 'x-wechat-bot-timestamp': timestamp, 'x-wechat-bot-signature': signature }, body: JSON.stringify({ account_id: account.id, agent_id: account.agentId, conversation_id: conversationId, message_id: messageId, text }) }) const data = await response.json().catch(() => ({})) if (!response.ok) throw new Error(data.detail || `系统 API ${response.status}`) return data.reply || '' } function json (res, status, body) { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) res.end(JSON.stringify(body)) } async function readBody (req) { const chunks = [] for await (const chunk of req) chunks.push(chunk) return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') } const server = http.createServer(async (req, res) => { try { if (req.method === 'POST' && req.url === '/accounts/connect') { const body = await readBody(req) if (!body.account_id || !body.bot_secret || !body.agent_id) return json(res, 422, { detail: '缺少账号参数' }) let account = accounts.get(Number(body.account_id)) // 删除或失败后的账号允许重新扫码;清理旧实例,避免复用过期凭据。 if (account && ['disconnected', 'error'].includes(account.status)) { account.stopping = true account.bot?.stop() accounts.delete(account.id) account = undefined } if (!account) { account = { id: Number(body.account_id), agentId: Number(body.agent_id), botSecret: String(body.bot_secret), status: 'pending_qr', qrCode: '', stopping: false } // loginCallbacks 不是 WeChatBot 构造参数的运行时配置;登录时必须传入 // callbacks。storage 按账号隔离,避免多账号复用同一份凭据。 const bot = new WeChatBot({ storage: 'file', storageDir: resolveStorageDir(process.env.YAWAN_WECHAT_STORAGE, account.id), botAgent: 'YawanWorkbench/1.0 (iLink)' }) const loginCallbacks = { onQrUrl: url => { account.qrCode = String(url || '') account.status = 'pending_qr' void notifySystem(account, 'pending_qr', { qr_code: account.qrCode, last_error: '' }) }, onScanned: () => { void notifySystem(account, 'pending_qr', { qr_code: account.qrCode, last_error: '' }) }, onExpired: () => { account.qrCode = '' void notifySystem(account, 'pending_qr', { qr_code: '', last_error: '二维码已过期,正在刷新' }) }, } account.bot = bot; accounts.set(account.id, account) // SDK 处理 session:expired 时会通过 this.login({ force: true }) 自动重登, // 包装 login 让自动重登同样回传二维码。 const originalLogin = bot.login.bind(bot) bot.login = (options = {}) => originalLogin({ ...options, callbacks: { ...(options.callbacks || {}), ...loginCallbacks }, }) bot.on('login', creds => { account.status = 'connected'; account.qrCode = '' const externalAccountId = creds?.accountId account.externalAccountId = externalAccountId ? String(externalAccountId) : '' void notifySystem(account, 'connected', externalAccountId ? { external_account_id: String(externalAccountId) } : {}) }) bot.on('session:restored', creds => { account.status = 'connected'; account.qrCode = '' const externalAccountId = creds?.accountId || account.externalAccountId void notifySystem(account, 'connected', externalAccountId ? { external_account_id: String(externalAccountId) } : {}) }) bot.on('session:expired', () => { account.status = 'pending_qr' account.qrCode = '' void notifySystem(account, 'pending_qr', { qr_code: '', last_error: '微信登录已过期,请重新扫码' }) }) bot.on('error', error => { account.status = 'error'; account.lastError = error?.message || String(error) void notifySystem(account, 'error', { last_error: account.lastError }) }) bot.on('poll:stop', () => { if (account.stopping) void notifySystem(account, 'disconnected', { qr_code: '', last_error: '' }) }) bot.on('close', () => { if (account.stopping) void notifySystem(account, 'disconnected', { qr_code: '', last_error: '' }) }) bot.onMessage(async msg => { if (!msg?.text) return const conversationId = String(msg.userId || 'unknown') const messageId = String(msg.messageId || msg.raw?.msg_id || `${conversationId}:${Date.now()}`) try { await bot.sendTyping(conversationId); const reply = await callSystem(account, msg.text, messageId, conversationId); if (reply) await bot.reply(msg, reply) } catch (error) { console.error('微信消息转发失败', error.message) } }) void bot.login().then(() => bot.start()).catch(error => { account.status = 'error'; account.lastError = error instanceof Error ? error.message : String(error) void notifySystem(account, 'error', { last_error: account.lastError }) }) } return json(res, 200, { status: account.status, qr_code: account.qrCode, external_account_id: account.externalAccountId || '' }) } const match = req.url?.match(/^\/accounts\/(\d+)$/) // 后端服务与 sidecar 通过本机回环通信;绑定 Agent 变更时同步内存配置。 // sidecar 仅监听 127.0.0.1,公网请求无法直接访问此路由。 if (req.method === 'PATCH' && match) { const account = accounts.get(Number(match[1])) if (!account) return json(res, 404, { detail: 'not found' }) const body = await readBody(req) if (body.agent_id !== undefined) { const agentId = Number(body.agent_id) if (!Number.isInteger(agentId) || agentId <= 0) return json(res, 422, { detail: '无效的 Agent' }) account.agentId = agentId } return json(res, 200, { ok: true, account_id: account.id, agent_id: account.agentId }) } if (req.method === 'GET' && match) { const account = accounts.get(Number(match[1])); return json(res, account ? 200 : 404, account ? { status: account.status, qr_code: account.qrCode, last_error: account.lastError || '', external_account_id: account.externalAccountId || '', } : { detail: 'not found' }) } if (req.method === 'DELETE' && match) { const account = accounts.get(Number(match[1])) if (account) { account.stopping = true account.bot?.stop() await notifySystem(account, 'disconnected', { qr_code: '', last_error: '' }) accounts.delete(account.id) } return json(res, 200, { ok: true }) } json(res, 404, { detail: 'not found' }) } catch (error) { json(res, 500, { detail: error.message }) } }) server.listen(port, '127.0.0.1', () => console.log(`微信 iLink sidecar listening on ${port}`))