mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
907 lines
28 KiB
TypeScript
907 lines
28 KiB
TypeScript
/**
|
|
* HTTP API 鏈嶅姟
|
|
* 鎻愪緵 ChatLab 鏍囧噯鍖栨牸寮忕殑娑堟伅鏌ヨ API
|
|
*/
|
|
import * as http from 'http'
|
|
import * as fs from 'fs'
|
|
import * as path from 'path'
|
|
import { URL } from 'url'
|
|
import { chatService, Message } from './chatService'
|
|
import { wcdbService } from './wcdbService'
|
|
import { ConfigService } from './config'
|
|
import { videoService } from './videoService'
|
|
|
|
// ChatLab 鏍煎紡瀹氫箟
|
|
interface ChatLabHeader {
|
|
version: string
|
|
exportedAt: number
|
|
generator: string
|
|
description?: string
|
|
}
|
|
|
|
interface ChatLabMeta {
|
|
name: string
|
|
platform: string
|
|
type: 'group' | 'private'
|
|
groupId?: string
|
|
groupAvatar?: string
|
|
ownerId?: string
|
|
}
|
|
|
|
interface ChatLabMember {
|
|
platformId: string
|
|
accountName: string
|
|
groupNickname?: string
|
|
aliases?: string[]
|
|
avatar?: string
|
|
}
|
|
|
|
interface ChatLabMessage {
|
|
sender: string
|
|
accountName: string
|
|
groupNickname?: string
|
|
timestamp: number
|
|
type: number
|
|
content: string | null
|
|
platformMessageId?: string
|
|
replyToMessageId?: string
|
|
mediaPath?: string
|
|
}
|
|
|
|
interface ChatLabData {
|
|
chatlab: ChatLabHeader
|
|
meta: ChatLabMeta
|
|
members: ChatLabMember[]
|
|
messages: ChatLabMessage[]
|
|
}
|
|
|
|
interface ApiMediaOptions {
|
|
enabled: boolean
|
|
exportImages: boolean
|
|
exportVoices: boolean
|
|
exportVideos: boolean
|
|
exportEmojis: boolean
|
|
}
|
|
|
|
type MediaKind = 'image' | 'voice' | 'video' | 'emoji'
|
|
|
|
interface ApiExportedMedia {
|
|
kind: MediaKind
|
|
fileName: string
|
|
fullPath: string
|
|
}
|
|
|
|
// ChatLab 娑堟伅绫诲瀷鏄犲皠
|
|
const ChatLabType = {
|
|
TEXT: 0,
|
|
IMAGE: 1,
|
|
VOICE: 2,
|
|
VIDEO: 3,
|
|
FILE: 4,
|
|
EMOJI: 5,
|
|
LINK: 7,
|
|
LOCATION: 8,
|
|
RED_PACKET: 20,
|
|
TRANSFER: 21,
|
|
POKE: 22,
|
|
CALL: 23,
|
|
SHARE: 24,
|
|
REPLY: 25,
|
|
FORWARD: 26,
|
|
CONTACT: 27,
|
|
SYSTEM: 80,
|
|
RECALL: 81,
|
|
OTHER: 99
|
|
} as const
|
|
|
|
class HttpService {
|
|
private server: http.Server | null = null
|
|
private configService: ConfigService
|
|
private port: number = 5031
|
|
private running: boolean = false
|
|
private connections: Set<import('net').Socket> = new Set()
|
|
|
|
constructor() {
|
|
this.configService = ConfigService.getInstance()
|
|
}
|
|
|
|
/**
|
|
* 鍚姩 HTTP 鏈嶅姟
|
|
*/
|
|
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> {
|
|
if (this.running && this.server) {
|
|
return { success: true, port: this.port }
|
|
}
|
|
|
|
this.port = port
|
|
|
|
return new Promise((resolve) => {
|
|
this.server = http.createServer((req, res) => this.handleRequest(req, res))
|
|
|
|
// 璺熻釜鎵€鏈夎繛鎺ワ紝浠ヤ究鍏抽棴鏃惰兘寮哄埗鏂紑
|
|
this.server.on('connection', (socket) => {
|
|
this.connections.add(socket)
|
|
socket.on('close', () => {
|
|
this.connections.delete(socket)
|
|
})
|
|
})
|
|
|
|
this.server.on('error', (err: NodeJS.ErrnoException) => {
|
|
if (err.code === 'EADDRINUSE') {
|
|
console.error(`[HttpService] Port ${this.port} is already in use`)
|
|
resolve({ success: false, error: `Port ${this.port} is already in use` })
|
|
} else {
|
|
console.error('[HttpService] Server error:', err)
|
|
resolve({ success: false, error: err.message })
|
|
}
|
|
})
|
|
|
|
this.server.listen(this.port, '127.0.0.1', () => {
|
|
this.running = true
|
|
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
|
|
resolve({ success: true, port: this.port })
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 鍋滄 HTTP 鏈嶅姟
|
|
*/
|
|
async stop(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
if (this.server) {
|
|
// 寮哄埗鍏抽棴鎵€鏈夋椿鍔ㄨ繛鎺?
|
|
for (const socket of this.connections) {
|
|
socket.destroy()
|
|
}
|
|
this.connections.clear()
|
|
|
|
this.server.close(() => {
|
|
this.running = false
|
|
this.server = null
|
|
console.log('[HttpService] HTTP API server stopped')
|
|
resolve()
|
|
})
|
|
} else {
|
|
this.running = false
|
|
resolve()
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 妫€鏌ユ湇鍔℃槸鍚﹁繍琛?
|
|
*/
|
|
isRunning(): boolean {
|
|
return this.running
|
|
}
|
|
|
|
/**
|
|
* 鑾峰彇褰撳墠绔彛
|
|
*/
|
|
getPort(): number {
|
|
return this.port
|
|
}
|
|
|
|
getDefaultMediaExportPath(): string {
|
|
return this.getApiMediaExportPath()
|
|
}
|
|
|
|
/**
|
|
* 澶勭悊 HTTP 璇锋眰
|
|
*/
|
|
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
// 璁剧疆 CORS 澶?
|
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204)
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
|
|
const pathname = url.pathname
|
|
|
|
try {
|
|
// 璺敱澶勭悊
|
|
if (pathname === '/health' || pathname === '/api/v1/health') {
|
|
this.sendJson(res, { status: 'ok' })
|
|
} else if (pathname === '/api/v1/messages') {
|
|
await this.handleMessages(url, res)
|
|
} else if (pathname === '/api/v1/sessions') {
|
|
await this.handleSessions(url, res)
|
|
} else if (pathname === '/api/v1/contacts') {
|
|
await this.handleContacts(url, res)
|
|
} else {
|
|
this.sendError(res, 404, 'Not Found')
|
|
}
|
|
} catch (error) {
|
|
console.error('[HttpService] Request error:', error)
|
|
this.sendError(res, 500, String(error))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 鎵归噺鑾峰彇娑堟伅锛堝惊鐜父鏍囩洿鍒版弧瓒?limit锛?
|
|
* 缁曡繃 chatService 鐨勫崟 batch 闄愬埗锛岀洿鎺ユ搷浣?wcdbService 娓告爣
|
|
*/
|
|
private async fetchMessagesBatch(
|
|
talker: string,
|
|
offset: number,
|
|
limit: number,
|
|
startTime: number,
|
|
endTime: number,
|
|
ascending: boolean
|
|
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
|
try {
|
|
// 浣跨敤鍥哄畾 batch 澶у皬锛堜笌 limit 鐩稿悓鎴栨渶澶?500锛夋潵鍑忓皯寰幆娆℃暟
|
|
const batchSize = Math.min(limit, 500)
|
|
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
|
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
|
|
|
const cursorResult = await wcdbService.openMessageCursor(talker, batchSize, ascending, beginTimestamp, endTimestamp)
|
|
if (!cursorResult.success || !cursorResult.cursor) {
|
|
return { success: false, error: cursorResult.error || '鎵撳紑娑堟伅娓告爣澶辫触' }
|
|
}
|
|
|
|
const cursor = cursorResult.cursor
|
|
try {
|
|
const allRows: Record<string, any>[] = []
|
|
let hasMore = true
|
|
let skipped = 0
|
|
|
|
// 寰幆鑾峰彇娑堟伅锛屽鐞?offset 璺宠繃 + limit 绱Н
|
|
while (allRows.length < limit && hasMore) {
|
|
const batch = await wcdbService.fetchMessageBatch(cursor)
|
|
if (!batch.success || !batch.rows || batch.rows.length === 0) {
|
|
hasMore = false
|
|
break
|
|
}
|
|
|
|
let rows = batch.rows
|
|
hasMore = batch.hasMore === true
|
|
|
|
// 澶勭悊 offset: 璺宠繃鍓?N 鏉?
|
|
if (skipped < offset) {
|
|
const remaining = offset - skipped
|
|
if (remaining >= rows.length) {
|
|
skipped += rows.length
|
|
continue
|
|
}
|
|
rows = rows.slice(remaining)
|
|
skipped = offset
|
|
}
|
|
|
|
allRows.push(...rows)
|
|
}
|
|
|
|
const trimmedRows = allRows.slice(0, limit)
|
|
const finalHasMore = hasMore || allRows.length > limit
|
|
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
|
|
return { success: true, messages, hasMore: finalHasMore }
|
|
} finally {
|
|
await wcdbService.closeMessageCursor(cursor)
|
|
}
|
|
} catch (e) {
|
|
console.error('[HttpService] fetchMessagesBatch error:', e)
|
|
return { success: false, error: String(e) }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query param helpers.
|
|
*/
|
|
private parseIntParam(value: string | null, defaultValue: number, min: number, max: number): number {
|
|
const parsed = parseInt(value || '', 10)
|
|
if (!Number.isFinite(parsed)) return defaultValue
|
|
return Math.min(Math.max(parsed, min), max)
|
|
}
|
|
|
|
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
|
|
for (const key of keys) {
|
|
const raw = url.searchParams.get(key)
|
|
if (raw === null) continue
|
|
const normalized = raw.trim().toLowerCase()
|
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
|
|
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
private parseMediaOptions(url: URL): ApiMediaOptions {
|
|
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
|
|
if (!mediaEnabled) {
|
|
return {
|
|
enabled: false,
|
|
exportImages: false,
|
|
exportVoices: false,
|
|
exportVideos: false,
|
|
exportEmojis: false
|
|
}
|
|
}
|
|
|
|
return {
|
|
enabled: true,
|
|
exportImages: this.parseBooleanParam(url, ['image', 'tupian'], true),
|
|
exportVoices: this.parseBooleanParam(url, ['voice', 'vioce'], true),
|
|
exportVideos: this.parseBooleanParam(url, ['video'], true),
|
|
exportEmojis: this.parseBooleanParam(url, ['emoji'], true)
|
|
}
|
|
}
|
|
|
|
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
|
|
const talker = (url.searchParams.get('talker') || '').trim()
|
|
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
|
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
|
const keyword = (url.searchParams.get('keyword') || '').trim().toLowerCase()
|
|
const startParam = url.searchParams.get('start')
|
|
const endParam = url.searchParams.get('end')
|
|
const chatlab = this.parseBooleanParam(url, ['chatlab'], false)
|
|
const formatParam = (url.searchParams.get('format') || '').trim().toLowerCase()
|
|
const format = formatParam || (chatlab ? 'chatlab' : 'json')
|
|
const mediaOptions = this.parseMediaOptions(url)
|
|
|
|
if (!talker) {
|
|
this.sendError(res, 400, 'Missing required parameter: talker')
|
|
return
|
|
}
|
|
|
|
if (format !== 'json' && format !== 'chatlab') {
|
|
this.sendError(res, 400, 'Invalid format, supported: json/chatlab')
|
|
return
|
|
}
|
|
|
|
const startTime = this.parseTimeParam(startParam)
|
|
const endTime = this.parseTimeParam(endParam, true)
|
|
const queryOffset = keyword ? 0 : offset
|
|
const queryLimit = keyword ? 10000 : limit
|
|
|
|
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true)
|
|
if (!result.success || !result.messages) {
|
|
this.sendError(res, 500, result.error || 'Failed to get messages')
|
|
return
|
|
}
|
|
|
|
let messages = result.messages
|
|
let hasMore = result.hasMore === true
|
|
|
|
if (keyword) {
|
|
const filtered = messages.filter((msg) => {
|
|
const content = (msg.parsedContent || msg.rawContent || '').toLowerCase()
|
|
return content.includes(keyword)
|
|
})
|
|
const endIndex = offset + limit
|
|
hasMore = filtered.length > endIndex
|
|
messages = filtered.slice(offset, endIndex)
|
|
}
|
|
|
|
const mediaMap = mediaOptions.enabled
|
|
? await this.exportMediaForMessages(messages, talker, mediaOptions)
|
|
: new Map<number, ApiExportedMedia>()
|
|
|
|
const displayNames = await this.getDisplayNames([talker])
|
|
const talkerName = displayNames[talker] || talker
|
|
|
|
if (format === 'chatlab') {
|
|
const chatLabData = await this.convertToChatLab(messages, talker, talkerName, mediaMap)
|
|
this.sendJson(res, {
|
|
...chatLabData,
|
|
media: {
|
|
enabled: mediaOptions.enabled,
|
|
exportPath: this.getApiMediaExportPath(),
|
|
count: mediaMap.size
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
const apiMessages = messages.map((msg) => this.toApiMessage(msg, mediaMap.get(msg.localId)))
|
|
this.sendJson(res, {
|
|
success: true,
|
|
talker,
|
|
count: apiMessages.length,
|
|
hasMore,
|
|
media: {
|
|
enabled: mediaOptions.enabled,
|
|
exportPath: this.getApiMediaExportPath(),
|
|
count: mediaMap.size
|
|
},
|
|
messages: apiMessages
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 澶勭悊浼氳瘽鍒楄〃鏌ヨ
|
|
* GET /api/v1/sessions?keyword=xxx&limit=100
|
|
*/
|
|
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
|
|
const keyword = (url.searchParams.get('keyword') || '').trim()
|
|
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
|
|
|
try {
|
|
const sessions = await chatService.getSessions()
|
|
if (!sessions.success || !sessions.sessions) {
|
|
this.sendError(res, 500, sessions.error || 'Failed to get sessions')
|
|
return
|
|
}
|
|
|
|
let filteredSessions = sessions.sessions
|
|
if (keyword) {
|
|
const lowerKeyword = keyword.toLowerCase()
|
|
filteredSessions = sessions.sessions.filter(s =>
|
|
s.username.toLowerCase().includes(lowerKeyword) ||
|
|
(s.displayName && s.displayName.toLowerCase().includes(lowerKeyword))
|
|
)
|
|
}
|
|
|
|
// 搴旂敤 limit
|
|
const limitedSessions = filteredSessions.slice(0, limit)
|
|
|
|
this.sendJson(res, {
|
|
success: true,
|
|
count: limitedSessions.length,
|
|
sessions: limitedSessions.map(s => ({
|
|
username: s.username,
|
|
displayName: s.displayName,
|
|
type: s.type,
|
|
lastTimestamp: s.lastTimestamp,
|
|
unreadCount: s.unreadCount
|
|
}))
|
|
})
|
|
} catch (error) {
|
|
this.sendError(res, 500, String(error))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 澶勭悊鑱旂郴浜烘煡璇?
|
|
* GET /api/v1/contacts?keyword=xxx&limit=100
|
|
*/
|
|
private async handleContacts(url: URL, res: http.ServerResponse): Promise<void> {
|
|
const keyword = (url.searchParams.get('keyword') || '').trim()
|
|
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
|
|
|
try {
|
|
const contacts = await chatService.getContacts()
|
|
if (!contacts.success || !contacts.contacts) {
|
|
this.sendError(res, 500, contacts.error || 'Failed to get contacts')
|
|
return
|
|
}
|
|
|
|
let filteredContacts = contacts.contacts
|
|
if (keyword) {
|
|
const lowerKeyword = keyword.toLowerCase()
|
|
filteredContacts = contacts.contacts.filter(c =>
|
|
c.username.toLowerCase().includes(lowerKeyword) ||
|
|
(c.nickname && c.nickname.toLowerCase().includes(lowerKeyword)) ||
|
|
(c.remark && c.remark.toLowerCase().includes(lowerKeyword)) ||
|
|
(c.displayName && c.displayName.toLowerCase().includes(lowerKeyword))
|
|
)
|
|
}
|
|
|
|
const limited = filteredContacts.slice(0, limit)
|
|
|
|
this.sendJson(res, {
|
|
success: true,
|
|
count: limited.length,
|
|
contacts: limited
|
|
})
|
|
} catch (error) {
|
|
this.sendError(res, 500, String(error))
|
|
}
|
|
}
|
|
|
|
private getApiMediaExportPath(): string {
|
|
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
|
}
|
|
|
|
private sanitizeFileName(value: string, fallback: string): string {
|
|
const safe = (value || '')
|
|
.trim()
|
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
|
.replace(/\.+$/g, '')
|
|
return safe || fallback
|
|
}
|
|
|
|
private ensureDir(dirPath: string): void {
|
|
if (!fs.existsSync(dirPath)) {
|
|
fs.mkdirSync(dirPath, { recursive: true })
|
|
}
|
|
}
|
|
|
|
private detectImageExt(buffer: Buffer): string {
|
|
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg'
|
|
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return '.png'
|
|
if (buffer.length >= 6) {
|
|
const sig6 = buffer.subarray(0, 6).toString('ascii')
|
|
if (sig6 === 'GIF87a' || sig6 === 'GIF89a') return '.gif'
|
|
}
|
|
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return '.webp'
|
|
if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4d) return '.bmp'
|
|
return '.jpg'
|
|
}
|
|
|
|
private async exportMediaForMessages(
|
|
messages: Message[],
|
|
talker: string,
|
|
options: ApiMediaOptions
|
|
): Promise<Map<number, ApiExportedMedia>> {
|
|
const mediaMap = new Map<number, ApiExportedMedia>()
|
|
if (!options.enabled || messages.length === 0) {
|
|
return mediaMap
|
|
}
|
|
|
|
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
|
|
this.ensureDir(sessionDir)
|
|
|
|
for (const msg of messages) {
|
|
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
|
|
if (exported) {
|
|
mediaMap.set(msg.localId, exported)
|
|
}
|
|
}
|
|
|
|
return mediaMap
|
|
}
|
|
|
|
private async exportMediaForMessage(
|
|
msg: Message,
|
|
talker: string,
|
|
sessionDir: string,
|
|
options: ApiMediaOptions
|
|
): Promise<ApiExportedMedia | null> {
|
|
try {
|
|
if (msg.localType === 3 && options.exportImages) {
|
|
const result = await chatService.getImageData(talker, String(msg.localId))
|
|
if (result.success && result.data) {
|
|
const imageBuffer = Buffer.from(result.data, 'base64')
|
|
const ext = this.detectImageExt(imageBuffer)
|
|
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
|
const fileName = `${fileBase}${ext}`
|
|
const targetDir = path.join(sessionDir, 'images')
|
|
const fullPath = path.join(targetDir, fileName)
|
|
this.ensureDir(targetDir)
|
|
if (!fs.existsSync(fullPath)) {
|
|
fs.writeFileSync(fullPath, imageBuffer)
|
|
}
|
|
return { kind: 'image', fileName, fullPath }
|
|
}
|
|
}
|
|
|
|
if (msg.localType === 34 && options.exportVoices) {
|
|
const result = await chatService.getVoiceData(
|
|
talker,
|
|
String(msg.localId),
|
|
msg.createTime || undefined,
|
|
msg.serverId || undefined
|
|
)
|
|
if (result.success && result.data) {
|
|
const fileName = `voice_${msg.localId}.wav`
|
|
const targetDir = path.join(sessionDir, 'voices')
|
|
const fullPath = path.join(targetDir, fileName)
|
|
this.ensureDir(targetDir)
|
|
if (!fs.existsSync(fullPath)) {
|
|
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
|
|
}
|
|
return { kind: 'voice', fileName, fullPath }
|
|
}
|
|
}
|
|
|
|
if (msg.localType === 43 && options.exportVideos && msg.videoMd5) {
|
|
const info = await videoService.getVideoInfo(msg.videoMd5)
|
|
if (info.exists && info.videoUrl && fs.existsSync(info.videoUrl)) {
|
|
const ext = path.extname(info.videoUrl) || '.mp4'
|
|
const fileName = `${this.sanitizeFileName(msg.videoMd5, `video_${msg.localId}`)}${ext}`
|
|
const targetDir = path.join(sessionDir, 'videos')
|
|
const fullPath = path.join(targetDir, fileName)
|
|
this.ensureDir(targetDir)
|
|
if (!fs.existsSync(fullPath)) {
|
|
fs.copyFileSync(info.videoUrl, fullPath)
|
|
}
|
|
return { kind: 'video', fileName, fullPath }
|
|
}
|
|
}
|
|
|
|
if (msg.localType === 47 && options.exportEmojis && msg.emojiCdnUrl) {
|
|
const result = await chatService.downloadEmoji(msg.emojiCdnUrl, msg.emojiMd5)
|
|
if (result.success && result.localPath && fs.existsSync(result.localPath)) {
|
|
const sourceExt = path.extname(result.localPath) || '.gif'
|
|
const fileName = `${this.sanitizeFileName(msg.emojiMd5 || `emoji_${msg.localId}`, `emoji_${msg.localId}`)}${sourceExt}`
|
|
const targetDir = path.join(sessionDir, 'emojis')
|
|
const fullPath = path.join(targetDir, fileName)
|
|
this.ensureDir(targetDir)
|
|
if (!fs.existsSync(fullPath)) {
|
|
fs.copyFileSync(result.localPath, fullPath)
|
|
}
|
|
return { kind: 'emoji', fileName, fullPath }
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[HttpService] exportMediaForMessage failed:', e)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
private toApiMessage(msg: Message, media?: ApiExportedMedia): Record<string, any> {
|
|
return {
|
|
localId: msg.localId,
|
|
serverId: msg.serverId,
|
|
localType: msg.localType,
|
|
createTime: msg.createTime,
|
|
sortSeq: msg.sortSeq,
|
|
isSend: msg.isSend,
|
|
senderUsername: msg.senderUsername,
|
|
content: this.getMessageContent(msg),
|
|
rawContent: msg.rawContent,
|
|
parsedContent: msg.parsedContent,
|
|
mediaType: media?.kind,
|
|
mediaFileName: media?.fileName,
|
|
mediaPath: media?.fullPath
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 瑙f瀽鏃堕棿鍙傛暟
|
|
* 鏀寔 YYYYMMDD 鏍煎紡锛岃繑鍥炵绾ф椂闂存埑
|
|
*/
|
|
private parseTimeParam(param: string | null, isEnd: boolean = false): number {
|
|
if (!param) return 0
|
|
|
|
// 绾暟瀛椾笖闀垮害涓?锛岃涓?YYYYMMDD
|
|
if (/^\d{8}$/.test(param)) {
|
|
const year = parseInt(param.slice(0, 4), 10)
|
|
const month = parseInt(param.slice(4, 6), 10) - 1
|
|
const day = parseInt(param.slice(6, 8), 10)
|
|
const date = new Date(year, month, day)
|
|
if (isEnd) {
|
|
// 缁撴潫鏃堕棿璁句负褰撳ぉ 23:59:59
|
|
date.setHours(23, 59, 59, 999)
|
|
}
|
|
return Math.floor(date.getTime() / 1000)
|
|
}
|
|
|
|
// 绾暟瀛楋紝瑙嗕负鏃堕棿鎴?
|
|
if (/^\d+$/.test(param)) {
|
|
const ts = parseInt(param, 10)
|
|
// 濡傛灉鏄绉掔骇鏃堕棿鎴筹紝杞负绉掔骇
|
|
return ts > 10000000000 ? Math.floor(ts / 1000) : ts
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
/**
|
|
* 鑾峰彇鏄剧ず鍚嶇О
|
|
*/
|
|
private async getDisplayNames(usernames: string[]): Promise<Record<string, string>> {
|
|
try {
|
|
const result = await wcdbService.getDisplayNames(usernames)
|
|
if (result.success && result.map) {
|
|
return result.map
|
|
}
|
|
} catch (e) {
|
|
console.error('[HttpService] Failed to get display names:', e)
|
|
}
|
|
// 杩斿洖绌哄璞★紝璋冪敤鏂逛細浣跨敤 username 浣滀负澶囩敤
|
|
return {}
|
|
}
|
|
|
|
/**
|
|
* 杞崲涓?ChatLab 鏍煎紡
|
|
*/
|
|
private async convertToChatLab(
|
|
messages: Message[],
|
|
talkerId: string,
|
|
talkerName: string,
|
|
mediaMap: Map<number, ApiExportedMedia> = new Map()
|
|
): Promise<ChatLabData> {
|
|
const isGroup = talkerId.endsWith('@chatroom')
|
|
const myWxid = this.configService.get('myWxid') || ''
|
|
|
|
// 鏀堕泦鎵€鏈夊彂閫佽€?
|
|
const senderSet = new Set<string>()
|
|
for (const msg of messages) {
|
|
if (msg.senderUsername) {
|
|
senderSet.add(msg.senderUsername)
|
|
}
|
|
}
|
|
|
|
// 鑾峰彇鍙戦€佽€呮樉绀哄悕
|
|
const senderNames = await this.getDisplayNames(Array.from(senderSet))
|
|
|
|
// 鑾峰彇缇ゆ樀绉帮紙濡傛灉鏄兢鑱婏級
|
|
let groupNicknamesMap = new Map<string, string>()
|
|
if (isGroup) {
|
|
try {
|
|
const result = await wcdbService.getGroupNicknames(talkerId)
|
|
if (result.success && result.nicknames) {
|
|
groupNicknamesMap = new Map(Object.entries(result.nicknames))
|
|
}
|
|
} catch (e) {
|
|
console.error('[HttpService] Failed to get group nicknames:', e)
|
|
}
|
|
}
|
|
|
|
// 鏋勫缓鎴愬憳鍒楄〃
|
|
const memberMap = new Map<string, ChatLabMember>()
|
|
for (const msg of messages) {
|
|
const sender = msg.senderUsername || ''
|
|
if (sender && !memberMap.has(sender)) {
|
|
const displayName = senderNames[sender] || sender
|
|
const isSelf = sender === myWxid || sender.toLowerCase() === myWxid.toLowerCase()
|
|
// 鑾峰彇缇ゆ樀绉帮紙灏濊瘯澶氱鏂瑰紡锛?
|
|
const groupNickname = isGroup
|
|
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
|
|
: ''
|
|
memberMap.set(sender, {
|
|
platformId: sender,
|
|
accountName: isSelf ? '鎴? : displayName,
|
|
groupNickname: groupNickname || undefined
|
|
})
|
|
}
|
|
}
|
|
|
|
// 杞崲娑堟伅
|
|
const chatLabMessages: ChatLabMessage[] = messages.map(msg => {
|
|
const sender = msg.senderUsername || ''
|
|
const isSelf = msg.isSend === 1 || sender === myWxid
|
|
const accountName = isSelf ? '鎴? : (senderNames[sender] || sender)
|
|
// 鑾峰彇璇ュ彂閫佽€呯殑缇ゆ樀绉?
|
|
const groupNickname = isGroup
|
|
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
|
|
: ''
|
|
|
|
return {
|
|
sender,
|
|
accountName,
|
|
groupNickname: groupNickname || undefined,
|
|
timestamp: msg.createTime,
|
|
type: this.mapMessageType(msg.localType, msg),
|
|
content: this.getMessageContent(msg),
|
|
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
|
mediaPath: mediaMap.get(msg.localId)?.fullPath
|
|
}
|
|
})
|
|
|
|
return {
|
|
chatlab: {
|
|
version: '0.0.2',
|
|
exportedAt: Math.floor(Date.now() / 1000),
|
|
generator: 'WeFlow'
|
|
},
|
|
meta: {
|
|
name: talkerName,
|
|
platform: 'wechat',
|
|
type: isGroup ? 'group' : 'private',
|
|
groupId: isGroup ? talkerId : undefined,
|
|
ownerId: myWxid || undefined
|
|
},
|
|
members: Array.from(memberMap.values()),
|
|
messages: chatLabMessages
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 鏄犲皠 WeChat 娑堟伅绫诲瀷鍒?ChatLab 绫诲瀷
|
|
*/
|
|
private mapMessageType(localType: number, msg: Message): number {
|
|
switch (localType) {
|
|
case 1: // 鏂囨湰
|
|
return ChatLabType.TEXT
|
|
case 3: // 鍥剧墖
|
|
return ChatLabType.IMAGE
|
|
case 34: // 璇煶
|
|
return ChatLabType.VOICE
|
|
case 43: // 瑙嗛
|
|
return ChatLabType.VIDEO
|
|
case 47: // 鍔ㄧ敾琛ㄦ儏
|
|
return ChatLabType.EMOJI
|
|
case 48: // 浣嶇疆
|
|
return ChatLabType.LOCATION
|
|
case 42: // 鍚嶇墖
|
|
return ChatLabType.CONTACT
|
|
case 50: // 璇煶/瑙嗛閫氳瘽
|
|
return ChatLabType.CALL
|
|
case 10000: // 绯荤粺娑堟伅
|
|
return ChatLabType.SYSTEM
|
|
case 49: // 澶嶅悎娑堟伅
|
|
return this.mapType49(msg)
|
|
case 244813135921: // 寮曠敤娑堟伅
|
|
return ChatLabType.REPLY
|
|
case 266287972401: // 鎷嶄竴鎷?
|
|
return ChatLabType.POKE
|
|
case 8594229559345: // 绾㈠寘
|
|
return ChatLabType.RED_PACKET
|
|
case 8589934592049: // 杞处
|
|
return ChatLabType.TRANSFER
|
|
default:
|
|
return ChatLabType.OTHER
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 鏄犲皠 Type 49 瀛愮被鍨?
|
|
*/
|
|
private mapType49(msg: Message): number {
|
|
const xmlType = msg.xmlType
|
|
|
|
switch (xmlType) {
|
|
case '5': // 閾炬帴
|
|
case '49':
|
|
return ChatLabType.LINK
|
|
case '6': // 鏂囦欢
|
|
return ChatLabType.FILE
|
|
case '19': // 鑱婂ぉ璁板綍
|
|
return ChatLabType.FORWARD
|
|
case '33': // 灏忕▼搴?
|
|
case '36':
|
|
return ChatLabType.SHARE
|
|
case '57': // 寮曠敤娑堟伅
|
|
return ChatLabType.REPLY
|
|
case '2000': // 杞处
|
|
return ChatLabType.TRANSFER
|
|
case '2001': // 绾㈠寘
|
|
return ChatLabType.RED_PACKET
|
|
default:
|
|
return ChatLabType.OTHER
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 鑾峰彇娑堟伅鍐呭
|
|
*/
|
|
private getMessageContent(msg: Message): string | null {
|
|
// 浼樺厛浣跨敤宸茶В鏋愮殑鍐呭
|
|
if (msg.parsedContent) {
|
|
return msg.parsedContent
|
|
}
|
|
|
|
// 鏍规嵁绫诲瀷杩斿洖鍗犱綅绗?
|
|
switch (msg.localType) {
|
|
case 1:
|
|
return msg.rawContent || null
|
|
case 3:
|
|
return '[图片]'
|
|
case 34:
|
|
return '[语音]'
|
|
case 43:
|
|
return '[视频]'
|
|
case 47:
|
|
return '[表情]'
|
|
case 42:
|
|
return msg.cardNickname || '[名片]'
|
|
case 48:
|
|
return '[位置]'
|
|
case 49:
|
|
return msg.linkTitle || msg.fileName || '[消息]'
|
|
default:
|
|
return msg.rawContent || null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 鍙戦€?JSON 鍝嶅簲
|
|
*/
|
|
private sendJson(res: http.ServerResponse, data: any): void {
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
res.writeHead(200)
|
|
res.end(JSON.stringify(data, null, 2))
|
|
}
|
|
|
|
/**
|
|
* 鍙戦€侀敊璇搷搴?
|
|
*/
|
|
private sendError(res: http.ServerResponse, code: number, message: string): void {
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
res.writeHead(code)
|
|
res.end(JSON.stringify({ error: message }))
|
|
}
|
|
}
|
|
|
|
export const httpService = new HttpService()
|
|
|