diff --git a/.gitignore b/.gitignore index 5cc4ff8..cccee17 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,5 @@ Thumbs.db wcdb/ *info -*.md +概述.md +chatlab-format.md diff --git a/README.md b/README.md index 6264249..de25ae8 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,22 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 - 统计分析与群聊画像 - 年度报告与可视化概览 - 导出聊天记录为 HTML 等格式 +- HTTP API 接口(供开发者集成) + + +## HTTP API + +> [!WARNING] +> 此功能目前处于早期阶段,接口可能会有变动,请等待后续更新完善。 + +WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可用于与其他工具集成或二次开发。 + +- **启用方式**:设置 → API 服务 → 启动服务 +- **默认端口**:5031 +- **访问地址**:`http://127.0.0.1:5031` +- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式 + +📖 完整接口文档:[docs/HTTP-API.md](docs/HTTP-API.md) ## 快速开始 diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md new file mode 100644 index 0000000..0a099fe --- /dev/null +++ b/docs/HTTP-API.md @@ -0,0 +1,312 @@ +# WeFlow HTTP API 接口文档 + +WeFlow 提供 HTTP API 服务,支持通过 HTTP 接口查询消息数据,支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出。 + +## 启用 API 服务 + +在设置页面 → API 服务 → 点击「启动服务」按钮。 + +默认端口:`5031` + +## 基础地址 + +``` +http://127.0.0.1:5031 +``` + +--- + +## 接口列表 + +### 1. 健康检查 + +检查 API 服务是否正常运行。 + +**请求** +``` +GET /health +``` + +**响应** +```json +{ + "status": "ok" +} +``` + +--- + +### 2. 获取消息列表 + +获取指定会话的消息,支持 ChatLab 格式输出。 + +**请求** +``` +GET /api/v1/messages +``` + +**参数** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) | +| `limit` | number | ❌ | 返回数量限制,默认 100 | +| `offset` | number | ❌ | 偏移量,用于分页,默认 0 | +| `start` | string | ❌ | 开始时间,格式 YYYYMMDD | +| `end` | string | ❌ | 结束时间,格式 YYYYMMDD | +| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 | +| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` | + +**示例请求** + +```bash +# 获取消息(原始格式) +GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50 + +# 获取消息(ChatLab 格式) +GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1 + +# 带时间范围查询 +GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100 +``` + +**响应(原始格式)** +```json +{ + "success": true, + "talker": "wxid_xxx", + "count": 50, + "hasMore": true, + "messages": [ + { + "localId": 123, + "talker": "wxid_xxx", + "type": 1, + "content": "消息内容", + "createTime": 1738713600000, + "isSelf": false, + "sender": "wxid_sender" + } + ] +} +``` + +**响应(ChatLab 格式)** +```json +{ + "chatlab": { + "version": "0.0.2", + "exportedAt": 1738713600000, + "generator": "WeFlow", + "description": "Exported from WeFlow" + }, + "meta": { + "name": "会话名称", + "platform": "wechat", + "type": "private", + "ownerId": "wxid_me" + }, + "members": [ + { + "platformId": "wxid_xxx", + "accountName": "用户名", + "groupNickname": "群昵称" + } + ], + "messages": [ + { + "sender": "wxid_xxx", + "accountName": "用户名", + "timestamp": 1738713600000, + "type": 0, + "content": "消息内容" + } + ] +} +``` + +--- + +### 3. 获取会话列表 + +获取所有会话列表。 + +**请求** +``` +GET /api/v1/sessions +``` + +**参数** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID | +| `limit` | number | ❌ | 返回数量限制,默认 100 | + +**示例请求** +```bash +GET http://127.0.0.1:5031/api/v1/sessions + +GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20 +``` + +**响应** +```json +{ + "success": true, + "count": 50, + "total": 100, + "sessions": [ + { + "username": "wxid_xxx", + "displayName": "用户名", + "lastMessage": "最后一条消息", + "lastTime": 1738713600000, + "unreadCount": 0 + } + ] +} +``` + +--- + +### 4. 获取联系人列表 + +获取所有联系人信息。 + +**请求** +``` +GET /api/v1/contacts +``` + +**参数** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| `keyword` | string | ❌ | 搜索关键词 | +| `limit` | number | ❌ | 返回数量限制,默认 100 | + +**示例请求** +```bash +GET http://127.0.0.1:5031/api/v1/contacts + +GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三 +``` + +**响应** +```json +{ + "success": true, + "count": 50, + "contacts": [ + { + "userName": "wxid_xxx", + "alias": "微信号", + "nickName": "昵称", + "remark": "备注名" + } + ] +} +``` + +--- + +## ChatLab 格式说明 + +ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2。 + +### 消息类型映射 + +| ChatLab Type | 值 | 说明 | +|--------------|-----|------| +| TEXT | 0 | 文本消息 | +| IMAGE | 1 | 图片 | +| VOICE | 2 | 语音 | +| VIDEO | 3 | 视频 | +| FILE | 4 | 文件 | +| EMOJI | 5 | 表情 | +| LINK | 7 | 链接 | +| LOCATION | 8 | 位置 | +| RED_PACKET | 20 | 红包 | +| TRANSFER | 21 | 转账 | +| CALL | 23 | 通话 | +| SYSTEM | 80 | 系统消息 | +| RECALL | 81 | 撤回消息 | +| OTHER | 99 | 其他 | + +--- + +## 使用示例 + +### PowerShell + +```powershell +# 健康检查 +Invoke-RestMethod http://127.0.0.1:5031/health + +# 获取会话列表 +Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions + +# 获取消息 +Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10" + +# 获取 ChatLab 格式 +Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10 +``` + +### cURL + +```bash +# 健康检查 +curl http://127.0.0.1:5031/health + +# 获取会话列表 +curl http://127.0.0.1:5031/api/v1/sessions + +# 获取消息(ChatLab 格式) +curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" +``` + +### Python + +```python +import requests + +BASE_URL = "http://127.0.0.1:5031" + +# 获取会话列表 +sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json() +print(sessions) + +# 获取消息 +messages = requests.get(f"{BASE_URL}/api/v1/messages", params={ + "talker": "wxid_xxx", + "limit": 100, + "chatlab": 1 +}).json() +print(messages) +``` + +### JavaScript / Node.js + +```javascript +const BASE_URL = "http://127.0.0.1:5031"; + +// 获取会话列表 +const sessions = await fetch(`${BASE_URL}/api/v1/sessions`).then(r => r.json()); +console.log(sessions); + +// 获取消息(ChatLab 格式) +const messages = await fetch(`${BASE_URL}/api/v1/messages?talker=wxid_xxx&chatlab=1`) + .then(r => r.json()); +console.log(messages); +``` + +--- + +## 注意事项 + +1. API 仅监听本地地址 `127.0.0.1`,不对外网开放 +2. 需要先连接数据库才能查询数据 +3. 时间参数格式为 `YYYYMMDD`(如 20260205) +4. 支持 CORS,可从浏览器前端直接调用 diff --git a/electron/main.ts b/electron/main.ts index b6637a4..92564f8 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -23,6 +23,7 @@ import { contactExportService } from './services/contactExportService' import { windowsHelloService } from './services/windowsHelloService' import { llamaService } from './services/llamaService' import { registerNotificationHandlers, showNotification } from './windows/notificationWindow' +import { httpService } from './services/httpService' // 配置自动更新 @@ -1282,6 +1283,23 @@ function registerIpcHandlers() { }) }) + // HTTP API 服务 + ipcMain.handle('http:start', async (_, port?: number) => { + return httpService.start(port || 5031) + }) + + ipcMain.handle('http:stop', async () => { + await httpService.stop() + return { success: true } + }) + + ipcMain.handle('http:status', async () => { + return { + running: httpService.isRunning(), + port: httpService.getPort() + } + }) + } // 主窗口引用 diff --git a/electron/preload.ts b/electron/preload.ts index ce5e94f..715d548 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -286,5 +286,12 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('llama:downloadProgress', listener) return () => ipcRenderer.removeListener('llama:downloadProgress', listener) } + }, + + // HTTP API 服务 + http: { + start: (port?: number) => ipcRenderer.invoke('http:start', port), + stop: () => ipcRenderer.invoke('http:stop'), + status: () => ipcRenderer.invoke('http:status') } }) diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts index a4a31d5..88d062e 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -193,11 +193,15 @@ class AnnualReportService { if (!raw) return '' if (typeof raw === 'string') { if (raw.length === 0) return '' - if (this.looksLikeHex(raw)) { + // 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码 + // 短字符串(如 "123456" 等纯数字)容易被误判为 hex + if (raw.length > 16 && this.looksLikeHex(raw)) { const bytes = Buffer.from(raw, 'hex') if (bytes.length > 0) return this.decodeBinaryContent(bytes) } - if (this.looksLikeBase64(raw)) { + // 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码 + // 短字符串(如 "test", "home" 等)容易被误判为 base64 + if (raw.length > 16 && this.looksLikeBase64(raw)) { try { const bytes = Buffer.from(raw, 'base64') return this.decodeBinaryContent(bytes) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 2a38eb4..eade809 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -2227,7 +2227,9 @@ class ChatService { if (raw.length === 0) return '' // 检查是否是 hex 编码 - if (this.looksLikeHex(raw)) { + // 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码 + // 短字符串(如 "123456" 等纯数字)容易被误判为 hex + if (raw.length > 16 && this.looksLikeHex(raw)) { const bytes = Buffer.from(raw, 'hex') if (bytes.length > 0) { const result = this.decodeBinaryContent(bytes, raw) @@ -2237,7 +2239,9 @@ class ChatService { } // 检查是否是 base64 编码 - if (this.looksLikeBase64(raw)) { + // 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码 + // 短字符串(如 "test", "home" 等)容易被误判为 base64 + if (raw.length > 16 && this.looksLikeBase64(raw)) { try { const bytes = Buffer.from(raw, 'base64') return this.decodeBinaryContent(bytes, raw) diff --git a/electron/services/dualReportService.ts b/electron/services/dualReportService.ts index a4305c3..f62f947 100644 --- a/electron/services/dualReportService.ts +++ b/electron/services/dualReportService.ts @@ -106,11 +106,15 @@ class DualReportService { if (!raw) return '' if (typeof raw === 'string') { if (raw.length === 0) return '' - if (this.looksLikeHex(raw)) { + // 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码 + // 短字符串(如 "123456" 等纯数字)容易被误判为 hex + if (raw.length > 16 && this.looksLikeHex(raw)) { const bytes = Buffer.from(raw, 'hex') if (bytes.length > 0) return this.decodeBinaryContent(bytes) } - if (this.looksLikeBase64(raw)) { + // 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码 + // 短字符串(如 "test", "home" 等)容易被误判为 base64 + if (raw.length > 16 && this.looksLikeBase64(raw)) { try { const bytes = Buffer.from(raw, 'base64') return this.decodeBinaryContent(bytes) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 86b7d77..db7c7f4 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -270,11 +270,14 @@ class ExportService { if (/^[0-9]+$/.test(raw)) { return raw } - if (this.looksLikeHex(raw)) { + // 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码 + if (raw.length > 16 && this.looksLikeHex(raw)) { const bytes = Buffer.from(raw, 'hex') if (bytes.length > 0) return this.decodeBinaryContent(bytes) } - if (this.looksLikeBase64(raw)) { + // 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码 + // 短字符串(如 "test", "home" 等)容易被误判为 base64 + if (raw.length > 16 && this.looksLikeBase64(raw)) { try { const bytes = Buffer.from(raw, 'base64') return this.decodeBinaryContent(bytes) @@ -587,6 +590,47 @@ class ExportService { return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)/, '') } + /** + * 从撤回消息内容中提取撤回者的 wxid + * 撤回消息 XML 格式通常包含 等字段 + * 以及撤回者的 wxid 在某些字段中 + * @returns { isRevoke: true, isSelfRevoke: true } - 是自己撤回的消息 + * @returns { isRevoke: true, revokerWxid: string } - 是别人撤回的消息,提取到撤回者 + * @returns { isRevoke: false } - 不是撤回消息 + */ + private extractRevokerInfo(content: string): { isRevoke: boolean; isSelfRevoke?: boolean; revokerWxid?: string } { + if (!content) return { isRevoke: false } + + // 检查是否是撤回消息 + if (!content.includes('revokemsg') && !content.includes('撤回')) { + return { isRevoke: false } + } + + // 检查是否是 "你撤回了" - 自己撤回 + if (content.includes('你撤回')) { + return { isRevoke: true, isSelfRevoke: true } + } + + // 尝试从 标签提取(格式: wxid_xxx) + const sessionMatch = /([^<]+)<\/session>/i.exec(content) + if (sessionMatch) { + const session = sessionMatch[1].trim() + // 如果 session 是 wxid 格式,返回它 + if (session.startsWith('wxid_') || /^[a-zA-Z][a-zA-Z0-9_-]+$/.test(session)) { + return { isRevoke: true, revokerWxid: session } + } + } + + // 尝试从 提取 + const fromUserMatch = /([^<]+)<\/fromusername>/i.exec(content) + if (fromUserMatch) { + return { isRevoke: true, revokerWxid: fromUserMatch[1].trim() } + } + + // 是撤回消息但无法提取撤回者 + return { isRevoke: true } + } + private extractXmlValue(xml: string, tagName: string): string { const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\/${tagName}>`, 'i') const match = regex.exec(xml) @@ -1405,7 +1449,30 @@ class ExportService { const isSend = parseInt(isSendRaw, 10) === 1 const localId = parseInt(row.local_id || row.localId || '0', 10) - const actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId) + // 确定实际发送者 + let actualSender: string + if (localType === 10000 || localType === 266287972401) { + // 系统消息特殊处理 + const revokeInfo = this.extractRevokerInfo(content) + if (revokeInfo.isRevoke) { + // 撤回消息 + if (revokeInfo.isSelfRevoke) { + // "你撤回了" - 发送者是当前用户 + actualSender = cleanedMyWxid + } else if (revokeInfo.revokerWxid) { + // 提取到了撤回者的 wxid + actualSender = revokeInfo.revokerWxid + } else { + // 无法确定撤回者,使用 sessionId + actualSender = sessionId + } + } else { + // 普通系统消息(如"xxx加入群聊"),发送者是群聊ID + actualSender = sessionId + } + } else { + actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId) + } senderSet.add(actualSender) // 提取媒体相关字段 @@ -1849,6 +1916,24 @@ class ExportService { await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) } + // ========== 获取群昵称并更新到 memberSet ========== + const groupNicknamesMap = isGroup + ? await this.getGroupNicknamesForRoom(sessionId) + : new Map() + + // 将群昵称更新到 memberSet 中 + if (isGroup && groupNicknamesMap.size > 0) { + for (const [username, info] of collected.memberSet) { + // 尝试多种方式查找群昵称(支持大小写) + const groupNickname = groupNicknamesMap.get(username) + || groupNicknamesMap.get(username.toLowerCase()) + || '' + if (groupNickname) { + info.member.groupNickname = groupNickname + } + } + } + allMessages.sort((a, b) => a.createTime - b.createTime) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) @@ -1925,6 +2010,11 @@ class ExportService { groupNickname: undefined } + // 如果 memberInfo 中没有群昵称,尝试从 groupNicknamesMap 获取 + const groupNickname = memberInfo.groupNickname + || (isGroup ? (groupNicknamesMap.get(msg.senderUsername) || groupNicknamesMap.get(msg.senderUsername?.toLowerCase()) || '') : '') + || '' + // 确定消息内容 let content: string | null if (msg.localType === 34 && options.exportVoiceAsText) { @@ -1937,7 +2027,7 @@ class ExportService { const message: ChatLabMessage = { sender: msg.senderUsername, accountName: memberInfo.accountName, - groupNickname: memberInfo.groupNickname, + groupNickname: groupNickname || undefined, timestamp: msg.createTime, type: this.convertMessageType(msg.localType, msg.content), content: content diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts new file mode 100644 index 0000000..e620003 --- /dev/null +++ b/electron/services/httpService.ts @@ -0,0 +1,584 @@ +/** + * 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() diff --git a/package.json b/package.json index ec17f15..828afe1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "weflow", - "version": "1.5.2", + "version": "1.5.3", "description": "WeFlow", "main": "dist-electron/main.js", "author": "cc", diff --git a/src/components/GlobalSessionMonitor.tsx b/src/components/GlobalSessionMonitor.tsx index 9077892..ae86379 100644 --- a/src/components/GlobalSessionMonitor.tsx +++ b/src/components/GlobalSessionMonitor.tsx @@ -81,6 +81,11 @@ export function GlobalSessionMonitor() { } const checkForNewMessages = async (oldSessions: ChatSession[], newSessions: ChatSession[]) => { + if (!oldSessions || oldSessions.length === 0) { + console.log('[NotificationFilter] Skipping check on initial load (empty baseline)') + return + } + const oldMap = new Map(oldSessions.map(s => [s.username, s])) for (const newSession of newSessions) { @@ -140,6 +145,14 @@ export function GlobalSessionMonitor() { } } + // 新增:如果未读数量没有增加,说明可能是自己在其他设备回复(或者已读),不弹通知 + const oldUnread = oldSession ? oldSession.unreadCount : 0 + const newUnread = newSession.unreadCount + if (newUnread <= oldUnread) { + // 仅仅是状态同步(如自己在手机上发消息 or 已读),跳过通知 + continue + } + let title = newSession.displayName || newSession.username let avatarUrl = newSession.avatarUrl let content = newSession.summary || '[新消息]' diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 2e9e66f..5b9503e 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -1191,6 +1191,109 @@ } } +// 通用弹窗覆盖层 +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.2s ease; +} + +// API 警告弹窗 +.api-warning-modal { + width: 420px; + background: var(--bg-primary); + border-radius: 16px; + overflow: hidden; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2); + animation: slideUp 0.25s ease; + + .modal-header { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); + + svg { + color: var(--warning, #f59e0b); + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + } + + .modal-body { + padding: 20px 24px; + + .warning-text { + margin: 0 0 16px; + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; + } + + .warning-list { + display: flex; + flex-direction: column; + gap: 10px; + + .warning-item { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + + .bullet { + color: var(--warning, #f59e0b); + font-weight: bold; + } + } + } + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px 24px; + border-top: 1px solid var(--border-color); + background: var(--bg-secondary); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} // 协议弹窗 .agreement-overlay { @@ -1855,4 +1958,148 @@ to { transform: rotate(360deg); } +} + +// API 地址显示样式 +.api-url-display { + display: flex; + gap: 8px; + margin-top: 8px; + + input { + flex: 1; + font-family: 'SF Mono', 'Consolas', monospace; + font-size: 13px; + } + + .btn { + flex-shrink: 0; + } +} + +// API 服务设置样式 +.status-badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + + &.running { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + } + + &.stopped { + background: rgba(156, 163, 175, 0.15); + color: var(--text-tertiary); + } +} + +.api-url { + display: inline-block; + padding: 8px 14px; + background: var(--bg-tertiary); + border-radius: 6px; + font-family: 'SF Mono', 'Consolas', monospace; + font-size: 13px; + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.api-docs { + display: flex; + flex-direction: column; + gap: 12px; +} + +.api-item { + padding: 12px 16px; + background: var(--bg-tertiary); + border-radius: 8px; + border: 1px solid var(--border-color); + + .api-endpoint { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; + + .method { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + + &.get { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + } + + &.post { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + } + } + + code { + font-family: 'SF Mono', 'Consolas', monospace; + font-size: 13px; + color: var(--text-primary); + } + } + + .api-desc { + font-size: 12px; + color: var(--text-secondary); + margin: 0 0 8px 0; + } + + .api-params { + display: flex; + flex-wrap: wrap; + gap: 6px; + + .param { + display: inline-block; + padding: 2px 8px; + background: var(--bg-secondary); + border-radius: 4px; + font-size: 11px; + color: var(--text-tertiary); + + code { + color: var(--primary); + font-family: 'SF Mono', 'Consolas', monospace; + } + } + } +} + +.code-block { + padding: 12px 16px; + background: var(--bg-tertiary); + border-radius: 8px; + border: 1px solid var(--border-color); + overflow-x: auto; + + code { + font-family: 'SF Mono', 'Consolas', monospace; + font-size: 12px; + color: var(--text-primary); + white-space: nowrap; + } +} + +.btn-sm { + padding: 4px 10px !important; + font-size: 12px !important; + + svg { + width: 14px; + height: 14px; + } } \ No newline at end of file diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index d3d4adf..5d24a7f 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -9,12 +9,12 @@ import { Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, RotateCcw, Trash2, Plug, Check, Sun, Moon, Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic, - ShieldCheck, Fingerprint, Lock, KeyRound, Bell + ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe } from 'lucide-react' import { Avatar } from '../components/Avatar' import './SettingsPage.scss' -type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'security' | 'about' +type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'api' | 'security' | 'about' const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, @@ -23,6 +23,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'models', label: '模型管理', icon: Mic }, { id: 'export', label: '导出', icon: Download }, { id: 'cache', label: '缓存', icon: HardDrive }, + { id: 'api', label: 'API 服务', icon: Globe }, { id: 'security', label: '安全', icon: ShieldCheck }, { id: 'about', label: '关于', icon: Info } ] @@ -137,6 +138,13 @@ function SettingsPage() { const [confirmPassword, setConfirmPassword] = useState('') const [isSettingHello, setIsSettingHello] = useState(false) + // HTTP API 设置 state + const [httpApiEnabled, setHttpApiEnabled] = useState(false) + const [httpApiPort, setHttpApiPort] = useState(5031) + const [httpApiRunning, setHttpApiRunning] = useState(false) + const [isTogglingApi, setIsTogglingApi] = useState(false) + const [showApiWarning, setShowApiWarning] = useState(false) + const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache // 检查 Hello 可用性 @@ -146,6 +154,22 @@ function SettingsPage() { } }, []) + // 检查 HTTP API 服务状态 + useEffect(() => { + const checkApiStatus = async () => { + try { + const status = await window.electronAPI.http.status() + setHttpApiRunning(status.running) + if (status.port) { + setHttpApiPort(status.port) + } + } catch (e) { + console.error('检查 API 状态失败:', e) + } + } + checkApiStatus() + }, []) + async function sha256(message: string) { const msgBuffer = new TextEncoder().encode(message) const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer) @@ -1835,6 +1859,147 @@ function SettingsPage() { ) + // HTTP API 服务控制 + const handleToggleApi = async () => { + if (isTogglingApi) return + + // 启动时显示警告弹窗 + if (!httpApiRunning) { + setShowApiWarning(true) + return + } + + setIsTogglingApi(true) + try { + await window.electronAPI.http.stop() + setHttpApiRunning(false) + showMessage('API 服务已停止', true) + } catch (e: any) { + showMessage(`操作失败: ${e}`, false) + } finally { + setIsTogglingApi(false) + } + } + + // 确认启动 API 服务 + const confirmStartApi = async () => { + setShowApiWarning(false) + setIsTogglingApi(true) + try { + const result = await window.electronAPI.http.start(httpApiPort) + if (result.success) { + setHttpApiRunning(true) + if (result.port) setHttpApiPort(result.port) + showMessage(`API 服务已启动,端口 ${result.port}`, true) + } else { + showMessage(`启动失败: ${result.error}`, false) + } + } catch (e: any) { + showMessage(`操作失败: ${e}`, false) + } finally { + setIsTogglingApi(false) + } + } + + const handleCopyApiUrl = () => { + const url = `http://127.0.0.1:${httpApiPort}` + navigator.clipboard.writeText(url) + showMessage('已复制 API 地址', true) + } + + const renderApiTab = () => ( +
+
+ + 启用后可通过 HTTP 接口查询消息数据(仅限本机访问) +
+ + {httpApiRunning ? '运行中' : '已停止'} + + +
+
+ +
+ + API 服务监听的端口号(1024-65535) + setHttpApiPort(parseInt(e.target.value, 10) || 5031)} + disabled={httpApiRunning} + style={{ width: 120 }} + min={1024} + max={65535} + /> +
+ + {httpApiRunning && ( +
+ + 使用以下地址访问 API +
+ + +
+
+ )} + + {/* API 安全警告弹窗 */} + {showApiWarning && ( +
setShowApiWarning(false)}> +
e.stopPropagation()}> +
+ +

安全提示

+
+
+

启用 HTTP API 服务后,本机上的其他程序可通过接口访问您的聊天记录数据。

+
+
+ + 请确保您了解此功能的用途 +
+
+ + 不要在公共或不信任的网络环境下使用 +
+
+ + 此功能仅供高级用户或开发者使用 +
+
+
+
+ + +
+
+
+ )} +
+ ) + const handleSetupHello = async () => { setIsSettingHello(true) try { @@ -2075,6 +2240,7 @@ function SettingsPage() { {activeTab === 'models' && renderModelsTab()} {activeTab === 'export' && renderExportTab()} {activeTab === 'cache' && renderCacheTab()} + {activeTab === 'api' && renderApiTab()} {activeTab === 'security' && renderSecurityTab()} {activeTab === 'about' && renderAboutTab()}