Files
WechatExplorer/src/main/services/agent-hub-service.ts
T

1163 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { AIProviderService } from './ai-provider-service'
import {
getGroupSnapshot,
isReady,
listContacts,
listMessages,
listRecentChat,
resolveMd5
} 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'
}
interface ContactChatIntent {
contact: string
limit: number
summarize: boolean
}
interface GroupMemberChatIntent {
group: string
member: string
range: 'today' | 'yesterday' | '7days'
days: number
goal: string
}
interface NaturalLanguageResult {
command?: string
reply?: string
}
const agentAIProvider = new AIProviderService()
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 groupMemberIntent = this.matchGroupMemberChatIntent(text)
if (groupMemberIntent) {
if (messageId) this.processedMessages.set(messageId, Date.now())
void this.summarizeGroupMemberChat(inbound, groupMemberIntent)
return this.sendHubJson(response, 202, { status: 'generating', mode: 'group-member-summary' })
}
const contactChatIntent = this.matchContactChatIntent(text)
if (contactChatIntent) {
if (messageId) this.processedMessages.set(messageId, Date.now())
if (contactChatIntent.summarize) {
void this.summarizeContactChat(inbound, contactChatIntent)
return this.sendHubJson(response, 202, { status: 'generating', mode: 'contact-summary' })
}
await this.replyContactChat(inbound, contactChatIntent)
return this.sendHubJson(response, 200, { status: 'ok' })
}
const recentChatLimit = this.matchRecentChatIntent(text)
if (recentChatLimit === null && text.trim()) {
if (messageId) this.processedMessages.set(messageId, Date.now())
void this.handleNaturalLanguage(inbound, text)
return this.sendHubJson(response, 202, {
status: 'processing',
mode: 'natural-language'
})
}
if (recentChatLimit === 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(recentChatLimit)
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 handleNaturalLanguage(inbound: InboundMessage, text: string): Promise<void> {
try {
const result = await this.resolveNaturalLanguage(text)
if (result.reply) {
await this.sendConnector(inbound, this.formatAIReply(result.reply))
this.addLog('agent-hub', 'info', '自然语言回复已发送')
return
}
if (!result.command) {
await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。')
return
}
this.addLog('agent-hub', 'info', `自然语言已理解为:${result.command}`)
const reportIntent = this.matchGroupReportIntent(result.command)
if (reportIntent) {
await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch(() => undefined)
await this.generateAndSendReport(inbound, reportIntent)
return
}
const groupMemberIntent = this.matchGroupMemberChatIntent(result.command)
if (groupMemberIntent) {
await this.summarizeGroupMemberChat(inbound, groupMemberIntent)
return
}
const contactIntent = this.matchContactChatIntent(result.command)
if (contactIntent) {
if (contactIntent.summarize) await this.summarizeContactChat(inbound, contactIntent)
else await this.replyContactChat(inbound, contactIntent)
return
}
const recentLimit = this.matchRecentChatIntent(result.command)
if (recentLimit !== null) {
await this.replyRecentChats(inbound, recentLimit)
return
}
await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。')
} catch (error) {
this.addLog('agent-hub', 'error', `自然语言处理失败:${this.errorMessage(error)}`)
await this.sendConnector(inbound, `处理失败:${this.errorMessage(error)}`).catch(
() => undefined
)
}
}
private async replyRecentChats(inbound: InboundMessage, limit: number): Promise<void> {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
return
}
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' ? '群聊' : '联系人'}`
})
await this.sendConnector(
inbound,
lines.length ? `最近 ${items.length} 个会话:\n${lines.join('\n')}` : '暂时没有找到最近会话。'
)
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`)
}
private async resolveNaturalLanguage(text: string): Promise<NaturalLanguageResult> {
const result = await agentAIProvider.chat([
{
role: 'system',
content: `你是 WechatExplorer 微信机器人的意图理解器。只能输出一行 JSON,不要 Markdown。
支持的工具:
1. recent:查看最近会话,参数 limit 为 1-20。
2. contact:查看我与某个联系人的最近聊天,参数 contact 和 limit。
3. report:生成某个群的群聊总结图片,参数 group 和 rangetoday、yesterday、7days)。
4. group_member:分析某个群里某位成员的发言,参数 group、member、rangetoday、yesterday、7days)、days1-30)和 goal(保留用户希望总结、研究人物、提取观点等完整目标)。只要用户同时提到群聊和群成员,应优先使用 group_member,不能识别成 contact。
5. chat:不需要工具的普通对话,reply 用简洁中文直接回答。
输出格式:{"type":"recent|contact|report|group_member|chat","limit":5,"contact":"","group":"","member":"","range":"today","days":3,"goal":"","reply":""}
不要声称已经读取未调用的聊天记录,不要执行电脑控制、文件操作、付款或发送给其他联系人。`
},
{ role: 'user', content: text.slice(0, 1000) }
])
if (!result.success || !result.data) {
this.addLog('agent-hub', 'warn', `自然语言理解不可用:${result.error || 'AI 未返回内容'}`)
return {}
}
try {
const json = result.data.match(/\{[\s\S]*\}/)?.[0]
if (!json) return {}
const parsed = JSON.parse(json) as Record<string, unknown>
const limit = Math.max(1, Math.min(20, Number(parsed['limit']) || 5))
if (parsed['type'] === 'recent') return { command: `最近${limit}条消息` }
if (parsed['type'] === 'contact' && String(parsed['contact'] || '').trim()) {
return { command: `我和${String(parsed['contact']).trim()}最近${limit}条聊了什么` }
}
if (parsed['type'] === 'report' && String(parsed['group'] || '').trim()) {
const range =
parsed['range'] === '7days'
? '最近7天'
: parsed['range'] === 'yesterday'
? '昨天'
: '今天'
return { command: `生成${String(parsed['group']).trim()}${range}的群聊总结图片` }
}
if (
parsed['type'] === 'group_member' &&
String(parsed['group'] || '').trim() &&
String(parsed['member'] || '').trim()
) {
const range =
parsed['range'] === 'yesterday'
? '昨天'
: parsed['range'] === 'today'
? '今天'
: '最近7天'
const group = String(parsed['group'] || '')
.trim()
.replace(/(?:群聊|群)+$/g, '')
const days = Math.max(1, Math.min(30, Number(parsed['days']) || 7))
const goal = String(parsed['goal'] || '总结发言').trim()
return {
command: `看看${group}群里${String(parsed['member']).trim()}${range === '最近7天' ? `最近${days}天` : range}说了什么,${goal}`
}
}
if (parsed['type'] === 'chat') {
const reply = String(parsed['reply'] || '').trim()
return reply ? { reply: reply.slice(0, 1500) } : {}
}
} catch (error) {
this.addLog('agent-hub', 'warn', `自然语言结果解析失败:${this.errorMessage(error)}`)
}
return {}
}
private async replyContactChat(
inbound: InboundMessage,
intent: ContactChatIntent
): Promise<void> {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
return
}
const contact = resolveMd5(intent.contact)
if (!contact || contact.type !== 'user') {
this.addLog('agent-hub', 'info', `没有匹配到联系人:${intent.contact}`)
await this.sendConnector(inbound, `没有找到联系人“${intent.contact}”。`)
return
}
this.addLog(
'agent-hub',
'info',
`匹配联系人聊天查询:${contact.m_nsNickName}(最近 ${intent.limit} 条)`
)
const messages = listMessages(contact.md5, undefined, undefined, { limit: intent.limit })
const recent = messages.slice(-intent.limit)
const lines = recent.map((message) => {
const speaker = message.isSender ? '我' : contact.m_nsNickName
const content = this.describeChatMessage(message.content, message.type)
return `${speaker}${content}`
})
const reply = lines.length
? `我和${contact.m_nsNickName}最近聊了这些:\n${lines.join('\n')}`
: `暂时没有找到和${contact.m_nsNickName}的聊天记录。`
await this.sendConnector(inbound, reply)
this.addLog('agent-hub', 'info', `联系人聊天回复已发送(${recent.length} 条)`)
}
private async summarizeContactChat(
inbound: InboundMessage,
intent: ContactChatIntent
): Promise<void> {
try {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
return
}
const contact = resolveMd5(intent.contact)
if (!contact || contact.type !== 'user') {
await this.sendConnector(inbound, `没有找到联系人“${intent.contact}”。`)
return
}
await this.sendConnector(
inbound,
`收到!正在整理和${contact.m_nsNickName}的近期聊天,请等待…`
)
const endTime = Math.floor(Date.now() / 1000)
const startTime = endTime - 7 * 24 * 60 * 60
const messages = listMessages(contact.md5, startTime, endTime, { limit: 300 }).slice(-300)
if (!messages.length) {
await this.sendConnector(inbound, `最近 7 天没有找到和${contact.m_nsNickName}的聊天记录。`)
return
}
const transcript = messages
.map((message) => {
const speaker = message.isSender ? '我' : contact.m_nsNickName
return `[${message.datetime}] ${speaker}${this.describeChatMessage(message.content, message.type)}`
})
.join('\n')
const summary = await agentAIProvider.chat([
{
role: 'system',
content:
'你是私人聊天记录总结助手。仅根据提供的记录总结,不编造。按日期或主题整理关键进展、双方观点、决定、待办和未解决问题;忽略无意义表情,保留重要数字与事实。使用适合微信阅读的简洁中文。'
},
{
role: 'user',
content: `请总结我和“${contact.m_nsNickName}”最近 7 天聊了什么。\n\n聊天记录:\n${transcript}`
}
])
if (!summary.success || !summary.data?.trim()) {
throw new Error(summary.error || 'AI 未返回总结')
}
await this.sendConnector(
inbound,
this.formatAIReply(
`和${contact.m_nsNickName}最近聊天总结(近 7 天,共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}`
)
)
this.addLog('agent-hub', 'info', `联系人聊天总结已发送(${messages.length} 条)`)
} catch (error) {
this.addLog('agent-hub', 'error', `联系人聊天总结失败:${this.errorMessage(error)}`)
await this.sendConnector(inbound, `聊天总结失败:${this.errorMessage(error)}`).catch(
() => undefined
)
}
}
private async summarizeGroupMemberChat(
inbound: InboundMessage,
intent: GroupMemberChatIntent
): Promise<void> {
try {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
return
}
const group = this.resolveGroup(intent.group)
if (!group) {
await this.sendConnector(inbound, `没有找到群聊“${intent.group}”。`)
return
}
const snapshot = getGroupSnapshot(group.md5)
const memberQuery = intent.member.trim().toLowerCase()
const member = snapshot?.members.find((item) =>
[item.groupNickname, item.wechatNickname, item.remark, item.nickname, item.wxid].some(
(name) =>
String(name || '')
.trim()
.toLowerCase() === memberQuery
)
)
if (!member) {
await this.sendConnector(
inbound,
`没有在“${group.m_nsNickName}”找到成员“${intent.member}”。`
)
return
}
const displayName =
member.groupNickname || member.wechatNickname || member.remark || member.nickname
await this.sendConnector(
inbound,
`收到!正在整理${displayName}在“${group.m_nsNickName}”的近期发言,请等待…`
)
const now = new Date()
const todayStart = Math.floor(
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
)
const startTime =
intent.range === 'today'
? todayStart
: intent.range === 'yesterday'
? todayStart - 24 * 60 * 60
: Math.floor(Date.now() / 1000) - intent.days * 24 * 60 * 60
const endTime = intent.range === 'yesterday' ? todayStart - 1 : Math.floor(Date.now() / 1000)
const aliases = new Set(
[member.groupNickname, member.wechatNickname, member.remark, member.nickname]
.map((name) =>
String(name || '')
.trim()
.toLowerCase()
)
.filter(Boolean)
)
const messages = listMessages(group.md5, startTime, endTime, { limit: 10_000 })
.filter(
(message) =>
String(message.senderId || '').trim() === member.wxid ||
aliases.has(
String(message.name || '')
.trim()
.toLowerCase()
)
)
.slice(-1000)
if (!messages.length) {
await this.sendConnector(
inbound,
`所选时间范围没有找到${displayName}在“${group.m_nsNickName}”的发言。`
)
return
}
const transcript = messages
.map(
(message) =>
`[${message.datetime}] ${this.describeChatMessage(message.content, message.type)}`
)
.join('\n')
const summary = await agentAIProvider.chat([
{
role: 'system',
content:
'你是擅长分析微信群聊的助手。严格依据提供的发言完成用户的原始要求,输出结构和侧重点由内容决定,不套固定模板。可以归纳人物特征、兴趣、表达习惯和群内角色,但必须区分事实与推测,为推测说明依据和不确定性,不得编造。使用适合微信阅读的中文。'
},
{
role: 'user',
content: `用户原始要求:${intent.goal}\n分析对象:“${displayName}”在群聊“${group.m_nsNickName}”中的发言。\n时间范围:${intent.range === 'today' ? '今天' : intent.range === 'yesterday' ? '昨天' : `最近 ${intent.days} 天`}\n共提供 ${messages.length} 条发言。\n\n发言记录:\n${transcript}`
}
])
if (!summary.success || !summary.data?.trim()) {
throw new Error(summary.error || 'AI 未返回总结')
}
await this.sendConnector(
inbound,
this.formatAIReply(
`${displayName}在“${group.m_nsNickName}”的发言总结(共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}`
)
)
this.addLog('agent-hub', 'info', `群成员发言总结已发送(${messages.length} 条)`)
} catch (error) {
this.addLog('agent-hub', 'error', `群成员发言总结失败:${this.errorMessage(error)}`)
await this.sendConnector(inbound, `群成员发言总结失败:${this.errorMessage(error)}`).catch(
() => undefined
)
}
}
private describeChatMessage(content: string, type: string): string {
const normalized = String(content || '')
.replace(/\s+/g, ' ')
.trim()
if (normalized) return normalized.length > 100 ? `${normalized.slice(0, 100)}…` : normalized
const label = String(type || '消息').replace(/^普通文本$/, '消息')
return `[${label}]`
}
private formatAIReply(content: string): string {
return content
.replace(/\r\n?/g, '\n')
.replace(/[ \t]*•[ \t]*/g, '\n• ')
.replace(/[ \t]+(?=\d+[.、][ \t])/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
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 matchContactChatIntent(text: string): ContactChatIntent | null {
const normalized = text.replace(/\s+/g, '').replace(/[,。!??:]/g, '')
if (!normalized.includes('最近') || !/(聊|消息|会话)/.test(normalized)) return null
const patterns = [
/(?:看一下|看看|查一下|查询)?我和(.+?)最近(?:\d{1,2}条)?(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/,
/(?:看一下|看看|查一下|查询)?(?:我)?最近(?:\d{1,2}条)?和(.+?)(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/,
/(?:看一下|看看|查一下|查询)?和(.+?)最近(?:\d{1,2}条)?(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/
]
const contact = patterns
.map((pattern) => normalized.match(pattern)?.[1]?.trim())
.find((value): value is string => Boolean(value))
if (!contact) return null
const limit = Number(normalized.match(/最近(\d{1,2})条/)?.[1] || 10)
const summarize = /(聊了什么|聊什么|说了什么|谈了什么|总结)/.test(normalized)
return { contact, limit: Math.max(1, Math.min(20, limit)), summarize }
}
private matchGroupMemberChatIntent(text: string): GroupMemberChatIntent | null {
const normalized = text.trim().replace(/[,。!??:]/g, '')
const timePattern = '(今天|今日|昨天|昨日|最近\\d{1,2}天|近\\d{1,2}天|最近|近来|这几天)'
const actionPattern = '(?:说了什么|聊了什么|发言|说过什么|都聊什么|都说什么|干了什么)'
const patterns = [
new RegExp(
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)[\\s,]+(.+?)${timePattern}${actionPattern}`
),
new RegExp(
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)(?:里|中的)(.+?)${timePattern}${actionPattern}`
)
]
for (const pattern of patterns) {
const match = normalized.match(pattern)
if (match?.[1]?.trim() && match[2]?.trim()) {
const range = /昨天|昨日/.test(match[3] || '')
? 'yesterday'
: /今天|今日/.test(match[3] || '')
? 'today'
: '7days'
const days = Math.max(1, Math.min(30, Number((match[3] || '').match(/\d{1,2}/)?.[0]) || 7))
return {
group: match[1].trim(),
member: match[2].trim(),
range,
days,
goal: normalized
}
}
}
return null
}
private resolveGroup(query: string): ReturnType<typeof resolveMd5> {
const normalize = (value: string): string =>
value
.trim()
.toLowerCase()
.replace(/[\s,。!??:、“”'‘’]/g, '')
.replace(/(?:群聊|群)+$/g, '')
const target = normalize(query)
if (!target) return null
const groups = listContacts().filter((contact) => contact.type === 'group')
return (
groups.find((contact) => normalize(contact.m_nsNickName) === target) ||
groups.find((contact) => {
const name = normalize(contact.m_nsNickName)
return name.includes(target) || target.includes(name)
}) ||
null
)
}
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()