| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384 |
- const crypto = require('crypto')
- const db = require('../../plugin/DataBase/db')
- const Logger = require('../Logger')
- const config = require('../../config.json')
- const { ConversationService, normalizeText } = require('./ConversationService')
- const { consumeQuota } = require('./QuotaService')
- const WeixinBotClient = require('./WeixinBotClient')
- const OneBotV11 = require('../../plugin/OneBot/OneBotV11')
- const { getUserVipInfo } = require('../VipService')
- const STATE_PENDING = 0
- const STATE_ACTIVE = 1
- const STATE_DISABLED = 2
- const STATE_EXPIRED = 3
- const WECHAT_EXPIRE_MS = 24 * 60 * 60 * 1000
- const WECHAT_REMIND_MS = 23 * 60 * 60 * 1000
- const logger = new Logger()
- let pollTimer = null
- let running = false
- function keyBuffer() {
- const raw = config.weixinBot?.tokenAesKey || config.qk?.passwordAesKey || 'runforge-weixin-token-key'
- return crypto.createHash('sha256').update(String(raw)).digest()
- }
- function encryptToken(text) {
- if (!text) return ''
- const iv = crypto.randomBytes(12)
- const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer(), iv)
- const encrypted = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()])
- const tag = cipher.getAuthTag()
- return `${iv.toString('base64')}.${tag.toString('base64')}.${encrypted.toString('base64')}`
- }
- function decryptToken(value) {
- if (!value) return ''
- const [ivRaw, tagRaw, encryptedRaw] = String(value).split('.')
- const decipher = crypto.createDecipheriv('aes-256-gcm', keyBuffer(), Buffer.from(ivRaw, 'base64'))
- decipher.setAuthTag(Buffer.from(tagRaw, 'base64'))
- return Buffer.concat([
- decipher.update(Buffer.from(encryptedRaw, 'base64')),
- decipher.final()
- ]).toString('utf8')
- }
- async function assertVip(uuid) {
- const vip = await getUserVipInfo(uuid)
- return vip.vip
- }
- function sanitizeBinding(row) {
- if (!row) return null
- return {
- user_uuid: row.user_uuid,
- state: Number(row.state),
- qrcode_url: row.qrcode_url,
- wechat_last_user_id: row.wechat_last_user_id ? 'bound' : '',
- last_user_message_time: Number(row.last_user_message_time || 0),
- expire_remind_time: Number(row.expire_remind_time || 0),
- last_error: row.last_error || '',
- bind_time: Number(row.bind_time || 0),
- create_time: Number(row.create_time || 0),
- update_time: Number(row.update_time || 0)
- }
- }
- async function getBindingByUser(uuid, includeSecret = false) {
- const rows = await db.query(
- `SELECT * FROM weixin_bot_binding WHERE user_uuid = ? LIMIT 1`,
- [uuid]
- )
- const row = rows?.[0] || null
- return includeSecret ? row : sanitizeBinding(row)
- }
- async function createBinding(uuid) {
- if (!await assertVip(uuid)) return { ok: false, msg: '微信绑定仅限 VIP 用户使用' }
- let qr
- try {
- qr = await WeixinBotClient.getQrCode()
- } catch (err) {
- logger.error(`Weixin get qrcode failed: ${err.stack || err.message || err}`)
- return { ok: false, msg: err.userMessage || '获取微信二维码失败' }
- }
- const qrcode = qr.qrcode || qr.data?.qrcode || ''
- let qrcodeUrl = qr.url || qr.qrcode_url || qr.qrcode_img_content || qr.data?.url || qr.data?.qrcode_url || qr.data?.qrcode_img_content || ''
- if (qrcodeUrl && !/^https?:\/\//i.test(qrcodeUrl) && !String(qrcodeUrl).startsWith('data:image/')) {
- qrcodeUrl = `data:image/png;base64,${qrcodeUrl}`
- }
- if (!qrcode) return { ok: false, msg: '获取微信二维码失败' }
- const now = Date.now()
- await db.query(
- `INSERT INTO weixin_bot_binding
- (user_uuid, state, qrcode, qrcode_url, bot_token_enc, base_url, get_updates_buf, last_error, create_time, update_time)
- VALUES (?, ?, ?, ?, '', '', '', '', ?, ?)
- ON DUPLICATE KEY UPDATE state = VALUES(state), qrcode = VALUES(qrcode), qrcode_url = VALUES(qrcode_url),
- bot_token_enc = '', base_url = '', get_updates_buf = '', last_error = '', update_time = VALUES(update_time)`,
- [uuid, STATE_PENDING, qrcode, qrcodeUrl, now, now]
- )
- return { ok: true, data: await getBindingByUser(uuid) }
- }
- async function refreshQrStatus(uuid) {
- const row = await getBindingByUser(uuid, true)
- if (!row) return null
- if (Number(row.state) !== STATE_PENDING || !row.qrcode) return sanitizeBinding(row)
- const status = await WeixinBotClient.getQrCodeStatus(row.qrcode)
- const confirmed = status.status === 'confirmed' || status.data?.status === 'confirmed'
- if (!confirmed) return sanitizeBinding(row)
- const botToken = status.bot_token || status.data?.bot_token
- const baseUrl = status.baseurl || status.base_url || status.data?.baseurl || status.data?.base_url || WeixinBotClient.DEFAULT_BASE_URL
- if (!botToken) return sanitizeBinding(row)
- const now = Date.now()
- await db.query(
- `UPDATE weixin_bot_binding
- SET state = ?, bot_token_enc = ?, base_url = ?, bind_time = ?, last_user_message_time = ?, last_error = '', update_time = ?
- WHERE user_uuid = ?`,
- [STATE_ACTIVE, encryptToken(botToken), baseUrl, now, now, now, uuid]
- )
- return getBindingByUser(uuid)
- }
- async function disableBinding(uuid) {
- const now = Date.now()
- await db.query(
- 'UPDATE weixin_bot_binding SET state = ?, update_time = ? WHERE user_uuid = ?',
- [STATE_DISABLED, now, uuid]
- )
- return true
- }
- function extractInbound(msg) {
- const items = Array.isArray(msg?.item_list) ? msg.item_list : []
- const texts = []
- const images = []
- for (const item of items) {
- if (Number(item.type) === 1 && item.text_item?.text) texts.push(String(item.text_item.text))
- if (Number(item.type) === 2) {
- const image = item.image_item || item.image || {}
- const url = image.url || image.cdn_url || image.download_url || image.file_url || ''
- if (url) images.push(String(url))
- else texts.push('[微信图片]')
- }
- }
- return {
- text: normalizeText(texts.join('\n'), 2000),
- images,
- fromUserId: msg?.from_user_id || '',
- toUserId: msg?.to_user_id || '',
- contextToken: msg?.context_token || ''
- }
- }
- async function getOrCreateWechatConversation(uuid) {
- return ConversationService.getOrCreateChannelConversation({
- uuid,
- channel: 'wechat',
- title: '微信对话'
- })
- }
- async function handleInboundMessage(binding, msg) {
- const inbound = extractInbound(msg)
- if (!inbound.fromUserId || (!inbound.text && inbound.images.length === 0)) return
- const now = Date.now()
- await db.query(
- `UPDATE weixin_bot_binding
- SET wechat_bot_user_id = ?, wechat_last_user_id = ?, last_context_token = ?, last_user_message_time = ?, expire_remind_time = 0, update_time = ?
- WHERE user_uuid = ?`,
- [inbound.toUserId, inbound.fromUserId, inbound.contextToken, now, now, binding.user_uuid]
- )
- const quota = await consumeQuota({ uuid: binding.user_uuid, channel: 'wechat' })
- if (!quota.allowed) {
- await sendTextToUser(binding.user_uuid, quota.message || '今日与小妍助理聊天次数已达上限,请明天再试')
- return
- }
- const conversation = await getOrCreateWechatConversation(binding.user_uuid)
- const saved = await ConversationService.addUserMessage({
- uuid: binding.user_uuid,
- conversationId: conversation.id,
- content: inbound.text,
- images: inbound.images,
- channel: 'wechat'
- })
- if (!saved || saved.missingContent) return
- try {
- await OneBotV11.sendAiChatMessage({
- conversationId: saved.conversationId,
- conversationNo: saved.conversationNo,
- senderUuid: binding.user_uuid,
- content: saved.content,
- images: saved.images,
- channel: 'wechat'
- })
- } catch (err) {
- logger.error(`WeChat AIChat OneBot forward failed: ${err.stack || err}`)
- await ConversationService.addSystemMessage({
- conversationId: saved.conversationId,
- content: '消息已保存,但暂时无法连接小妍助理。',
- status: 'error',
- errorMsg: err.message || 'OneBot send failed',
- channel: 'wechat'
- })
- await sendTextToUser(binding.user_uuid, '消息已保存,但暂时无法连接小妍助理,请稍后再试。')
- }
- }
- async function pollOneBinding(row) {
- const now = Date.now()
- if (!await assertVip(row.user_uuid)) {
- await db.query('UPDATE weixin_bot_binding SET state = ?, update_time = ?, last_error = ? WHERE user_uuid = ?', [STATE_DISABLED, now, 'VIP expired', row.user_uuid])
- return
- }
- if (Number(row.last_user_message_time || 0) > 0 && now - Number(row.last_user_message_time) >= WECHAT_EXPIRE_MS) {
- await db.query('UPDATE weixin_bot_binding SET state = ?, update_time = ? WHERE user_uuid = ?', [STATE_EXPIRED, now, row.user_uuid])
- return
- }
- const botToken = decryptToken(row.bot_token_enc)
- const result = await WeixinBotClient.getUpdates({
- baseUrl: row.base_url,
- botToken,
- getUpdatesBuf: row.get_updates_buf || ''
- })
- const nextBuf = result.get_updates_buf ?? result.data?.get_updates_buf ?? row.get_updates_buf ?? ''
- await db.query(
- 'UPDATE weixin_bot_binding SET get_updates_buf = ?, last_poll_time = ?, last_error = ?, update_time = ? WHERE user_uuid = ?',
- [nextBuf, Date.now(), '', Date.now(), row.user_uuid]
- )
- const msgs = result.msgs || result.data?.msgs || []
- for (const msg of msgs) {
- if (Number(msg.message_type) === 1) await handleInboundMessage(row, msg)
- }
- }
- async function pollActiveBindings() {
- if (running) return
- running = true
- try {
- const rows = await db.query(
- `SELECT * FROM weixin_bot_binding
- WHERE state = ?
- ORDER BY last_poll_time ASC
- LIMIT ?`,
- [STATE_ACTIVE, String(Number(config.weixinBot?.pollBatchSize || 5))]
- ) || []
- for (const row of rows) {
- try {
- await pollOneBinding(row)
- } catch (err) {
- logger.error(`WeChat binding poll failed user=${row.user_uuid}: ${err.stack || err}`)
- await db.query(
- 'UPDATE weixin_bot_binding SET last_error = ?, last_poll_time = ?, update_time = ? WHERE user_uuid = ?',
- [String(err.message || err).slice(0, 255), Date.now(), Date.now(), row.user_uuid]
- )
- }
- }
- await sendExpireReminders()
- } finally {
- running = false
- }
- }
- async function sendExpireReminders() {
- const threshold = Date.now() - WECHAT_REMIND_MS
- const rows = await db.query(
- `SELECT user_uuid FROM weixin_bot_binding
- WHERE state = ? AND last_user_message_time > 0 AND last_user_message_time <= ? AND expire_remind_time = 0
- LIMIT 30`,
- [STATE_ACTIVE, threshold]
- ) || []
- for (const row of rows) {
- try {
- await sendTextToUser(row.user_uuid, '微信登录即将过期,请在 1 小时内向小妍助理发送任意消息以保持登录。')
- await db.query('UPDATE weixin_bot_binding SET expire_remind_time = ?, update_time = ? WHERE user_uuid = ?', [Date.now(), Date.now(), row.user_uuid])
- } catch (err) {
- logger.error(`WeChat expire reminder failed user=${row.user_uuid}: ${err.stack || err}`)
- }
- }
- }
- async function sendTextToUser(uuid, text) {
- if (!await assertVip(uuid)) return false
- const row = await getBindingByUser(uuid, true)
- if (!row || Number(row.state) !== STATE_ACTIVE) return false
- if (!row.wechat_last_user_id || !row.last_context_token) return false
- try {
- await WeixinBotClient.sendText({
- baseUrl: row.base_url,
- botToken: decryptToken(row.bot_token_enc),
- toUserId: row.wechat_last_user_id,
- contextToken: row.last_context_token,
- text
- })
- await db.query(
- 'UPDATE weixin_bot_binding SET last_error = ?, update_time = ? WHERE user_uuid = ?',
- ['', Date.now(), uuid]
- )
- } catch (err) {
- await db.query(
- 'UPDATE weixin_bot_binding SET last_error = ?, update_time = ? WHERE user_uuid = ?',
- [String(err.message || err).slice(0, 255), Date.now(), uuid]
- )
- throw err
- }
- return true
- }
- async function listAdminBindings({ user_uuid, username, state, vip, expired, current = 1, pagesize = 20 }) {
- const c = Math.max(1, Number(current || 1))
- const p = Math.min(100, Math.max(1, Number(pagesize || 20)))
- const where = ['1=1']
- const params = []
- const countParams = []
- if (user_uuid) {
- where.push('b.user_uuid LIKE ?')
- params.push(`%${user_uuid}%`)
- countParams.push(`%${user_uuid}%`)
- }
- if (username) {
- where.push('u.username LIKE ?')
- params.push(`%${username}%`)
- countParams.push(`%${username}%`)
- }
- if (state !== undefined && state !== null && state !== '' && Number(state) !== -1) {
- where.push('b.state = ?')
- params.push(Number(state))
- countParams.push(Number(state))
- }
- if (String(vip) === '1') where.push('(u.vip_expire_time > UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 OR (u.vip = 1 AND u.vip_expire_time = 0))')
- if (String(vip) === '0') where.push('NOT (u.vip_expire_time > UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 OR (u.vip = 1 AND u.vip_expire_time = 0))')
- if (String(expired) === '1') where.push('(b.state = 3 OR (b.last_user_message_time > 0 AND b.last_user_message_time <= UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 - 86400000))')
- const whereSql = where.join(' AND ')
- const rows = await db.query(
- `SELECT b.user_uuid, b.state, b.wechat_last_user_id, b.last_user_message_time, b.expire_remind_time,
- b.last_error, b.last_poll_time, b.bind_time, b.create_time, b.update_time,
- u.username, u.vip, u.vip_expire_time
- FROM weixin_bot_binding b
- LEFT JOIN users u ON u.uuid = b.user_uuid
- WHERE ${whereSql}
- ORDER BY b.update_time DESC
- LIMIT ? OFFSET ?`,
- [...params, String(p), String((c - 1) * p)]
- )
- const countRows = await db.query(
- `SELECT COUNT(*) AS total FROM weixin_bot_binding b LEFT JOIN users u ON u.uuid = b.user_uuid WHERE ${whereSql}`,
- countParams
- )
- return {
- data: (rows || []).map(row => ({
- ...row,
- wechat_last_user_id: row.wechat_last_user_id ? 'bound' : '',
- token: undefined,
- bot_token_enc: undefined
- })),
- pagination: { current: c, pagesize: p, total: Number(countRows?.[0]?.total || 0) }
- }
- }
- function startPolling() {
- if (config.weixinBot?.enabled !== true) return
- if (pollTimer) return
- const interval = Math.max(5000, Number(config.weixinBot?.pollIntervalMs || 5000))
- pollTimer = setInterval(() => {
- pollActiveBindings().catch(err => logger.error(`WeChat poll loop failed: ${err.stack || err}`))
- }, interval)
- pollActiveBindings().catch(err => logger.error(`WeChat initial poll failed: ${err.stack || err}`))
- }
- module.exports = {
- STATE_PENDING,
- STATE_ACTIVE,
- STATE_DISABLED,
- STATE_EXPIRED,
- getBindingByUser,
- createBinding,
- refreshQrStatus,
- disableBinding,
- sendTextToUser,
- listAdminBindings,
- startPolling
- }
|