chore(scripts): add development, DSH and sidecar launchers
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
/** 腾讯/ 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}`))
|
||||
Reference in New Issue
Block a user