/** * HTTP API 服务 * 提供 ChatLab 标准化格式的消息查询 API */ import * as http from 'http' import { URL } from 'url' import { chatService, Message } from './chatService' import { wcdbService } from './wcdbService' import { ConfigService } from './config' // 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 } interface ChatLabData { chatlab: ChatLabHeader meta: ChatLabMeta members: ChatLabMember[] messages: ChatLabMessage[] } // 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 = 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 { 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 } /** * 处理 HTTP 请求 */ private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { // 设置 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)) } } /** * 处理消息查询 * GET /api/v1/messages?talker=xxx&limit=100&start=20260101&chatlab=1 */ private async handleMessages(url: URL, res: http.ServerResponse): Promise { const talker = url.searchParams.get('talker') const limit = parseInt(url.searchParams.get('limit') || '100', 10) const offset = parseInt(url.searchParams.get('offset') || '0', 10) const startParam = url.searchParams.get('start') const endParam = url.searchParams.get('end') const chatlab = url.searchParams.get('chatlab') === '1' const format = url.searchParams.get('format') || (chatlab ? 'chatlab' : 'json') if (!talker) { this.sendError(res, 400, 'Missing required parameter: talker') return } // 解析时间参数 (支持 YYYYMMDD 格式) const startTime = this.parseTimeParam(startParam) const endTime = this.parseTimeParam(endParam, true) // 获取消息 const result = await chatService.getMessages(talker, offset, limit, startTime, endTime, true) if (!result.success || !result.messages) { this.sendError(res, 500, result.error || 'Failed to get messages') return } if (format === 'chatlab') { // 获取会话显示名 const displayNames = await this.getDisplayNames([talker]) const talkerName = displayNames[talker] || talker const chatLabData = await this.convertToChatLab(result.messages, talker, talkerName) this.sendJson(res, chatLabData) } else { // 返回原始消息格式 this.sendJson(res, { success: true, talker, count: result.messages.length, hasMore: result.hasMore, messages: result.messages }) } } /** * 处理会话列表查询 * GET /api/v1/sessions?keyword=xxx&limit=100 */ private async handleSessions(url: URL, res: http.ServerResponse): Promise { const keyword = url.searchParams.get('keyword') || '' const limit = parseInt(url.searchParams.get('limit') || '100', 10) 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 { const keyword = url.searchParams.get('keyword') || '' const limit = parseInt(url.searchParams.get('limit') || '100', 10) 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)) } } /** * 解析时间参数 * 支持 YYYYMMDD 格式,返回秒级时间戳 */ private parseTimeParam(param: string | null, isEnd: boolean = false): number { if (!param) return 0 // 纯数字且长度为8,视为 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> { 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): Promise { const isGroup = talkerId.endsWith('@chatroom') const myWxid = this.configService.get('myWxid') || '' // 收集所有发送者 const senderSet = new Set() for (const msg of messages) { if (msg.senderUsername) { senderSet.add(msg.senderUsername) } } // 获取发送者显示名 const senderNames = await this.getDisplayNames(Array.from(senderSet)) // 获取群昵称(如果是群聊) let groupNicknamesMap = new Map() 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() 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 } }) 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 msg.imageMd5 || '[图片]' case 34: return '[语音]' case 43: return msg.videoMd5 || '[视频]' case 47: return msg.emojiCdnUrl || msg.emojiMd5 || '[表情]' 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()