mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 本地 HTTP API + 设置面板 + 账号自助入口
让 WechatExplorer 既能用图形界面浏览聊天记录,也能作为本机 MCP 数据源被
Claude / Codex 等客户端通过 127.0.0.1:6131 直接拉取。
本机 HTTP API
- 新增 http-server: 8 个端点,覆盖 health / current_time / contact /
chatroom / recent_chat / chatlog / group_snapshot / resolve / report
- 时间参数支持 YYYY-MM-DD / YYYY-MM-DD/HH:mm / Unix 秒级;日期单独使用
时自动补到 00:00:00~23:59:59,避免漏消息
- apiServer 单例支持动态启停,启动失败返回 friendlyMessage(把
EADDRINUSE 翻译成"端口已被占用"的中文错误并附 4 次重试)
数据库根目录与自服务入口
- Wcdb4Client 接受 accountRoot 时会自动解析:父目录下找最新含
db_storage 的 wxid 子目录;设置面板"测试连接"成功后回写解析后的
精确路径
- 抽 chat-service.ts:IPC 和 HTTP 共享 listContacts / listMessages /
getGroupSnapshot / searchMessages / getSelfAccountInfo / testConnection
/ reopenWithRoot
- WechatDb 接受可选 accountRoot;设置面板新增"应用并重新初始化"
按钮,改完 dbRoot 立即生效
- 新增 settings-store.ts,dbRoot / apiEnabled / apiHost / apiPort 落到
userData/settings.json
主进程健壮性
- 新增 safe-log.ts 包一层 console.log/warn/error,electron-vite 关闭
子进程 stderr 后写 EPIPE 不再炸 IPC handler(原 main build 启动时
即 installSafeConsole)
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
||||
import {
|
||||
isReady,
|
||||
listContacts,
|
||||
listMessages,
|
||||
getGroupSnapshot,
|
||||
listRecentChat,
|
||||
resolveMd5
|
||||
} from './services/chat-service'
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import { GroupReportExportRequest } from '../shared/group-report'
|
||||
import { safeError, safeLog, safeWarn } from './safe-log'
|
||||
|
||||
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||
export const DEFAULT_HTTP_PORT = 6131
|
||||
|
||||
export interface HttpServerHandle {
|
||||
host: string
|
||||
port: number
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
interface RouteContext {
|
||||
req: IncomingMessage
|
||||
res: ServerResponse
|
||||
url: URL
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
||||
const body = JSON.stringify(payload, null, 2)
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-store'
|
||||
})
|
||||
res.end(body)
|
||||
}
|
||||
|
||||
function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void {
|
||||
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function rangeToSec(input: string, endOfUnit = false): number | null {
|
||||
const m = input.match(/^(\d{4})-(\d{2})-(\d{2})(?:\/(\d{2}):(\d{2}))?$/)
|
||||
if (!m) return null
|
||||
const [, y, mo, d, hStr, miStr] = m
|
||||
const hasTime = hStr !== undefined
|
||||
|
||||
let hh: number, mi: number, ss: number, ms: number
|
||||
if (hasTime) {
|
||||
hh = Number(hStr)
|
||||
mi = Number(miStr)
|
||||
ss = endOfUnit ? 59 : 0
|
||||
ms = endOfUnit ? 999 : 0
|
||||
} else if (endOfUnit) {
|
||||
hh = 23
|
||||
mi = 59
|
||||
ss = 59
|
||||
ms = 999
|
||||
} else {
|
||||
hh = 0
|
||||
mi = 0
|
||||
ss = 0
|
||||
ms = 0
|
||||
}
|
||||
|
||||
const date = new Date(Number(y), Number(mo) - 1, Number(d), hh, mi, ss, ms)
|
||||
return Math.floor(date.getTime() / 1000)
|
||||
}
|
||||
|
||||
function parseTimeRange(value: string | null): { startTime?: number; endTime?: number } {
|
||||
if (!value) return {}
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return {}
|
||||
|
||||
if (/^\d{10,13}$/.test(trimmed)) {
|
||||
const n = Number(trimmed)
|
||||
if (!Number.isFinite(n)) return {}
|
||||
return { startTime: n > 1e12 ? Math.floor(n / 1000) : Math.floor(n) }
|
||||
}
|
||||
|
||||
if (trimmed.includes('~')) {
|
||||
const [a, b] = trimmed.split('~').map((s) => s.trim())
|
||||
const start = rangeToSec(a, false)
|
||||
const end = rangeToSec(b, true)
|
||||
return {
|
||||
startTime: start ?? undefined,
|
||||
endTime: end ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
const start = rangeToSec(trimmed, false)
|
||||
const end = rangeToSec(trimmed, true)
|
||||
return {
|
||||
startTime: start ?? undefined,
|
||||
endTime: end ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
function parseNumeric(value: string | null, fallback: number): number {
|
||||
if (!value) return fallback
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
const routes: Record<string, RouteHandler> = {
|
||||
'/api/v1/health': ({ res }) => {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
ready: isReady(),
|
||||
service: 'WechatExplorer Reader',
|
||||
version: '1.0.0',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
},
|
||||
|
||||
'/api/v1/current_time': ({ res }) => {
|
||||
const now = new Date()
|
||||
sendJson(res, 200, {
|
||||
time: now.toISOString(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
timestamp: Math.floor(now.getTime() / 1000),
|
||||
localDate: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
now.getDate()
|
||||
).padStart(2, '0')}`
|
||||
})
|
||||
},
|
||||
|
||||
'/api/v1/contact': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
const filter = url.searchParams.get('filter') || undefined
|
||||
const type = url.searchParams.get('type') || undefined
|
||||
let contacts = listContacts(filter)
|
||||
if (type === 'user' || type === 'group') {
|
||||
contacts = contacts.filter((c) => c.type === type)
|
||||
}
|
||||
sendJson(res, 200, { count: contacts.length, contacts })
|
||||
},
|
||||
|
||||
'/api/v1/chatroom': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
const keyword = url.searchParams.get('keyword') || ''
|
||||
let groups = listContacts().filter((c) => c.type === 'group')
|
||||
if (keyword) {
|
||||
const lower = keyword.toLowerCase()
|
||||
groups = groups.filter(
|
||||
(c) => c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
|
||||
)
|
||||
}
|
||||
sendJson(res, 200, { count: groups.length, chatrooms: groups })
|
||||
},
|
||||
|
||||
'/api/v1/recent_chat': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
const limit = parseNumeric(url.searchParams.get('limit'), 50)
|
||||
const items = listRecentChat(limit)
|
||||
sendJson(res, 200, { count: items.length, items })
|
||||
},
|
||||
|
||||
'/api/v1/chatlog': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
const talker = url.searchParams.get('talker')
|
||||
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
|
||||
|
||||
const resolved = resolveMd5(talker)
|
||||
if (!resolved) return sendError(res, 404, `未找到会话: ${talker}`)
|
||||
|
||||
const timeParam = url.searchParams.get('time')
|
||||
const startParam = url.searchParams.get('startTime')
|
||||
const endParam = url.searchParams.get('endTime')
|
||||
|
||||
let startTime: number | undefined
|
||||
let endTime: number | undefined
|
||||
if (timeParam) {
|
||||
const range = parseTimeRange(timeParam)
|
||||
startTime = range.startTime
|
||||
endTime = range.endTime
|
||||
} else {
|
||||
if (startParam) {
|
||||
const r = parseTimeRange(startParam)
|
||||
startTime = r.startTime
|
||||
}
|
||||
if (endParam) {
|
||||
const r = parseTimeRange(endParam)
|
||||
endTime = r.endTime
|
||||
}
|
||||
}
|
||||
|
||||
const messages = listMessages(resolved.md5, startTime, endTime)
|
||||
sendJson(res, 200, {
|
||||
contact: resolved,
|
||||
query: { talker, time: timeParam, startTime, endTime },
|
||||
count: messages.length,
|
||||
messages
|
||||
})
|
||||
},
|
||||
|
||||
'/api/v1/group_snapshot': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
const md5 = url.searchParams.get('md5')
|
||||
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
|
||||
const snapshot = getGroupSnapshot(md5)
|
||||
if (!snapshot) return sendError(res, 404, `未找到群聊: ${md5}`)
|
||||
sendJson(res, 200, snapshot)
|
||||
},
|
||||
|
||||
'/api/v1/resolve': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
const q = url.searchParams.get('q')
|
||||
if (!q) return sendError(res, 400, '缺少必要参数 q')
|
||||
const contact = resolveMd5(q)
|
||||
if (!contact) return sendError(res, 404, `未匹配到联系人: ${q}`)
|
||||
sendJson(res, 200, contact)
|
||||
},
|
||||
|
||||
'/api/v1/report': async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (typeof body !== 'string' || !body.trim()) {
|
||||
return sendError(res, 400, '请求体为空,需 POST GroupReportExportRequest JSON')
|
||||
}
|
||||
let request: GroupReportExportRequest
|
||||
try {
|
||||
request = JSON.parse(body) as GroupReportExportRequest
|
||||
} catch (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)
|
||||
}
|
||||
}
|
||||
|
||||
export function startHttpServer(
|
||||
host: string = DEFAULT_HTTP_HOST,
|
||||
port: number = DEFAULT_HTTP_PORT
|
||||
): Promise<HttpServerHandle> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server: Server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', `http://${host}:${port}`)
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*'
|
||||
})
|
||||
return res.end()
|
||||
}
|
||||
const handler = routes[url.pathname]
|
||||
if (!handler) {
|
||||
return sendError(res, 404, `端点不存在: ${url.pathname}`)
|
||||
}
|
||||
let body: string | undefined
|
||||
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
body = await readBody(req)
|
||||
}
|
||||
const ctx: RouteContext = { req, res, url, body }
|
||||
await handler(ctx)
|
||||
} catch (error) {
|
||||
safeError('[HttpServer] 请求处理失败:', error)
|
||||
if (!res.headersSent) {
|
||||
sendError(res, 500, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
server.once('error', (error: NodeJS.ErrnoException) => {
|
||||
const message =
|
||||
error.code === 'EADDRINUSE'
|
||||
? `端口 ${port} 已被占用,请关闭占用进程或在设置中更换端口`
|
||||
: error.message
|
||||
reject(Object.assign(error, { friendlyMessage: message }))
|
||||
})
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', () => undefined)
|
||||
const actualPort = (server.address() as { port: number } | null)?.port ?? port
|
||||
safeLog(`[HttpServer] Listening on http://${host}:${actualPort}`)
|
||||
resolve({
|
||||
host,
|
||||
port: actualPort,
|
||||
close: () =>
|
||||
new Promise<void>((res) => {
|
||||
server.close(() => res())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export interface ApiServerState {
|
||||
running: boolean
|
||||
host: string
|
||||
port: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
let singleton: HttpServerHandle | null = null
|
||||
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))
|
||||
}
|
||||
|
||||
export const apiServer = {
|
||||
isRunning(): boolean {
|
||||
return singleton !== null
|
||||
},
|
||||
|
||||
getState(): ApiServerState {
|
||||
return { ...singletonState }
|
||||
},
|
||||
|
||||
async start(host: string = DEFAULT_HTTP_HOST, port: number = DEFAULT_HTTP_PORT): Promise<ApiServerState> {
|
||||
if (singleton) {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
const maxAttempts = 4
|
||||
let lastError: (NodeJS.ErrnoException & { friendlyMessage?: string }) | null = null
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
singleton = await startHttpServer(host, port)
|
||||
singletonState = {
|
||||
running: true,
|
||||
host: singleton.host,
|
||||
port: singleton.port
|
||||
}
|
||||
safeLog(`[ApiServer] started on http://${singleton.host}:${singleton.port}`)
|
||||
return { ...singletonState }
|
||||
} catch (error) {
|
||||
lastError = error as NodeJS.ErrnoException & { friendlyMessage?: string }
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE' || attempt === maxAttempts) break
|
||||
// Brief wait to let the OS release the port (TIME_WAIT / concurrent dev session).
|
||||
await sleep(400 * attempt)
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
lastError?.friendlyMessage ||
|
||||
(lastError instanceof Error ? lastError.message : String(lastError)) ||
|
||||
'API 启动失败'
|
||||
singletonState = {
|
||||
running: false,
|
||||
host,
|
||||
port,
|
||||
error: message
|
||||
}
|
||||
safeError('[ApiServer] start failed:', message)
|
||||
return { ...singletonState }
|
||||
},
|
||||
|
||||
async stop(): Promise<ApiServerState> {
|
||||
if (!singleton) {
|
||||
return this.getState()
|
||||
}
|
||||
try {
|
||||
await singleton.close()
|
||||
} catch (error) {
|
||||
safeWarn('[ApiServer] close failed:', error)
|
||||
}
|
||||
singleton = null
|
||||
singletonState = { ...singletonState, running: false }
|
||||
safeLog('[ApiServer] stopped')
|
||||
return { ...singletonState }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user