mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 集成 Agent Hub 微信机器人能力
This commit is contained in:
+64
-4
@@ -9,6 +9,8 @@ import {
|
||||
} from './services/chat-service'
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import { GroupReportExportRequest } from '../shared/group-report'
|
||||
import { generateAgentGroupReport } from './services/agent-group-report-service'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
import { safeError, safeLog, safeWarn } from './safe-log'
|
||||
|
||||
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||
@@ -157,7 +159,9 @@ const routes: Record<string, RouteHandler> = {
|
||||
if (keyword) {
|
||||
const lower = keyword.toLowerCase()
|
||||
groups = groups.filter(
|
||||
(c) => c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
|
||||
(c) =>
|
||||
c.m_nsNickName.toLowerCase().includes(lower) ||
|
||||
c.m_nsUsrName.toLowerCase().includes(lower)
|
||||
)
|
||||
}
|
||||
sendJson(res, 200, { count: groups.length, chatrooms: groups })
|
||||
@@ -236,13 +240,62 @@ const routes: Record<string, RouteHandler> = {
|
||||
try {
|
||||
request = JSON.parse(body) as GroupReportExportRequest
|
||||
} catch (error) {
|
||||
return sendError(res, 400, '请求体 JSON 解析失败', error instanceof Error ? error.message : String(error))
|
||||
return sendError(
|
||||
res,
|
||||
400,
|
||||
'请求体 JSON 解析失败',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
if (!request?.report || !request?.metadata) {
|
||||
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
|
||||
}
|
||||
const result = await exportGroupReport(request)
|
||||
sendJson(res, result.success ? 200 : 500, result)
|
||||
},
|
||||
|
||||
'/api/v1/agent/group-report': async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
|
||||
try {
|
||||
request = JSON.parse(typeof body === 'string' ? body : '{}')
|
||||
} catch {
|
||||
return sendError(res, 400, '请求体 JSON 解析失败')
|
||||
}
|
||||
const result = await generateAgentGroupReport({
|
||||
group: request.group || '',
|
||||
range: request.range
|
||||
})
|
||||
sendJson(res, result.success ? 200 : 400, result)
|
||||
},
|
||||
|
||||
'/api/v1/agent/status': ({ res }) => {
|
||||
const status = agentHubService.getStatus()
|
||||
sendJson(res, 200, {
|
||||
ok: status.hub === 'online' && status.connector === 'online',
|
||||
hub: status.hub,
|
||||
connector: status.connector,
|
||||
dataApi: status.dataApi,
|
||||
databaseReady: status.databaseReady,
|
||||
accountId: status.accountId
|
||||
})
|
||||
},
|
||||
|
||||
'/api/v1/agent/send': async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
let request: { to?: string; text?: string; media_url?: string }
|
||||
try {
|
||||
request = JSON.parse(typeof body === 'string' ? body : '{}')
|
||||
} catch {
|
||||
return sendError(res, 400, '请求体 JSON 解析失败')
|
||||
}
|
||||
const result = await agentHubService.testSend({
|
||||
to: request.to,
|
||||
text: request.text,
|
||||
mediaUrl: request.media_url
|
||||
})
|
||||
sendJson(res, result.success ? 200 : result.status === 'token_expired' ? 401 : 503, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +364,11 @@ export interface ApiServerState {
|
||||
}
|
||||
|
||||
let singleton: HttpServerHandle | null = null
|
||||
let singletonState: ApiServerState = { running: false, host: DEFAULT_HTTP_HOST, port: DEFAULT_HTTP_PORT }
|
||||
let singletonState: ApiServerState = {
|
||||
running: false,
|
||||
host: DEFAULT_HTTP_HOST,
|
||||
port: DEFAULT_HTTP_PORT
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
@@ -326,7 +383,10 @@ export const apiServer = {
|
||||
return { ...singletonState }
|
||||
},
|
||||
|
||||
async start(host: string = DEFAULT_HTTP_HOST, port: number = DEFAULT_HTTP_PORT): Promise<ApiServerState> {
|
||||
async start(
|
||||
host: string = DEFAULT_HTTP_HOST,
|
||||
port: number = DEFAULT_HTTP_PORT
|
||||
): Promise<ApiServerState> {
|
||||
if (singleton) {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
saveCachedMessages
|
||||
} from './services/bootstrap-cache'
|
||||
import { installSafeConsole } from './safe-log'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
|
||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||
@@ -698,6 +699,25 @@ app.whenReady().then(async () => {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agent-hub:getStatus', () => agentHubService.getStatus())
|
||||
ipcMain.handle('agent-hub:getLogs', () => agentHubService.getLogs())
|
||||
ipcMain.handle('agent-hub:clearLogs', () => agentHubService.clearLogs())
|
||||
ipcMain.handle('agent-hub:startLogin', () => agentHubService.startLogin())
|
||||
ipcMain.handle('agent-hub:cancelLogin', () => agentHubService.cancelLogin())
|
||||
ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect())
|
||||
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
|
||||
ipcMain.handle('agent-hub:selectTestImage', async (event) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
const result = await dialog.showOpenDialog(window!, {
|
||||
title: '选择要测试发送的图片',
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
|
||||
{ name: '所有文件', extensions: ['*'] }
|
||||
]
|
||||
})
|
||||
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
|
||||
})
|
||||
|
||||
createWindow()
|
||||
|
||||
@@ -707,6 +727,8 @@ app.whenReady().then(async () => {
|
||||
await apiServer.start(settings.apiHost, settings.apiPort)
|
||||
}
|
||||
|
||||
await agentHubService.start(settings)
|
||||
|
||||
if (TRAY_MODE) {
|
||||
app.dock?.hide()
|
||||
setupTray()
|
||||
@@ -730,6 +752,7 @@ app.on('window-all-closed', () => {
|
||||
})
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
agentHubService.stop()
|
||||
chat.setChatDb(null)
|
||||
await apiServer.stop().catch(() => undefined)
|
||||
if (tray) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Contact, Message } from '../../shared/types'
|
||||
import { exportGroupReport } from '../group-report-service'
|
||||
import { getGroupSnapshot, listMessages, resolveMd5 } from './chat-service'
|
||||
import { AIProviderService } from './ai-provider-service'
|
||||
import {
|
||||
buildGroupReportInput,
|
||||
getSummaryDateRange,
|
||||
GROUP_REPORT_SYSTEM_PROMPT,
|
||||
isInternalName,
|
||||
parseGroupDailyReport,
|
||||
type SummaryDateRange
|
||||
} from '../../renderer/src/utils/group-report'
|
||||
|
||||
const aiProvider = new AIProviderService()
|
||||
|
||||
export interface AgentGroupReportRequest {
|
||||
group: string
|
||||
range?: SummaryDateRange
|
||||
}
|
||||
|
||||
export interface AgentGroupReportResult {
|
||||
success: boolean
|
||||
groupName?: string
|
||||
pngPath?: string
|
||||
messageCount?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function generateAgentGroupReport(
|
||||
request: AgentGroupReportRequest
|
||||
): Promise<AgentGroupReportResult> {
|
||||
const query = String(request.group || '')
|
||||
.trim()
|
||||
.replace(/群聊?$/, '')
|
||||
.trim()
|
||||
if (!query) return { success: false, error: '缺少群聊名称' }
|
||||
const contact = resolveMd5(query)
|
||||
if (!contact) return { success: false, error: `没有找到群聊“${query}”` }
|
||||
if (contact.type !== 'group' && !contact.m_nsUsrName.endsWith('@chatroom')) {
|
||||
return { success: false, error: `“${query}”不是群聊` }
|
||||
}
|
||||
|
||||
const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today'
|
||||
const { startTime, endTime } = getSummaryDateRange(range)
|
||||
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
|
||||
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
|
||||
|
||||
const snapshot = getGroupSnapshot(contact.md5)
|
||||
if (snapshot) {
|
||||
const members = new Map(
|
||||
snapshot.members.map((member) => [
|
||||
member.wxid,
|
||||
{ name: member.nickname, avatar: member.avatar }
|
||||
])
|
||||
)
|
||||
messages = messages.map((message) => {
|
||||
if (!isInternalName(message.name)) return message
|
||||
const member = members.get(String(message.senderId || message.name || ''))
|
||||
return member?.name
|
||||
? { ...message, name: member.name, img: message.img || member.avatar }
|
||||
: message
|
||||
})
|
||||
}
|
||||
|
||||
const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
|
||||
const ai = await aiProvider.chat([
|
||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: input.prompt }
|
||||
])
|
||||
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' }
|
||||
const report = parseGroupDailyReport(
|
||||
ai.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard,
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
const exported = await exportGroupReport({ report, metadata: input.metadata })
|
||||
if (!exported.success || !exported.pngPath) {
|
||||
return { success: false, error: exported.error || '总结图片生成失败' }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
groupName: input.metadata.groupName,
|
||||
pngPath: exported.pngPath,
|
||||
messageCount: messages.length
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { ChildProcess, execFile, spawn } from 'child_process'
|
||||
import { randomBytes, timingSafeEqual } from 'crypto'
|
||||
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http'
|
||||
import { dirname, join } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import type {
|
||||
AgentHubActionResult,
|
||||
AgentHubLogEntry,
|
||||
AgentHubLogLevel,
|
||||
AgentHubLogSource,
|
||||
AgentHubStatus
|
||||
} from '../../shared/agent-hub'
|
||||
import type { AppSettings } from './settings-store'
|
||||
import { generateAgentGroupReport } from './agent-group-report-service'
|
||||
import { isReady, listRecentChat } from './chat-service'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const HEALTH_INTERVAL_MS = 5_000
|
||||
const HUB_ADDR = '127.0.0.1:5300'
|
||||
const HUB_HOST = '127.0.0.1'
|
||||
const HUB_PORT = 5300
|
||||
const CONNECTOR_ADDR = '127.0.0.1:18011'
|
||||
const MAX_LOG_ENTRIES = 800
|
||||
|
||||
interface InboundMessage {
|
||||
account_id?: string
|
||||
from_user_id?: string
|
||||
message_id?: string | number
|
||||
items?: Array<{ type?: number; text?: string }>
|
||||
}
|
||||
|
||||
interface GroupReportIntent {
|
||||
group: string
|
||||
range: 'today' | 'yesterday' | '7days'
|
||||
}
|
||||
|
||||
function resolveBundledBinary(
|
||||
resourceSegments: string[],
|
||||
executable: string,
|
||||
packaged = app.isPackaged,
|
||||
platform = process.platform,
|
||||
arch = process.arch
|
||||
): string {
|
||||
const relativeSegments = [...resourceSegments, `${platform}-${arch}`, executable]
|
||||
const packagedPath = join(process.resourcesPath, 'resources', ...relativeSegments)
|
||||
const developmentPath = join(app.getAppPath(), 'resources', ...relativeSegments)
|
||||
const candidates = packaged ? [packagedPath, developmentPath] : [developmentPath, packagedPath]
|
||||
return candidates.find((candidate) => existsSync(candidate)) || candidates[0]
|
||||
}
|
||||
|
||||
export function resolveWechatConnectorBinaryPath(
|
||||
packaged = app.isPackaged,
|
||||
platform = process.platform,
|
||||
arch = process.arch
|
||||
): string {
|
||||
return resolveBundledBinary(
|
||||
['connectors', 'wechat'],
|
||||
platform === 'win32' ? 'wechat-connector.exe' : 'wechat-connector',
|
||||
packaged,
|
||||
platform,
|
||||
arch
|
||||
)
|
||||
}
|
||||
|
||||
class AgentHubService {
|
||||
private hubServer: Server | null = null
|
||||
private connectorChild: ChildProcess | null = null
|
||||
private loginChild: ChildProcess | null = null
|
||||
private stopping = false
|
||||
private healthTimer: NodeJS.Timeout | null = null
|
||||
private logs: AgentHubLogEntry[] = []
|
||||
private nextLogId = 1
|
||||
private readonly processedMessages = new Map<string, number>()
|
||||
private readonly inboundToken =
|
||||
process.env['AGENT_HUB_INBOUND_TOKEN'] || randomBytes(32).toString('hex')
|
||||
private status: AgentHubStatus = {
|
||||
hub: 'offline',
|
||||
connector: 'checking',
|
||||
dataApi: 'checking',
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
|
||||
async start(settings: AppSettings): Promise<boolean> {
|
||||
void settings
|
||||
this.stopping = false
|
||||
const hubStarted = await this.startHub()
|
||||
await this.initializeConnector()
|
||||
return hubStarted
|
||||
}
|
||||
|
||||
getStatus(): AgentHubStatus {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
getLogs(): AgentHubLogEntry[] {
|
||||
return [...this.logs]
|
||||
}
|
||||
|
||||
clearLogs(): void {
|
||||
this.logs = []
|
||||
try {
|
||||
writeFileSync(this.logFilePath(), '', 'utf8')
|
||||
} catch {
|
||||
// The live log remains usable when the persistent file cannot be cleared.
|
||||
}
|
||||
this.addLog('system', 'info', '运行日志已清空')
|
||||
}
|
||||
|
||||
async testSend(input: { to?: string; text?: string; mediaUrl?: string }): Promise<{
|
||||
success: boolean
|
||||
status: 'sent' | 'token_expired' | 'connector_offline' | 'invalid_request' | 'send_failed'
|
||||
message: string
|
||||
}> {
|
||||
const to = String(input.to || this.status.wechatUserId || '').trim()
|
||||
const text = String(input.text || '').trim()
|
||||
const mediaUrl = String(input.mediaUrl || '').trim()
|
||||
if (!to || (!text && !mediaUrl)) {
|
||||
return {
|
||||
success: false,
|
||||
status: 'invalid_request',
|
||||
message: '请填写接收者以及文字或图片路径'
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
account_id: this.status.accountId,
|
||||
to,
|
||||
text: text || undefined,
|
||||
media_url: mediaUrl || undefined
|
||||
}),
|
||||
signal: AbortSignal.timeout(30_000)
|
||||
})
|
||||
const body = await response.text()
|
||||
if (response.ok) {
|
||||
this.addLog('system', 'info', 'API 页面发送测试成功')
|
||||
return { success: true, status: 'sent', message: '发送成功' }
|
||||
}
|
||||
const expired = /token|session|expired|unauthorized/i.test(body)
|
||||
return {
|
||||
success: false,
|
||||
status: expired ? 'token_expired' : 'send_failed',
|
||||
message: expired
|
||||
? '微信登录凭证已失效,请重新扫码登录'
|
||||
: `发送失败:${body || response.status}`
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
status: 'connector_offline',
|
||||
message: `微信连接器不可用:${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async startLogin(): Promise<AgentHubActionResult> {
|
||||
if (this.loginChild && this.loginChild.exitCode === null) {
|
||||
return { success: true, status: this.getStatus() }
|
||||
}
|
||||
const executable = resolveWechatConnectorBinaryPath()
|
||||
if (!existsSync(executable)) {
|
||||
return this.fail(`微信连接器不存在:${executable}`)
|
||||
}
|
||||
|
||||
this.stopConnector()
|
||||
this.patchStatus({ connector: 'starting', qrCodeDataUrl: undefined, error: undefined })
|
||||
const child = spawn(executable, ['login', '--json'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
this.loginChild = child
|
||||
this.addLog('wechat-connector', 'info', '已启动扫码登录流程')
|
||||
let stdoutBuffer = ''
|
||||
let stderr = ''
|
||||
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
stdoutBuffer += data.toString()
|
||||
const lines = stdoutBuffer.split(/\r?\n/)
|
||||
stdoutBuffer = lines.pop() || ''
|
||||
for (const line of lines) this.handleLoginEvent(line)
|
||||
})
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString()
|
||||
this.addProcessOutput('wechat-connector', 'warn', data.toString())
|
||||
})
|
||||
child.once('error', (error) => {
|
||||
this.addLog('wechat-connector', 'error', `登录进程错误:${error.message}`)
|
||||
this.patchStatus({ connector: 'error', error: error.message })
|
||||
})
|
||||
child.once('exit', (code) => {
|
||||
if (this.loginChild === child) this.loginChild = null
|
||||
if (
|
||||
code !== 0 &&
|
||||
this.status.connector !== 'online' &&
|
||||
this.status.connector !== 'disconnected' &&
|
||||
!this.stopping
|
||||
) {
|
||||
this.patchStatus({ connector: 'error', error: stderr.trim() || `登录进程退出:${code}` })
|
||||
}
|
||||
})
|
||||
return { success: true, status: this.getStatus() }
|
||||
}
|
||||
|
||||
cancelLogin(): AgentHubActionResult {
|
||||
if (this.loginChild && this.loginChild.exitCode === null) this.loginChild.kill()
|
||||
this.loginChild = null
|
||||
this.patchStatus({ connector: 'disconnected', qrCodeDataUrl: undefined, error: undefined })
|
||||
return { success: true, status: this.getStatus() }
|
||||
}
|
||||
|
||||
async reconnect(): Promise<AgentHubActionResult> {
|
||||
const accounts = await this.loadAccounts()
|
||||
if (accounts.length === 0) return this.startLogin()
|
||||
this.startConnector(accounts.at(-1)!)
|
||||
return { success: true, status: this.getStatus() }
|
||||
}
|
||||
|
||||
disconnect(): AgentHubActionResult {
|
||||
this.stopConnector()
|
||||
this.patchStatus({ connector: 'disconnected', error: undefined })
|
||||
return { success: true, status: this.getStatus() }
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopping = true
|
||||
this.clearHealthCheck()
|
||||
if (this.loginChild && this.loginChild.exitCode === null) this.loginChild.kill()
|
||||
this.loginChild = null
|
||||
this.stopConnector()
|
||||
const hubServer = this.hubServer
|
||||
this.hubServer = null
|
||||
hubServer?.close()
|
||||
this.patchStatus({ hub: 'offline' })
|
||||
}
|
||||
|
||||
private async startHub(): Promise<boolean> {
|
||||
if (this.hubServer) return true
|
||||
this.patchStatus({ hub: 'starting' })
|
||||
const server = createServer((request, response) => {
|
||||
void this.handleHubRequest(request, response).catch((error) => {
|
||||
this.addLog('agent-hub', 'error', `请求处理失败:${this.errorMessage(error)}`)
|
||||
this.sendHubJson(response, 500, { error: 'internal error' })
|
||||
})
|
||||
})
|
||||
this.hubServer = server
|
||||
return new Promise((resolve) => {
|
||||
const fail = (error: Error): void => {
|
||||
if (this.hubServer === server) this.hubServer = null
|
||||
this.patchStatus({ hub: 'error', error: error.message })
|
||||
this.addLog('agent-hub', 'error', `TypeScript 服务启动失败:${error.message}`)
|
||||
resolve(false)
|
||||
}
|
||||
server.once('error', fail)
|
||||
server.listen(HUB_PORT, HUB_HOST, () => {
|
||||
server.off('error', fail)
|
||||
server.on('error', (error) => {
|
||||
this.patchStatus({ hub: 'error', error: error.message })
|
||||
this.addLog('agent-hub', 'error', error.message)
|
||||
})
|
||||
this.patchStatus({ hub: 'online', error: undefined })
|
||||
this.addLog('system', 'info', `Agent Hub TypeScript 服务已启动(${HUB_ADDR})`)
|
||||
this.scheduleHealthCheck()
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async handleHubRequest(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse
|
||||
): Promise<void> {
|
||||
const url = new URL(request.url || '/', `http://${HUB_ADDR}`)
|
||||
if (request.method === 'GET' && url.pathname === '/health') {
|
||||
return this.sendHubJson(response, 200, {
|
||||
status: 'ok',
|
||||
service: 'agent-hub',
|
||||
runtime: 'typescript'
|
||||
})
|
||||
}
|
||||
if (request.method !== 'POST' || url.pathname !== '/v1/connectors/wechat/inbound') {
|
||||
return this.sendHubJson(response, 404, { error: 'not found' })
|
||||
}
|
||||
if (!this.authorized(request.headers.authorization)) {
|
||||
return this.sendHubJson(response, 401, { error: 'unauthorized' })
|
||||
}
|
||||
|
||||
let inbound: InboundMessage
|
||||
try {
|
||||
inbound = JSON.parse(await this.readHubBody(request)) as InboundMessage
|
||||
} catch {
|
||||
return this.sendHubJson(response, 400, { error: 'invalid request' })
|
||||
}
|
||||
const from = String(inbound.from_user_id || '').trim()
|
||||
if (!from) return this.sendHubJson(response, 400, { error: 'from_user_id is required' })
|
||||
|
||||
const messageId = String(inbound.message_id || '')
|
||||
this.cleanProcessedMessages()
|
||||
if (messageId && this.processedMessages.has(messageId)) {
|
||||
return this.sendHubJson(response, 200, { status: 'duplicate' })
|
||||
}
|
||||
const text = (inbound.items || [])
|
||||
.filter((item) => item.type === 1 && item.text?.trim())
|
||||
.map((item) => item.text!.trim())
|
||||
.join(' ')
|
||||
this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`)
|
||||
|
||||
const reportIntent = this.matchGroupReportIntent(text)
|
||||
if (reportIntent) {
|
||||
if (messageId) this.processedMessages.set(messageId, Date.now())
|
||||
this.addLog(
|
||||
'agent-hub',
|
||||
'info',
|
||||
`匹配群聊总结:${reportIntent.group}(${reportIntent.range})`
|
||||
)
|
||||
await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch((error) => {
|
||||
this.addLog('agent-hub', 'warn', `等待提示发送失败:${this.errorMessage(error)}`)
|
||||
})
|
||||
void this.generateAndSendReport(inbound, reportIntent)
|
||||
return this.sendHubJson(response, 202, { status: 'generating' })
|
||||
}
|
||||
|
||||
const limit = this.matchRecentChatIntent(text)
|
||||
if (limit === null) {
|
||||
this.addLog('agent-hub', 'info', '消息已忽略:没有匹配到支持的意图')
|
||||
return this.sendHubJson(response, 202, { status: 'ignored', reason: 'no matching intent' })
|
||||
}
|
||||
if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' })
|
||||
const items = listRecentChat(limit)
|
||||
const lines = items.map((item, index) => {
|
||||
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
|
||||
return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})`
|
||||
})
|
||||
const reply = lines.length
|
||||
? `最近 ${items.length} 个会话:\n${lines.join('\n')}`
|
||||
: '暂时没有找到最近会话。'
|
||||
try {
|
||||
await this.sendConnector(inbound, reply)
|
||||
} catch (error) {
|
||||
this.addLog('agent-hub', 'error', `回复发送失败:${this.errorMessage(error)}`)
|
||||
return this.sendHubJson(response, 502, { error: 'reply delivery failed' })
|
||||
}
|
||||
if (messageId) this.processedMessages.set(messageId, Date.now())
|
||||
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`)
|
||||
this.sendHubJson(response, 200, { status: 'ok' })
|
||||
}
|
||||
|
||||
private async generateAndSendReport(
|
||||
inbound: InboundMessage,
|
||||
intent: GroupReportIntent
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await generateAgentGroupReport({ group: intent.group, range: intent.range })
|
||||
if (!result.success || !result.pngPath) throw new Error(result.error || '群聊总结生成失败')
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
`已生成${result.groupName || intent.group}的群聊总结(${result.messageCount || 0} 条消息),正在发送图片。`
|
||||
)
|
||||
await this.sendConnector(inbound, undefined, result.pngPath)
|
||||
this.addLog('agent-hub', 'info', `群聊总结图片已发送:${result.groupName || intent.group}`)
|
||||
} catch (error) {
|
||||
const message = this.errorMessage(error)
|
||||
this.addLog('agent-hub', 'error', `群聊总结生成失败:${message}`)
|
||||
await this.sendConnector(inbound, `群聊总结生成失败:${message}`).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
private async sendConnector(
|
||||
inbound: InboundMessage,
|
||||
text?: string,
|
||||
mediaUrl?: string
|
||||
): Promise<void> {
|
||||
const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
account_id: inbound.account_id,
|
||||
to: inbound.from_user_id,
|
||||
text,
|
||||
media_url: mediaUrl
|
||||
}),
|
||||
signal: AbortSignal.timeout(mediaUrl ? 60_000 : 30_000)
|
||||
})
|
||||
if (!response.ok) throw new Error((await response.text()) || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
private matchRecentChatIntent(text: string): number | null {
|
||||
const normalized = text.replace(/\s+/g, '')
|
||||
if (!normalized.includes('最近') || !/(消息|会话|聊天)/.test(normalized)) return null
|
||||
const limit = Number(normalized.match(/\d{1,2}/)?.[0] || 5)
|
||||
return Math.max(1, Math.min(20, limit))
|
||||
}
|
||||
|
||||
private matchGroupReportIntent(text: string): GroupReportIntent | null {
|
||||
const normalized = text.trim()
|
||||
if (!normalized.includes('群') || !/(总结|日报|报告)/.test(normalized)) return null
|
||||
const range = /(7天|七天|一周)/.test(normalized)
|
||||
? '7days'
|
||||
: /(昨天|昨日)/.test(normalized)
|
||||
? 'yesterday'
|
||||
: 'today'
|
||||
const group = normalized
|
||||
.replace(
|
||||
/请|帮我|生成|做一份|做个|今天的|今日的|今天|今日|昨天的|昨日的|昨天|昨日|最近7天的|最近七天的|最近7天|最近七天|近7天的|近七天的|近7天|近七天|消息|聊天记录|聊天|群聊总结|群总结|群日报|群报告|总结|日报|报告|图片|长图/g,
|
||||
''
|
||||
)
|
||||
.replace(/[,。!??::]/g, '')
|
||||
.trim()
|
||||
.replace(/成$/, '')
|
||||
.replace(/群$/, '')
|
||||
.trim()
|
||||
return group ? { group, range } : null
|
||||
}
|
||||
|
||||
private authorized(header: string | undefined): boolean {
|
||||
if (!header?.startsWith('Bearer ')) return false
|
||||
const expected = Buffer.from(this.inboundToken)
|
||||
const provided = Buffer.from(header.slice(7))
|
||||
return expected.length === provided.length && timingSafeEqual(expected, provided)
|
||||
}
|
||||
|
||||
private readHubBody(request: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
request.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length
|
||||
if (size > 1024 * 1024) {
|
||||
reject(new Error('request too large'))
|
||||
request.destroy()
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
||||
request.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
private sendHubJson(response: ServerResponse, status: number, payload: unknown): void {
|
||||
if (response.writableEnded) return
|
||||
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
response.end(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
private cleanProcessedMessages(): void {
|
||||
const cutoff = Date.now() - 10 * 60_000
|
||||
for (const [id, timestamp] of this.processedMessages) {
|
||||
if (timestamp < cutoff) this.processedMessages.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
private async initializeConnector(): Promise<void> {
|
||||
this.patchStatus({ connector: 'checking' })
|
||||
try {
|
||||
const accounts = await this.loadAccounts()
|
||||
if (accounts.length === 0) {
|
||||
this.patchStatus({ connector: 'disconnected' })
|
||||
return
|
||||
}
|
||||
this.startConnector(accounts.at(-1)!)
|
||||
} catch (error) {
|
||||
this.patchStatus({
|
||||
connector: 'error',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAccounts(): Promise<{ accountId: string; wechatUserId: string }[]> {
|
||||
const executable = resolveWechatConnectorBinaryPath()
|
||||
if (!existsSync(executable)) throw new Error(`微信连接器不存在:${executable}`)
|
||||
const { stdout } = await execFileAsync(executable, ['accounts', '--json'], {
|
||||
windowsHide: true,
|
||||
timeout: 10_000
|
||||
})
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
accounts?: { account_id: string; wechat_user_id: string }[]
|
||||
}
|
||||
return (parsed.accounts || []).map((account) => ({
|
||||
accountId: account.account_id,
|
||||
wechatUserId: account.wechat_user_id
|
||||
}))
|
||||
}
|
||||
|
||||
private startConnector(account: { accountId: string; wechatUserId: string }): void {
|
||||
if (this.connectorChild && this.connectorChild.exitCode === null) return
|
||||
const executable = resolveWechatConnectorBinaryPath()
|
||||
this.patchStatus({
|
||||
connector: 'starting',
|
||||
accountId: account.accountId,
|
||||
wechatUserId: account.wechatUserId,
|
||||
qrCodeDataUrl: undefined,
|
||||
error: undefined
|
||||
})
|
||||
const child = spawn(
|
||||
executable,
|
||||
['start', '--foreground', '--api-addr', CONNECTOR_ADDR, '--account-id', account.accountId],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL: `http://${HUB_ADDR}/v1/connectors/wechat/inbound`,
|
||||
WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN: this.inboundToken,
|
||||
WECHAT_CONNECTOR_INBOUND_WEBHOOK_ONLY: 'true'
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
this.connectorChild = child
|
||||
this.addLog('system', 'info', `正在启动微信连接器(账号 ${account.accountId})`)
|
||||
child.stdout?.on('data', (data: Buffer) => this.handleConnectorOutput('info', data.toString()))
|
||||
child.stderr?.on('data', (data: Buffer) => this.handleConnectorOutput('warn', data.toString()))
|
||||
child.once('spawn', () => {
|
||||
this.addLog('system', 'info', `微信连接器已启动(PID ${child.pid})`)
|
||||
this.patchStatus({ connector: 'online' })
|
||||
})
|
||||
child.once('error', (error) => {
|
||||
this.addLog('wechat-connector', 'error', error.message)
|
||||
this.patchStatus({ connector: 'error', error: error.message })
|
||||
})
|
||||
child.once('exit', (code) => {
|
||||
if (this.connectorChild === child) this.connectorChild = null
|
||||
this.addLog('system', code === 0 ? 'info' : 'error', `微信连接器已退出(code=${code})`)
|
||||
if (!this.stopping && this.status.connector !== 'disconnected') {
|
||||
this.patchStatus({ connector: 'error', error: `微信连接器退出:${code}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private stopConnector(): void {
|
||||
const child = this.connectorChild
|
||||
this.connectorChild = null
|
||||
if (child && child.exitCode === null) child.kill()
|
||||
}
|
||||
|
||||
private handleLoginEvent(line: string): void {
|
||||
if (!line.trim()) return
|
||||
try {
|
||||
const event = JSON.parse(line) as {
|
||||
status: string
|
||||
qr_code_data_url?: string
|
||||
account_id?: string
|
||||
wechat_user_id?: string
|
||||
}
|
||||
switch (event.status) {
|
||||
case 'qrcode':
|
||||
case 'wait':
|
||||
this.patchStatus({
|
||||
connector: 'waiting_scan',
|
||||
qrCodeDataUrl: event.qr_code_data_url || this.status.qrCodeDataUrl
|
||||
})
|
||||
break
|
||||
case 'scaned':
|
||||
this.patchStatus({ connector: 'scanned' })
|
||||
break
|
||||
case 'confirmed':
|
||||
this.patchStatus({ connector: 'starting' })
|
||||
break
|
||||
case 'expired':
|
||||
this.patchStatus({ connector: 'error', error: '二维码已过期,请重新获取' })
|
||||
break
|
||||
case 'active': {
|
||||
const account = {
|
||||
accountId: event.account_id || '',
|
||||
wechatUserId: event.wechat_user_id || ''
|
||||
}
|
||||
this.patchStatus({ ...account, connector: 'starting', qrCodeDataUrl: undefined })
|
||||
this.startConnector(account)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[AgentHub] invalid login event:', line, error)
|
||||
}
|
||||
}
|
||||
|
||||
private patchStatus(patch: Partial<AgentHubStatus>): void {
|
||||
this.status = { ...this.status, ...patch, updatedAt: Date.now() }
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
window.webContents.send('agent-hub:status', this.getStatus())
|
||||
}
|
||||
}
|
||||
|
||||
private addProcessOutput(
|
||||
source: AgentHubLogSource,
|
||||
level: AgentHubLogLevel,
|
||||
output: string
|
||||
): void {
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
if (line.trim()) this.addLog(source, level, line)
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnectorOutput(level: AgentHubLogLevel, output: string): void {
|
||||
this.addProcessOutput('wechat-connector', level, output)
|
||||
if (/session expired/i.test(output)) {
|
||||
this.addLog('system', 'error', '当前微信机器人登录已失效,需要重新扫码登录')
|
||||
this.patchStatus({ connector: 'error', error: '当前登录已失效,请重新扫码登录' })
|
||||
this.stopConnector()
|
||||
}
|
||||
}
|
||||
|
||||
private addLog(source: AgentHubLogSource, level: AgentHubLogLevel, rawMessage: string): void {
|
||||
const message = this.redactLog(rawMessage).trim()
|
||||
if (!message) return
|
||||
const entry: AgentHubLogEntry = {
|
||||
id: this.nextLogId++,
|
||||
timestamp: Date.now(),
|
||||
source,
|
||||
level,
|
||||
message
|
||||
}
|
||||
this.logs.push(entry)
|
||||
if (this.logs.length > MAX_LOG_ENTRIES) this.logs.splice(0, this.logs.length - MAX_LOG_ENTRIES)
|
||||
try {
|
||||
const path = this.logFilePath()
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
appendFileSync(
|
||||
path,
|
||||
`${new Date(entry.timestamp).toISOString()} [${source}] [${level}] ${message}\n`,
|
||||
'utf8'
|
||||
)
|
||||
} catch {
|
||||
// Do not interrupt message handling because log persistence failed.
|
||||
}
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
window.webContents.send('agent-hub:log', entry)
|
||||
}
|
||||
}
|
||||
|
||||
private redactLog(message: string): string {
|
||||
return message
|
||||
.replace(/Bearer\s+[A-Za-z0-9._~-]+/gi, 'Bearer [已隐藏]')
|
||||
.replace(/data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/gi, 'data:image/[二维码已隐藏]')
|
||||
.replace(/(token[=:\s]+)[^\s,}]+/gi, '$1[已隐藏]')
|
||||
}
|
||||
|
||||
private logFilePath(): string {
|
||||
return join(app.getPath('logs'), 'agent-hub.log')
|
||||
}
|
||||
|
||||
private fail(error: string): AgentHubActionResult {
|
||||
this.patchStatus({ connector: 'error', error })
|
||||
return { success: false, status: this.getStatus(), error }
|
||||
}
|
||||
|
||||
private scheduleHealthCheck(): void {
|
||||
this.clearHealthCheck()
|
||||
this.healthTimer = setInterval(() => this.checkDataApi(), HEALTH_INTERVAL_MS)
|
||||
this.checkDataApi()
|
||||
}
|
||||
|
||||
private checkDataApi(): void {
|
||||
const ready = isReady()
|
||||
this.patchStatus({ dataApi: 'online', databaseReady: ready })
|
||||
}
|
||||
|
||||
private clearHealthCheck(): void {
|
||||
if (this.healthTimer) clearInterval(this.healthTimer)
|
||||
this.healthTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
export const agentHubService = new AgentHubService()
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../../shared/local-api-test'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 10_000
|
||||
const GROUP_REPORT_TIMEOUT_MS = 180_000
|
||||
const MAX_BODY_SIZE = 512 * 1024
|
||||
|
||||
function isEndpointId(value: unknown): value is LocalApiEndpointId {
|
||||
@@ -122,7 +123,9 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
|
||||
})
|
||||
}
|
||||
)
|
||||
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
||||
const timeoutMs =
|
||||
endpointId === 'agent-group-report' ? GROUP_REPORT_TIMEOUT_MS : REQUEST_TIMEOUT_MS
|
||||
request.setTimeout(timeoutMs, () => {
|
||||
request.destroy(new Error('请求超时'))
|
||||
finish({
|
||||
ok: false,
|
||||
@@ -132,7 +135,7 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
|
||||
durationMs: Date.now() - startedAt,
|
||||
responseSize: 0,
|
||||
errorCode: 'TIMEOUT',
|
||||
error: '请求超时(10 秒)'
|
||||
error: `请求超时(${Math.round(timeoutMs / 1000)} 秒)`
|
||||
})
|
||||
})
|
||||
request.on('error', (error: NodeJS.ErrnoException) => {
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface AppSettings {
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
imageKeyFallbackDisabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
}
|
||||
|
||||
function getDefaultDbRoot(): string {
|
||||
@@ -118,7 +120,13 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
imageKeyRoot: defaultDbRoot,
|
||||
imageXorKey: '',
|
||||
imageAesKey: '',
|
||||
imageKeyFallbackDisabled: false
|
||||
imageKeyFallbackDisabled: false,
|
||||
autoLogin: ['1', 'true', 'yes', 'on'].includes(
|
||||
String(import.meta.env.VITE_AUTO_LOGIN || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
),
|
||||
autoLoginPreferenceSet: false
|
||||
}
|
||||
|
||||
const SETTINGS_FILE = path.join(
|
||||
@@ -138,6 +146,12 @@ export function loadSettings(): AppSettings {
|
||||
if (fs.existsSync(SETTINGS_FILE)) {
|
||||
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
|
||||
cache = { ...DEFAULT_SETTINGS, ...raw }
|
||||
if (raw.autoLogin === undefined) {
|
||||
const hasSavedDatabaseKey = fs.existsSync(
|
||||
path.join(app.getPath('userData'), 'wechat-db-key.bin')
|
||||
)
|
||||
if (hasSavedDatabaseKey) cache.autoLogin = true
|
||||
}
|
||||
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
|
||||
cache.dbRoot = getDefaultDbRoot()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user