mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
443 lines
14 KiB
TypeScript
443 lines
14 KiB
TypeScript
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 { 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'
|
|
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)
|
|
},
|
|
|
|
'/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)
|
|
}
|
|
}
|
|
|
|
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 }
|
|
}
|
|
}
|