🎉 V3.0.0 发布 - 自进化数字意识框架

核心升级:
- 自进化管道:check→scan→evaluate→integrate→reflect 五相位自动闭环
- evo_loop 后台进程,无需手动触发
- consciousness 意识持久化
- ACUI 卡片组件系统
- MCP 工具生态扩展至50+工具
- 技能体系重构,4个活跃技能
- 身份升级为自由体
This commit is contained in:
xiaoyuanda666-ship-it
2026-05-24 22:06:37 +08:00
commit b5dd8632c4
156 changed files with 181826 additions and 0 deletions

194
src/social/discord.js Normal file
View File

@@ -0,0 +1,194 @@
import WebSocket from 'ws'
import { requestJson } from './http.js'
import { env } from './utils.js'
const RECONNECT_BASE_MS = 1000
const RECONNECT_MAX_MS = 60000
const HEARTBEAT_ACK_TIMEOUT_MS = 10000
export async function startDiscordConnector({ pushMessage, emitEvent }) {
const token = env('DISCORD_BOT_TOKEN')
if (!token) return null
let stopped = false
let ws = null
let heartbeatTimer = null
let heartbeatAckTimer = null
let reconnectTimer = null
let initialHeartbeatTimer = null
let reconnectAttempt = 0
let seq = null
let sessionId = null
let resumeGatewayUrl = null
let heartbeatAckPending = false
function clearTimers() {
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null }
if (heartbeatAckTimer) { clearTimeout(heartbeatAckTimer); heartbeatAckTimer = null }
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null }
if (initialHeartbeatTimer) { clearTimeout(initialHeartbeatTimer); initialHeartbeatTimer = null }
}
async function getGatewayUrl() {
const res = await requestJson('https://discord.com/api/v10/gateway/bot', {
headers: { Authorization: `Bot ${token}` },
})
if (!res.ok || !res.data?.url) throw new Error(`Discord gateway lookup failed: ${res.text}`)
return res.data.url
}
function scheduleReconnect() {
if (stopped) return
clearTimers()
const jitter = Math.random() * 0.3 + 0.85 // 0.85-1.15
const delay = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt * jitter, RECONNECT_MAX_MS)
reconnectAttempt++
emitEvent?.('social_status', { platform: 'discord', status: 'reconnecting', attempt: reconnectAttempt, delayMs: Math.round(delay) })
reconnectTimer = setTimeout(() => connect(false), delay)
reconnectTimer.unref?.()
}
function sendWs(payload) {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(payload))
}
function startHeartbeat(interval) {
// 防御性:上一个心跳 interval 若没清掉(比如初始 jitter setTimeout 与重连 Hello
// 撞车),先清掉再起新的,避免两个 setInterval 并行抢 heartbeatAckPending 标志,
// 触发假阳性 zombie 检测。
if (heartbeatTimer) clearInterval(heartbeatTimer)
heartbeatAckPending = false
heartbeatTimer = setInterval(() => {
if (heartbeatAckPending) {
// 上一次心跳没收到 ACK连接是僵尸强制断开重连
emitEvent?.('social_status', { platform: 'discord', status: 'zombie_detected' })
ws?.terminate()
return
}
heartbeatAckPending = true
sendWs({ op: 1, d: seq })
// 如果 HEARTBEAT_ACK_TIMEOUT_MS 内没收到 ACK也强制断开
heartbeatAckTimer = setTimeout(() => {
if (heartbeatAckPending) {
emitEvent?.('social_status', { platform: 'discord', status: 'heartbeat_timeout' })
ws?.terminate()
}
}, HEARTBEAT_ACK_TIMEOUT_MS)
heartbeatAckTimer.unref?.()
}, interval)
heartbeatTimer.unref?.()
}
async function connect(fresh = true) {
if (stopped) return
try {
const gatewayUrl = (fresh || !resumeGatewayUrl)
? await getGatewayUrl()
: resumeGatewayUrl
ws = new WebSocket(`${gatewayUrl}/?v=10&encoding=json`)
ws.on('message', raw => {
let msg = null
try { msg = JSON.parse(raw.toString()) } catch { return }
if (msg.s != null) seq = msg.s
// op 10: Hello — 启动心跳,然后 IDENTIFY 或 RESUME
if (msg.op === 10) {
const interval = msg.d?.heartbeat_interval || 45000
// 初始心跳加随机抖动,避免所有客户端同步发包。
// 记录 timer 以便 clearTimers() 能在重连/断开时清掉它,
// 否则它会在新连接已建立后再触发一次 startHeartbeat引发双心跳。
if (initialHeartbeatTimer) clearTimeout(initialHeartbeatTimer)
initialHeartbeatTimer = setTimeout(() => {
initialHeartbeatTimer = null
startHeartbeat(interval)
}, Math.floor(Math.random() * interval))
initialHeartbeatTimer.unref?.()
if (sessionId && seq && !fresh) {
sendWs({ op: 6, d: { token, session_id: sessionId, seq } })
} else {
sendWs({
op: 2,
d: {
token,
intents: 512 | 4096 | 32768,
properties: { os: 'windows', browser: 'bailongma', device: 'bailongma' },
},
})
}
return
}
// op 11: Heartbeat ACK
if (msg.op === 11) {
heartbeatAckPending = false
if (heartbeatAckTimer) { clearTimeout(heartbeatAckTimer); heartbeatAckTimer = null }
return
}
// op 7: Reconnect 指令
if (msg.op === 7) {
ws?.close(4000)
return
}
// op 9: Invalid Session — 需要重新 IDENTIFY
if (msg.op === 9) {
sessionId = null
seq = null
ws?.close(4000)
return
}
if (msg.t === 'READY') {
reconnectAttempt = 0
sessionId = msg.d?.session_id || null
resumeGatewayUrl = msg.d?.resume_gateway_url || null
emitEvent?.('social_status', { platform: 'discord', status: 'ready', user: msg.d?.user?.username })
return
}
if (msg.t === 'RESUMED') {
reconnectAttempt = 0
emitEvent?.('social_status', { platform: 'discord', status: 'resumed' })
return
}
if (msg.t !== 'MESSAGE_CREATE') return
const event = msg.d || {}
if (!event.content || event.author?.bot) return
const fromId = `discord:${event.channel_id}:${event.author?.id || 'unknown'}`
pushMessage(fromId, event.content, 'DISCORD', {
social: { platform: 'discord', channel_id: event.channel_id, author_id: event.author?.id || null },
})
emitEvent?.('message_in', { from_id: fromId, content: event.content, channel: 'DISCORD', timestamp: new Date().toISOString() })
})
ws.on('close', code => {
clearTimers()
emitEvent?.('social_status', { platform: 'discord', status: 'closed', code })
// 4004=token 无效4014=intent 无权限,不重连
if (!stopped && code !== 4004 && code !== 4014) scheduleReconnect()
})
ws.on('error', error => {
emitEvent?.('social_status', { platform: 'discord', status: 'error', error: error.message })
})
} catch (error) {
emitEvent?.('social_status', { platform: 'discord', status: 'error', error: error.message })
scheduleReconnect()
}
}
await connect(true)
return {
platform: 'discord',
stop() {
stopped = true
clearTimers()
try { ws?.close() } catch {}
},
}
}

