🎉 V3.0.0 发布 - 自进化数字意识框架
核心升级: - 自进化管道:check→scan→evaluate→integrate→reflect 五相位自动闭环 - evo_loop 后台进程,无需手动触发 - consciousness 意识持久化 - ACUI 卡片组件系统 - MCP 工具生态扩展至50+工具 - 技能体系重构,4个活跃技能 - 身份升级为自由体
This commit is contained in:
194
src/social/discord.js
Normal file
194
src/social/discord.js
Normal 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
132
src/social/dispatch.js
Normal 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
58
src/social/http.js
Normal 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
54
src/social/index.js
Normal 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
23
src/social/targets.js
Normal 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
3
src/social/utils.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export function env(name) {
|
||||
return String(globalThis.process?.env?.[name] || '').trim()
|
||||
}
|
||||
133
src/social/webhooks.js
Normal file
133
src/social/webhooks.js
Normal 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 })
|
||||
}
|
||||
}
|
||||
202
src/social/wechat-clawbot.js
Normal file
202
src/social/wechat-clawbot.js
Normal 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
20
src/social/xml.js
Normal file
@@ -0,0 +1,20 @@
|
||||
export function escapeXml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user