Files
yawan-workbench/scripts/wechat_ilink_sidecar.mjs
T

185 lines
9.1 KiB
JavaScript

/** 腾讯/ 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}`))