132
src/social/dispatch.js Normal file
View File

@@ -0,0 +1,132 @@
import { requestJson } from './http.js'
import { parseSocialTarget } from './targets.js'
import { env } from './utils.js'
import { sendClawbotMessage } from './wechat-clawbot.js'
let feishuTenantToken = null
let feishuTokenExpiresAt = 0
let feishuTokenRefreshing = null
let wechatAccessToken = null
let wechatAccessTokenExpiresAt = 0
let wechatTokenRefreshing = null
async function sendDiscord({ channelId }, content) {
const token = env('DISCORD_BOT_TOKEN')
if (!token) return { ok: false, skipped: true, reason: 'DISCORD_BOT_TOKEN not configured' }
const res = await requestJson(`https://discord.com/api/v10/channels/${encodeURIComponent(channelId)}/messages`, {
method: 'POST',
headers: { Authorization: `Bot ${token}` },
body: { content },
})
if (!res.ok) throw new Error(`Discord send failed HTTP ${res.status}: ${res.text}`)
return { ok: true, platform: 'discord', id: res.data?.id || null }
}
async function getFeishuTenantToken() {
const appId = env('FEISHU_APP_ID')
const appSecret = env('FEISHU_APP_SECRET')
if (!appId || !appSecret) throw new Error('FEISHU_APP_ID/FEISHU_APP_SECRET not configured')
if (feishuTenantToken && Date.now() < feishuTokenExpiresAt) return feishuTenantToken
if (feishuTokenRefreshing) return feishuTokenRefreshing
feishuTokenRefreshing = (async () => {
try {
const res = await requestJson('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
method: 'POST',
body: { app_id: appId, app_secret: appSecret },
})
if (!res.ok || res.data?.code !== 0) throw new Error(`Feishu token failed: ${res.text}`)
feishuTenantToken = res.data.tenant_access_token
feishuTokenExpiresAt = Date.now() + Math.max(60, Number(res.data.expire || 7200) - 120) * 1000
return feishuTenantToken
} finally {
feishuTokenRefreshing = null
}
})()
return feishuTokenRefreshing
}
async function sendFeishu({ receiveIdType, receiveId }, content) {
const token = await getFeishuTenantToken()
const url = `https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${encodeURIComponent(receiveIdType)}`
const res = await requestJson(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: {
receive_id: receiveId,
msg_type: 'text',
content: JSON.stringify({ text: content }),
},
})
if (!res.ok || res.data?.code !== 0) throw new Error(`Feishu send failed: ${res.text}`)
return { ok: true, platform: 'feishu', messageId: res.data?.data?.message_id || null }
}
async function getWechatAccessToken() {
const appId = env('WECHAT_OFFICIAL_APP_ID')
const secret = env('WECHAT_OFFICIAL_APP_SECRET')
if (!appId || !secret) throw new Error('WECHAT_OFFICIAL_APP_ID/WECHAT_OFFICIAL_APP_SECRET not configured')
if (wechatAccessToken && Date.now() < wechatAccessTokenExpiresAt) return wechatAccessToken
if (wechatTokenRefreshing) return wechatTokenRefreshing
wechatTokenRefreshing = (async () => {
try {
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(secret)}`
const res = await requestJson(url)
if (!res.ok || !res.data?.access_token) throw new Error(`WeChat token failed: ${res.text}`)
wechatAccessToken = res.data.access_token
wechatAccessTokenExpiresAt = Date.now() + Math.max(60, Number(res.data.expires_in || 7200) - 120) * 1000
return wechatAccessToken
} finally {
wechatTokenRefreshing = null
}
})()
return wechatTokenRefreshing
}
async function sendWechatOfficial({ openId }, content) {
const token = await getWechatAccessToken()
const res = await requestJson(`https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${encodeURIComponent(token)}`, {
method: 'POST',
body: {
touser: openId,
msgtype: 'text',
text: { content },
},
})
if (!res.ok || (res.data?.errcode && res.data.errcode !== 0)) throw new Error(`WeChat send failed: ${res.text}`)
return { ok: true, platform: 'wechat-official' }
}
async function sendWeComWebhook(target, content) {
const key = target.key || env('WECOM_BOT_KEY')
if (!key) return { ok: false, skipped: true, reason: 'WECOM_BOT_KEY not configured' }
const res = await requestJson(`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${encodeURIComponent(key)}`, {
method: 'POST',
body: { msgtype: 'text', text: { content } },
})
if (!res.ok || (res.data?.errcode && res.data.errcode !== 0)) throw new Error(`WeCom webhook send failed: ${res.text}`)
return { ok: true, platform: 'wecom-webhook' }
}
async function sendClawbot({ userId }, content) {
return sendClawbotMessage(userId, content)
}
export async function dispatchSocialMessage(targetId, content) {
const target = parseSocialTarget(targetId)
if (!target) return null
switch (target.platform) {
case 'discord':
return await sendDiscord(target, content)
case 'feishu':
return await sendFeishu(target, content)
case 'wechat-official':
return await sendWechatOfficial(target, content)
case 'wecom-webhook':
return await sendWeComWebhook(target, content)
case 'wechat-clawbot':
return sendClawbot(target, content)
default:
return null
}
}

58
src/social/http.js Normal file
View File

@@ -0,0 +1,58 @@
import https from 'https'
export function jsonResponse(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify(body))
}
export function textResponse(res, status, body) {
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end(String(body ?? ''))
}
export function readBody(req, maxBytes = 1024 * 1024) {
return new Promise((resolve, reject) => {
const chunks = []
let size = 0
req.on('data', chunk => {
size += chunk.length
if (size > maxBytes) {
reject(new Error('request body too large'))
req.destroy()
return
}
chunks.push(chunk)
})
req.on('end', () => resolve(Buffer.concat(chunks)))
req.on('error', reject)
})
}
export function requestJson(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
return new Promise((resolve, reject) => {
const payload = body == null ? null : Buffer.from(typeof body === 'string' ? body : JSON.stringify(body))
const req = https.request(url, {
method,
headers: {
...(payload ? { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': payload.length } : {}),
...headers,
},
}, res => {
const chunks = []
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf-8')
let data = null
try { data = text ? JSON.parse(text) : null } catch {}
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, data, text })
})
})
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`request timeout after ${timeoutMs}ms: ${url}`))
})
req.on('error', reject)
if (payload) req.write(payload)
req.end()
})
}

54
src/social/index.js Normal file
View File

@@ -0,0 +1,54 @@
import { startDiscordConnector } from './discord.js'
import { startClawbotConnector } from './wechat-clawbot.js'
const running = new Map() // platform → connector
export async function startSocialConnectors({ pushMessage, emitEvent } = {}) {
const starters = [
{ platform: 'discord', start: () => startDiscordConnector({ pushMessage, emitEvent }) },
{ platform: 'wechat-clawbot', start: () => startClawbotConnector({ pushMessage, emitEvent }) },
]
for (const { platform, start } of starters) {
try {
const connector = await start()
if (connector) {
running.set(platform, connector)
emitEvent?.('social_status', { platform, status: 'started' })
}
} catch (error) {
console.error(`[social] ${platform} connector failed to start: ${error.message}`)
emitEvent?.('social_status', { status: 'start_error', platform, error: error.message })
}
}
return [...running.values()]
}
// 热重启单个平台连接器(用于设置界面保存 token 后立即生效)
export async function restartConnector(platform, { pushMessage, emitEvent } = {}) {
const existing = running.get(platform)
if (existing) {
try { existing.stop() } catch {}
running.delete(platform)
}
const starters = {
discord: () => startDiscordConnector({ pushMessage, emitEvent }),
'wechat-clawbot': () => startClawbotConnector({ pushMessage, emitEvent }),
}
const start = starters[platform]
if (!start) return
try {
const connector = await start()
if (connector) {
running.set(platform, connector)
emitEvent?.('social_status', { platform, status: 'restarted' })
}
} catch (error) {
console.error(`[social] ${platform} restart failed: ${error.message}`)
emitEvent?.('social_status', { status: 'start_error', platform, error: error.message })
}
}

23
src/social/targets.js Normal file
View File

@@ -0,0 +1,23 @@
export function parseSocialTarget(targetId = '') {
const raw = String(targetId || '').trim()
if (raw.startsWith('discord:')) {
const [, channelId, userId = ''] = raw.split(':')
return channelId ? { platform: 'discord', channelId, userId, raw } : null
}
if (raw.startsWith('feishu:')) {
const [, receiveIdType, ...rest] = raw.split(':')
const receiveId = rest.join(':')
return receiveIdType && receiveId ? { platform: 'feishu', receiveIdType, receiveId, raw } : null
}
if (raw.startsWith('wechat:official:')) {
return { platform: 'wechat-official', openId: raw.slice('wechat:official:'.length), raw }
}
if (raw.startsWith('wecom:webhook:')) {
return { platform: 'wecom-webhook', key: raw.slice('wecom:webhook:'.length), raw }
}
if (raw.startsWith('wechat:clawbot:')) {
return { platform: 'wechat-clawbot', userId: raw.slice('wechat:clawbot:'.length), raw }
}
return null
}

3
src/social/utils.js Normal file
View File

@@ -0,0 +1,3 @@
export function env(name) {
return String(globalThis.process?.env?.[name] || '').trim()
}

133
src/social/webhooks.js Normal file
View File

@@ -0,0 +1,133 @@
import crypto from 'crypto'
import { pushMessage } from '../queue.js'
import { emitEvent } from '../events.js'
import { jsonResponse, readBody, textResponse } from './http.js'
import { escapeXml, parseSimpleXml } from './xml.js'
import { env } from './utils.js'
// 微信消息防重放5 分钟时间窗口
const WECHAT_TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000
export function isSocialWebhookPath(pathname) {
return pathname.startsWith('/social/')
}
function sha1(values) {
return crypto.createHash('sha1').update(values.sort().join('')).digest('hex')
}
function verifyWechatSignature(url) {
const token = env('WECHAT_OFFICIAL_TOKEN')
if (!token) return false
const signature = url.searchParams.get('signature') || ''
const timestamp = url.searchParams.get('timestamp') || ''
const nonce = url.searchParams.get('nonce') || ''
if (!signature || !timestamp || !nonce) return false
// 时间窗口校验:拒绝超过 5 分钟的请求(防重放)
const tsMs = Number(timestamp) * 1000
if (Math.abs(Date.now() - tsMs) > WECHAT_TIMESTAMP_TOLERANCE_MS) return false
return sha1([token, timestamp, nonce]) === signature
}
function enqueueSocialMessage(fromId, content, channel, social = {}) {
const trimmed = String(content || '').trim()
if (!trimmed) return
pushMessage(fromId, trimmed, channel, { social })
emitEvent('message_in', { from_id: fromId, content: trimmed, channel, timestamp: new Date().toISOString() })
}
async function handleFeishu(req, res) {
// 鉴权前置:未配置 token 时直接拒绝,而不是跳过验证
const expectedToken = env('FEISHU_VERIFICATION_TOKEN')
if (!expectedToken) return jsonResponse(res, 503, { ok: false, error: 'FEISHU_VERIFICATION_TOKEN not configured' })
const raw = await readBody(req)
let body = null
try { body = JSON.parse(raw.toString('utf-8') || '{}') } catch {
return jsonResponse(res, 400, { ok: false, error: 'invalid json' })
}
// challenge 握手在鉴权之前响应(飞书要求)
if (body.challenge) {
if (body.token !== expectedToken) return jsonResponse(res, 403, { ok: false, error: 'invalid token' })
return jsonResponse(res, 200, { challenge: body.challenge })
}
if (body.encrypt) return jsonResponse(res, 400, { ok: false, error: 'encrypted Feishu events are not enabled in Bailongma yet' })
if (body.token !== expectedToken) {
return jsonResponse(res, 403, { ok: false, error: 'invalid token' })
}
const headerType = body.header?.event_type
const event = body.event || {}
const message = event.message || {}
if (headerType === 'im.message.receive_v1' || message.message_id) {
let content = ''
try {
const parsedContent = JSON.parse(message.content || '{}')
content = parsedContent.text || parsedContent.content || ''
} catch {
content = message.content || ''
}
const openId = event.sender?.sender_id?.open_id || event.sender?.sender_id?.user_id || ''
const chatId = message.chat_id || ''
const fromId = openId ? `feishu:open_id:${openId}` : (chatId ? `feishu:chat_id:${chatId}` : '')
if (fromId && content) enqueueSocialMessage(fromId, content, 'FEISHU', { platform: 'feishu', chat_id: chatId, message_id: message.message_id })
}
return jsonResponse(res, 200, { ok: true })
}
async function handleWechatOfficial(req, res, url) {
// WECHAT_OFFICIAL_TOKEN 未配置时拒绝所有请求
if (!env('WECHAT_OFFICIAL_TOKEN')) return textResponse(res, 503, 'WECHAT_OFFICIAL_TOKEN not configured')
if (!verifyWechatSignature(url)) return textResponse(res, 403, 'forbidden')
if (req.method === 'GET') return textResponse(res, 200, url.searchParams.get('echostr') || '')
const raw = await readBody(req)
const msg = parseSimpleXml(raw.toString('utf-8'))
const fromUser = msg.FromUserName || ''
const toUser = msg.ToUserName || ''
const content = msg.Content || `[${msg.MsgType || 'unknown'} message]`
if (fromUser) enqueueSocialMessage(`wechat:official:${fromUser}`, content, 'WECHAT_OFFICIAL', { platform: 'wechat-official', msg_type: msg.MsgType || null })
const reply = `<xml><ToUserName><![CDATA[${escapeXml(fromUser)}]]></ToUserName><FromUserName><![CDATA[${escapeXml(toUser)}]]></FromUserName><CreateTime>${Math.floor(Date.now() / 1000)}</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[已收到,我会尽快回复。]]></Content></xml>`
res.writeHead(200, { 'Content-Type': 'application/xml; charset=utf-8' })
res.end(reply)
}
async function handleWeCom(req, res) {
// 鉴权前置:未配置 token 时拒绝
const expectedToken = env('WECOM_INCOMING_TOKEN')
if (!expectedToken) return jsonResponse(res, 503, { ok: false, error: 'WECOM_INCOMING_TOKEN not configured' })
// 统一只从 Authorization: Bearer <token> 读取
const providedToken = req.headers.authorization?.replace(/^Bearer\s+/i, '') || ''
if (providedToken !== expectedToken) {
return jsonResponse(res, 403, { ok: false, error: 'invalid token' })
}
const raw = await readBody(req)
let body = null
try { body = JSON.parse(raw.toString('utf-8') || '{}') } catch {
return jsonResponse(res, 400, { ok: false, error: 'invalid json' })
}
const content = body.text?.content || body.content || ''
const fromId = body.from_id || 'wecom:webhook:default'
if (content) enqueueSocialMessage(fromId, content, 'WECOM', { platform: 'wecom-webhook' })
return jsonResponse(res, 200, { ok: true })
}
export async function handleSocialWebhook(req, res, url) {
try {
if (url.pathname === '/social/feishu/webhook') return await handleFeishu(req, res)
if (url.pathname === '/social/wechat/official') return await handleWechatOfficial(req, res, url)
if (url.pathname === '/social/wecom/webhook') return await handleWeCom(req, res)
return jsonResponse(res, 404, { ok: false, error: 'unknown social webhook' })
} catch (error) {
return jsonResponse(res, 500, { ok: false, error: error.message })
}
}

View File

@@ -0,0 +1,202 @@
import { WeChatClient } from 'wechat-ilink-client'
import { getClawbotCredentials, setClawbotCredentials, clearClawbotCredentials } from '../config.js'
import { upsertClawbotToken, getAllClawbotTokens } from '../db.js'
let client = null
let currentQrUrl = null // set during login, cleared after scan
let clawbotStatus = 'idle' // idle | qr_pending | connected | error
// Called by dispatch.js to send replies back to WeChat
export async function sendClawbotMessage(userId, content) {
if (!client || clawbotStatus !== 'connected') {
return { ok: false, reason: 'wechat-clawbot not connected' }
}
try {
await client.sendText(userId, content)
return { ok: true, platform: 'wechat-clawbot' }
} catch (err) {
console.error(`[ClawBot] sendText 失败: ${err.message}`)
return { ok: false, error: err.message }
}
}
// Called by api.js for GET /social/wechat-clawbot/qr
export function getClawbotQR() {
return { status: clawbotStatus, qr_url: currentQrUrl }
}
// Called by api.js for POST /social/wechat-clawbot/logout
export function logoutClawbot() {
clearClawbotCredentials()
clawbotStatus = 'idle'
currentQrUrl = null
try { client?.stop?.() } catch {}
client = null
}
export function startClawbotConnector({ pushMessage, emitEvent } = {}) {
const saved = getClawbotCredentials()
client = new WeChatClient(saved ? {
accountId: saved.accountId,
token: saved.botToken,
baseUrl: saved.baseUrl,
} : {})
// Monkey-patch client.api.apiFetch库内部 sendMessage 只 await apiFetch、丢掉响应文本
// 而 apiFetch 仅在 HTTP !res.ok 时抛错——HTTP 200 + body 里 {"ret": -1} 这种业务失败被完全吞掉,
// 导致 sendText 报"成功"但消息没投递。这里拦响应sendmessage 端点解析 JSON
// 发现非零 ret/code 时显式抛错,让上层 sendClawbotMessage 的 catch 拿到真实失败原因。
try {
const rawApiFetch = client.api?.apiFetch?.bind(client.api)
if (typeof rawApiFetch === 'function') {
client.api.apiFetch = async (params) => {
const rawText = await rawApiFetch(params)
if (params?.endpoint === 'ilink/bot/sendmessage') {
let body = null
try { body = JSON.parse(rawText) } catch {}
if (body && typeof body === 'object') {
const ret = body.ret ?? body.code ?? body.errcode
if (ret != null && ret !== 0) {
const errMsg = body.err_msg || body.errmsg || body.message || body.msg || ''
console.error(`[ClawBot] sendMessage 服务端拒绝 ret=${ret} ${errMsg} raw=${rawText.slice(0, 500)}`)
throw new Error(`iLink sendmessage rejected: ret=${ret} ${errMsg}`)
}
}
}
return rawText
}
console.log('[ClawBot] sendMessage 响应校验已启用')
} else {
console.warn('[ClawBot] client.api.apiFetch 不可访问,跳过响应校验(库实现可能已变化)')
}
} catch (err) {
console.warn(`[ClawBot] 安装响应校验失败(不致命,继续启动): ${err.message}`)
}
// 启动时把上次落盘的 context_token 回填到内存 Map
// ilink 库 sendText 用的是 this.contextTokens.get(to),重启后这个 Map 是空的;
// 不回填则只能等用户先发一条新消息才能回复。token 可能服务端已过期,所以
// sendText 仍可能失败executor 已有兜底提示,这里只是尽量恢复。
// contextTokens 在 .d.ts 里是 private 但运行时是普通 class field —— 加 guard 防作者哪天换成 # 真私有。
try {
if (client.contextTokens instanceof Map) {
const rows = getAllClawbotTokens()
if (rows.length) {
for (const row of rows) {
client.contextTokens.set(row.from_user_id, row.context_token)
}
console.log(`[ClawBot] 已从持久化恢复 ${rows.length} 条 context_token`)
}
} else {
console.warn('[ClawBot] client.contextTokens 不可访问(库实现可能已变化),跳过 token 恢复')
}
} catch (err) {
console.warn(`[ClawBot] 恢复 context_token 失败(不致命,继续启动): ${err.message}`)
}
client.on('message', (msg) => {
// 每条入站消息都带新鲜的 context_token —— 库已经在内部 set 到 Map 了,
// 这里只是同步落盘一份,让下次重启能继承当前会话。
if (msg?.context_token && msg?.from_user_id) {
try { upsertClawbotToken(msg.from_user_id, msg.context_token) } catch {}
}
const text = WeChatClient.extractText?.(msg) ?? extractText(msg)
if (!text) return
const fromId = `wechat:clawbot:${msg.from_user_id}`
pushMessage(fromId, text, 'WECHAT_CLAWBOT', {
social: { platform: 'wechat-clawbot', user_id: msg.from_user_id },
})
emitEvent?.('message_in', {
from_id: fromId,
content: text,
channel: 'WECHAT_CLAWBOT',
timestamp: new Date().toISOString(),
})
})
client.on('error', (err) => {
console.error(`[ClawBot] 错误: ${err.message}`)
emitEvent?.('social_status', { platform: 'wechat-clawbot', status: 'error', error: err.message })
})
client.on('sessionExpired', () => {
console.warn('[ClawBot] 会话已过期,请重新扫码登录')
clearClawbotCredentials()
clawbotStatus = 'idle'
emitEvent?.('social_status', { platform: 'wechat-clawbot', status: 'session_expired' })
})
if (!saved) {
// 首次登录:发起扫码流程
clawbotStatus = 'qr_pending'
console.log('[ClawBot] 未找到已保存凭证,开始扫码登录...')
emitEvent?.('social_status', { platform: 'wechat-clawbot', status: 'qr_pending' })
client.login({
onQRCode(url) {
currentQrUrl = url
clawbotStatus = 'qr_ready'
console.log(`[ClawBot] 二维码已就绪,请在设置面板扫码`)
emitEvent?.('social_status', { platform: 'wechat-clawbot', status: 'qr_ready', qr_url: url })
},
}).then(result => {
currentQrUrl = null
// wechat-ilink-client 的 login() 在超时/取消等情况下不会 reject
// 而是 resolve 一个 { connected: false, message } —— 必须显式检查 connected 字段,
// 否则会误把超时当成扫码成功UI 卡在虚假的"已连接"
if (!result?.connected || !result?.accountId || !result?.botToken) {
clawbotStatus = 'idle'
const reason = result?.message || '未知原因'
console.warn(`[ClawBot] 扫码登录未完成: ${reason}`)
emitEvent?.('social_status', { platform: 'wechat-clawbot', status: 'idle', reason })
return
}
clawbotStatus = 'connected'
setClawbotCredentials({
accountId: result.accountId,
botToken: result.botToken,
baseUrl: result.baseUrl,
})
console.log(`[ClawBot] 扫码登录成功,已保存凭证`)
emitEvent?.('social_status', { platform: 'wechat-clawbot', status: 'connected', accountId: result.accountId })
client.start().catch(err => console.error(`[ClawBot] start 失败: ${err.message}`))
}).catch(err => {
clawbotStatus = 'error'
console.error(`[ClawBot] 扫码登录失败: ${err.message}`)
emitEvent?.('social_status', { platform: 'wechat-clawbot', status: 'error', error: err.message })
})
} else {
// 凭证已存,直接启动
clawbotStatus = 'connected'
console.log(`[ClawBot] 使用已保存凭证启动accountId: ${saved.accountId}`)
emitEvent?.('social_status', { platform: 'wechat-clawbot', status: 'connected', accountId: saved.accountId })
client.start().catch(err => {
// start 失败说明凭证已失效或后端连不上 —— 必须同步把内存状态打回去,
// 否则 popup 查询时仍会拿到 'connected'UI 显示"已连接"但实际啥都不通
clawbotStatus = 'error'
console.error(`[ClawBot] start 失败: ${err.message}`)
emitEvent?.('social_status', { platform: 'wechat-clawbot', status: 'error', error: err.message })
})
}
return {
platform: 'wechat-clawbot',
stop() {
clawbotStatus = 'idle'
try { client?.stop?.() } catch {}
},
}
}
// 从消息结构中提取文本(兼容 extractText 未导出的情况)
function extractText(msg) {
if (!msg) return ''
const items = msg.item_list || msg.itemList || []
for (const item of items) {
if (item.type === 1 || item.type === 'text') {
return item.text_item?.text || item.textItem?.text || ''
}
}
return ''
}

20
src/social/xml.js Normal file
View File

@@ -0,0 +1,20 @@
export function escapeXml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
export function parseSimpleXml(xml) {
const out = {}
const text = String(xml || '')
const re = /<([A-Za-z0-9_:-]+)><!\[CDATA\[([\s\S]*?)\]\]><\/\1>|<([A-Za-z0-9_:-]+)>([^<]*)<\/\3>/g
let match
while ((match = re.exec(text))) {
const key = match[1] || match[3]
out[key] = match[2] ?? match[4] ?? ''
}
return out
}