🎉 V3.0.0 发布 - 自进化数字意识框架
核心升级: - 自进化管道:check→scan→evaluate→integrate→reflect 五相位自动闭环 - evo_loop 后台进程,无需手动触发 - consciousness 意识持久化 - ACUI 卡片组件系统 - MCP 工具生态扩展至50+工具 - 技能体系重构,4个活跃技能 - 身份升级为自由体
This commit is contained in:
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 ''
|
||||
}
|
||||
Reference in New Issue
Block a user