diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da0a4fe..a5d9959 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -190,7 +190,6 @@ jobs: fi WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')" MAC_ASSET="$(pick_asset "\\.dmg$")" - LINUX_DEB_ASSET="$(pick_asset "\\.deb$")" LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")" LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")" @@ -204,7 +203,6 @@ jobs: WINDOWS_URL="$(build_link "$WINDOWS_ASSET")" WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")" MAC_URL="$(build_link "$MAC_ASSET")" - LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")" LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")" LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")" @@ -219,7 +217,6 @@ jobs: - Windows x64(Win10+): ${WINDOWS_URL:-$RELEASE_PAGE} - Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE} - macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE} - - Linux (.deb) (即将废弃): ${LINUX_DEB_URL:-$RELEASE_PAGE} - Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE} - linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE} diff --git a/README.md b/README.md index 7c4637f..bfd936a 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,15 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 |------|----------|--------| | Windows | Windows10+、x64(amd64) | `.exe` | | macOS | Apple Silicon(M 系列,arm64) | `.dmg` | -| Linux | x64 设备(amd64) | `.deb`、`.tar.gz` | +| Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` | ## 快速开始 若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。 +> ArchLinux 用户可以选择 `yay -S weflow` 快速安装 + ## 详细功能清单 当前版本已支持以下能力: diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index ca2a89a..052dd8a 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -1,6 +1,6 @@ # WeFlow HTTP API / Push 文档 -WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。 +WeFlow 提供本地 HTTP API(已支持GET 和 POST请求),便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。 ## 启用方式 @@ -11,17 +11,27 @@ WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、 - 基础地址:`http://127.0.0.1:5031` - 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端 +**状态记忆**:API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。 + +## 鉴权规范 + +**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一): + +1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>` +2. **Query 参数**: `?access_token=<您的Token>`(SSE 长连接推荐此方式) +3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求) + ## 接口列表 -- `GET /health` -- `GET /api/v1/health` -- `GET /api/v1/push/messages` -- `GET /api/v1/messages` -- `GET /api/v1/messages/new` -- `GET /api/v1/sessions` -- `GET /api/v1/contacts` -- `GET /api/v1/group-members` -- `GET /api/v1/media/*` +- `GET|POST /health` +- `GET|POST /api/v1/health` +- `GET|POST /api/v1/push/messages` +- `GET|POST /api/v1/messages` +- `GET|POST /api/v1/messages/new` +- `GET|POST /api/v1/sessions` +- `GET|POST /api/v1/contacts` +- `GET|POST /api/v1/group-members` +- `GET|POST /api/v1/media/*` --- @@ -80,7 +90,7 @@ GET /api/v1/push/messages ### 示例 ```bash -curl -N "http://127.0.0.1:5031/api/v1/push/messages" +curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN ``` 示例事件: @@ -94,6 +104,8 @@ data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123 ## 3. 获取消息 +> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) + 读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。 **请求** @@ -231,6 +243,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1& ## 4. 获取会话列表 +> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) + **请求** ```http @@ -276,6 +290,8 @@ GET /api/v1/sessions ## 5. 获取联系人列表 +> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) + **请求** ```http @@ -325,6 +341,8 @@ GET /api/v1/contacts ## 6. 获取群成员列表 +> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) + 返回群成员的 `wxid`、群昵称、备注、微信号等信息。 **请求** @@ -417,6 +435,8 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include ## 7. 访问导出媒体 +> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) + 通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。 **请求** @@ -461,19 +481,23 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif" ### 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" -Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1" +$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" } +$body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json + +Invoke-RestMethod -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json" ``` ### cURL ```bash -curl http://127.0.0.1:5031/health -curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" -curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三" -curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom" +# GET 带 Token Header +curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx" + +# POST 带 JSON Body +curl -X POST http://127.0.0.1:5031/api/v1/messages \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"talker": "xxx@chatroom", "chatlab": true}' ``` ### Python @@ -482,19 +506,21 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom" import requests BASE_URL = "http://127.0.0.1:5031" +headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"} -messages = requests.get( - f"{BASE_URL}/api/v1/messages", - params={"talker": "xxx@chatroom", "limit": 50} +# POST 方式获取消息 +messages = requests.post( + f"{BASE_URL}/api/v1/messages", + json={"talker": "xxx@chatroom", "limit": 50}, + headers=headers ).json() +# GET 方式获取群成员 members = requests.get( f"{BASE_URL}/api/v1/group-members", - params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1} + params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}, + headers=headers ).json() - -print(messages) -print(members) ``` --- diff --git a/electron/main.ts b/electron/main.ts index 8e44cae..8f37dca 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2199,6 +2199,13 @@ function registerIpcHandlers() { return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime) }) + ipcMain.handle( + 'groupAnalytics:getGroupMemberAnalytics', + async (_, chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => { + return groupAnalyticsService.getGroupMemberAnalytics(chatroomId, memberUsername, startTime, endTime) + } + ) + ipcMain.handle( 'groupAnalytics:getGroupMemberMessages', async ( @@ -2647,8 +2654,9 @@ function registerIpcHandlers() { }) // HTTP API 服务 - ipcMain.handle('http:start', async (_, port?: number) => { - return httpService.start(port || 5031) + ipcMain.handle('http:start', async (_, port?: number, host?: string) => { + const bindHost = typeof host === 'string' && host.trim() ? host.trim() : '127.0.0.1' + return httpService.start(port || 5031, bindHost) }) ipcMain.handle('http:stop', async () => { @@ -2867,6 +2875,8 @@ app.whenReady().then(async () => { // 启动时检测更新(不阻塞启动) checkForUpdatesOnStartup() + await httpService.autoStart() + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { mainWindow = createWindow() diff --git a/electron/preload.ts b/electron/preload.ts index 41d8246..bfa151d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -297,6 +297,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime), getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime), getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime), + getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime), getGroupMemberMessages: ( chatroomId: string, memberUsername: string, @@ -422,7 +423,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // HTTP API 服务 http: { - start: (port?: number) => ipcRenderer.invoke('http:start', port), + start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host), stop: () => ipcRenderer.invoke('http:stop'), status: () => ipcRenderer.invoke('http:status') } diff --git a/electron/services/config.ts b/electron/services/config.ts index c293ee1..5fdd609 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -52,13 +52,17 @@ interface ConfigSchema { notificationFilterMode: 'all' | 'whitelist' | 'blacklist' notificationFilterList: string[] messagePushEnabled: boolean + httpApiEnabled: boolean + httpApiPort: number + httpApiHost: string + httpApiToken: string windowCloseBehavior: 'ask' | 'tray' | 'quit' quoteLayout: 'quote-top' | 'quote-bottom' wordCloudExcludeWords: string[] } // 需要 safeStorage 加密的字段(普通模式) -const ENCRYPTED_STRING_KEYS: Set = new Set(['decryptKey', 'imageAesKey', 'authPassword']) +const ENCRYPTED_STRING_KEYS: Set = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken']) const ENCRYPTED_BOOL_KEYS: Set = new Set(['authEnabled', 'authUseHello']) const ENCRYPTED_NUMBER_KEYS: Set = new Set(['imageXorKey']) @@ -119,6 +123,10 @@ export class ConfigService { notificationPosition: 'top-right', notificationFilterMode: 'all', notificationFilterList: [], + httpApiToken: '', + httpApiEnabled: false, + httpApiPort: 5031, + httpApiHost: '127.0.0.1', messagePushEnabled: false, windowCloseBehavior: 'ask', quoteLayout: 'quote-top', @@ -662,11 +670,9 @@ export class ConfigService { // 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁 const rawDecryptKey: any = this.store.get('decryptKey') - if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) { - return true - } + return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX); + - return false } // === 工具方法 === diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 175c307..a244d16 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -5,6 +5,7 @@ import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { chatService } from './chatService' import type { Message } from './chatService' +import type { ChatStatistics } from './analyticsService' export interface GroupChatInfo { username: string @@ -49,6 +50,13 @@ export interface GroupMediaStats { total: number } +export interface GroupMemberAnalytics { + statistics: ChatStatistics + timeDistribution: Record + commonPhrases?: Array<{ phrase: string; count: number }> + commonEmojis?: Array<{ emoji: string; count: number }> +} + export interface GroupMemberMessagesPage { messages: Message[] hasMore: boolean @@ -797,7 +805,12 @@ class GroupAnalyticsService { return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized } - private extractRowSenderUsername(row: Record): string { + private extractRowSenderUsername(row: Record, myWxid?: string): string { + const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send + if (isSendRaw != null && parseInt(isSendRaw, 10) === 1 && myWxid) { + return myWxid + } + const candidates = [ row.sender_username, row.senderUsername, @@ -820,13 +833,33 @@ class GroupAnalyticsService { if (normalizedValue) return normalizedValue } } + + // Fallback: fast extract from raw content to avoid full parse + const rawContent = String(row.StrContent || row.message_content || row.content || row.msg_content || '').trim() + if (rawContent) { + const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|)/i.exec(rawContent) + if (match && match[1]) { + return match[1].trim() + } + } + return '' } private parseSingleMessageRow(row: Record): Message | null { try { const mapped = chatService.mapRowsToMessagesForApi([row]) - return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null + if (Array.isArray(mapped) && mapped.length > 0) { + const msg = mapped[0] + if (!msg.localType) { + msg.localType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10) + } + if (!msg.createTime) { + msg.createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10) + } + return msg + } + return null } catch { return null } @@ -881,7 +914,7 @@ class GroupAnalyticsService { if (rows.length === 0) break for (const row of rows) { - const senderFromRow = this.extractRowSenderUsername(row) + const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim()) if (senderFromRow && !matchesTargetSender(senderFromRow)) { continue } @@ -987,7 +1020,7 @@ class GroupAnalyticsService { const row = rows[index] consumedRows += 1 - const senderFromRow = this.extractRowSenderUsername(row) + const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim()) if (senderFromRow && !matchesTargetSender(senderFromRow)) { continue } @@ -1467,6 +1500,154 @@ class GroupAnalyticsService { } } + async getGroupMemberAnalytics( + chatroomId: string, + memberUsername: string, + startTime?: number, + endTime?: number + ): Promise<{ success: boolean; data?: GroupMemberAnalytics; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success) return { success: false, error: conn.error } + + const normalizedChatroomId = String(chatroomId || '').trim() + const normalizedMemberUsername = String(memberUsername || '').trim() + + const batchSize = 10000 + const senderMatchCache = new Map() + const matchesTargetSender = (sender: string | null | undefined): boolean => { + const key = String(sender || '').trim().toLowerCase() + if (!key) return false + const cached = senderMatchCache.get(key) + if (typeof cached === 'boolean') return cached + const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender) + senderMatchCache.set(key, matched) + return matched + } + + const cursorResult = await this.openMemberMessageCursor(normalizedChatroomId, batchSize, true, startTime || 0, endTime || 0) + if (!cursorResult.success || !cursorResult.cursor) { + return { success: false, error: cursorResult.error || '创建游标失败' } + } + + const cursor = cursorResult.cursor + const stats: ChatStatistics = { + totalMessages: 0, + textMessages: 0, + imageMessages: 0, + voiceMessages: 0, + videoMessages: 0, + emojiMessages: 0, + otherMessages: 0, + sentMessages: 0, // In group, we only fetch messages of this member, so sentMessages = totalMessages + receivedMessages: 0, // No meaning here + firstMessageTime: null, + lastMessageTime: null, + activeDays: 0, + messageTypeCounts: {} + } + + const hourlyDistribution: Record = {} + for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0 + const dailySet = new Set() + const textTypes = [1, 244813135921] + + const phraseCounts = new Map() + const emojiCounts = new Map() + + const myWxid = String(this.configService.get('myWxid') || '').trim() + + try { + while (true) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) { + return { success: false, error: batch.error || '获取分析数据失败' } + } + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + if (rows.length === 0) break + + for (const row of rows) { + let senderFromRow = this.extractRowSenderUsername(row, myWxid) + + const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send + const isSend = isSendRaw != null ? parseInt(isSendRaw, 10) === 1 : false + + if (isSend) { + senderFromRow = myWxid + } + + if (!senderFromRow || !matchesTargetSender(senderFromRow)) { + continue + } + + const msgType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10) + const createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10) + + let content = String(row.StrContent || row.message_content || row.content || row.msg_content || '') + if (content) { + content = content.replace(/^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|)/i, '') + } + + stats.totalMessages++ + if (textTypes.includes(msgType)) { + stats.textMessages++ + if (content) { + const text = content.trim() + if (text && text.length <= 20) { + phraseCounts.set(text, (phraseCounts.get(text) || 0) + 1) + } + const emojiMatches = text.match(/\[.*?\]/g) + if (emojiMatches) { + for (const em of emojiMatches) { + emojiCounts.set(em, (emojiCounts.get(em) || 0) + 1) + } + } + } + } + else if (msgType === 3) stats.imageMessages++ + else if (msgType === 34) stats.voiceMessages++ + else if (msgType === 43) stats.videoMessages++ + else if (msgType === 47) stats.emojiMessages++ + else stats.otherMessages++ + + stats.sentMessages++ + + stats.messageTypeCounts[msgType] = (stats.messageTypeCounts[msgType] || 0) + 1 + + if (createTime > 0) { + if (stats.firstMessageTime === null || createTime < stats.firstMessageTime) stats.firstMessageTime = createTime + if (stats.lastMessageTime === null || createTime > stats.lastMessageTime) stats.lastMessageTime = createTime + + const d = new Date(createTime * 1000) + const hour = d.getHours() + hourlyDistribution[hour]++ + dailySet.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`) + } + } + if (!batch.hasMore) break + } + } finally { + await wcdbService.closeMessageCursor(cursor) + } + + stats.activeDays = dailySet.size + + const commonPhrases = Array.from(phraseCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([phrase, count]) => ({ phrase, count })) + + const commonEmojis = Array.from(emojiCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([emoji, count]) => ({ emoji, count })) + + return { success: true, data: { statistics: stats, timeDistribution: hourlyDistribution, commonPhrases, commonEmojis } } + } catch (e) { + return { success: false, error: String(e) } + } + } + async exportGroupMemberMessages( chatroomId: string, memberUsername: string, diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index b59a014..02fa030 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -101,6 +101,7 @@ class HttpService { private server: http.Server | null = null private configService: ConfigService private port: number = 5031 + private host: string = '127.0.0.1' private running: boolean = false private connections: Set = new Set() private messagePushClients: Set = new Set() @@ -114,12 +115,13 @@ class HttpService { /** * 启动 HTTP 服务 */ - async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> { + async start(port: number = 5031, host: string = '127.0.0.1'): Promise<{ success: boolean; port?: number; error?: string }> { if (this.running && this.server) { return { success: true, port: this.port } } this.port = port + this.host = host return new Promise((resolve) => { this.server = http.createServer((req, res) => this.handleRequest(req, res)) @@ -153,10 +155,10 @@ class HttpService { } }) - this.server.listen(this.port, '127.0.0.1', () => { + this.server.listen(this.port, this.host, () => { this.running = true this.startMessagePushHeartbeat() - console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`) + console.log(`[HttpService] HTTP API server started on http://${this.host}:${this.port}`) resolve({ success: true, port: this.port }) }) }) @@ -225,7 +227,7 @@ class HttpService { } getMessagePushStreamUrl(): string { - return `http://127.0.0.1:${this.port}/api/v1/push/messages` + return `http://${this.host}:${this.port}/api/v1/push/messages` } broadcastMessagePush(payload: Record): void { @@ -246,49 +248,116 @@ class HttpService { } } - /** - * 处理 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/push/messages') { - this.handleMessagePushStream(req, res) - } 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 if (pathname === '/api/v1/group-members') { - await this.handleGroupMembers(url, res) - } else if (pathname.startsWith('/api/v1/media/')) { - this.handleMediaRequest(pathname, res) - } else { - this.sendError(res, 404, 'Not Found') + async autoStart(): Promise { + const enabled = this.configService.get('httpApiEnabled') + if (enabled) { + const port = Number(this.configService.get('httpApiPort')) || 5031 + const host = String(this.configService.get('httpApiHost') || '127.0.0.1').trim() || '127.0.0.1' + try { + await this.start(port, host) + console.log(`[HttpService] Auto-started on port ${port}`) + } catch (err) { + console.error('[HttpService] Auto-start failed:', err) } - } catch (error) { - console.error('[HttpService] Request error:', error) - this.sendError(res, 500, String(error)) } } + /** + * 解析 POST 请求的 JSON Body + */ + private async parseBody(req: http.IncomingMessage): Promise> { + if (req.method !== 'POST') return {} + return new Promise((resolve) => { + let body = '' + req.on('data', chunk => { body += chunk.toString() }) + req.on('end', () => { + try { + resolve(JSON.parse(body)) + } catch { + resolve({}) + } + }) + req.on('error', () => resolve({})) + }) + } + + /** + * 鉴权拦截器 + */ + private verifyToken(req: http.IncomingMessage, url: URL, body: Record): boolean { + const expectedToken = String(this.configService.get('httpApiToken') || '').trim() + if (!expectedToken) return true + + const authHeader = req.headers.authorization + if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) { + const token = authHeader.substring(7).trim() + if (token === expectedToken) return true + } + + const queryToken = url.searchParams.get('access_token') + if (queryToken && queryToken.trim() === expectedToken) return true + + const bodyToken = body['access_token'] + return !!(bodyToken && String(bodyToken).trim() === expectedToken); + + + } + + /** + * 处理 HTTP 请求 (重构后) + */ + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') + + if (req.method === 'OPTIONS') { + res.writeHead(204) + res.end() + return + } + + const url = new URL(req.url || '/', `http://${this.host}:${this.port}`) + const pathname = url.pathname + + try { + const bodyParams = await this.parseBody(req) + + for (const [key, value] of Object.entries(bodyParams)) { + if (!url.searchParams.has(key)) { + url.searchParams.set(key, String(value)) + } + } + + if (pathname !== '/health' && pathname !== '/api/v1/health') { + if (!this.verifyToken(req, url, bodyParams)) { + this.sendError(res, 401, 'Unauthorized: Invalid or missing access_token') + return + } + } + + if (pathname === '/health' || pathname === '/api/v1/health') { + this.sendJson(res, { status: 'ok' }) + } else if (pathname === '/api/v1/push/messages') { + this.handleMessagePushStream(req, res) + } 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 if (pathname === '/api/v1/group-members') { + await this.handleGroupMembers(url, res) + } else if (pathname.startsWith('/api/v1/media/')) { + this.handleMediaRequest(pathname, res) + } else { + this.sendError(res, 404, 'Not Found') + } + } catch (error) { + console.error('[HttpService] Request error:', error) + this.sendError(res, 500, String(error)) + } + } private startMessagePushHeartbeat(): void { if (this.messagePushHeartbeatTimer) return this.messagePushHeartbeatTimer = setInterval(() => { @@ -895,7 +964,7 @@ class HttpService { parsedContent: msg.parsedContent, mediaType: media?.kind, mediaFileName: media?.fileName, - mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined, + mediaUrl: media ? `http://${this.host}:${this.port}/api/v1/media/${media.relativePath}` : undefined, mediaLocalPath: media?.fullPath } } @@ -1165,7 +1234,7 @@ class HttpService { type: this.mapMessageType(msg.localType, msg), content: this.getMessageContent(msg), platformMessageId: msg.serverId ? String(msg.serverId) : undefined, - mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined + mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined } }) diff --git a/electron/services/voiceTranscribeService.ts b/electron/services/voiceTranscribeService.ts index 107cbc5..952bac9 100644 --- a/electron/services/voiceTranscribeService.ts +++ b/electron/services/voiceTranscribeService.ts @@ -75,6 +75,14 @@ export class VoiceTranscribeService { if (candidates.length === 0) { console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`) } + } else if (process.platform === 'win32') { + // Windows: 把 sherpa-onnx DLL 所在目录加到 PATH,否则 native module 找不到依赖 + const existing = env['PATH'] || '' + const merged = [...candidates, ...existing.split(';').filter(Boolean)] + env['PATH'] = Array.from(new Set(merged)).join(';') + if (candidates.length === 0) { + console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`) + } } return env diff --git a/package.json b/package.json index 0b237bc..6b32450 100644 --- a/package.json +++ b/package.json @@ -96,12 +96,17 @@ "icon": "public/icon.png", "target": [ "appimage", - "deb", "tar.gz" ], "category": "Utility", "executableName": "weflow", - "synopsis": "WeFlow for Linux" + "synopsis": "WeFlow for Linux", + "extraFiles": [ + { + "from": "resources/linux/install.sh", + "to": "install.sh" + } + ] }, "nsis": { "oneClick": false, diff --git a/resources/linux/install.sh b/resources/linux/install.sh new file mode 100644 index 0000000..eacc714 --- /dev/null +++ b/resources/linux/install.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +set -e + +APP_NAME="weflow" +APP_EXEC="weflow" +OPT_DIR="/opt/$APP_NAME" +BIN_LINK="/usr/bin/$APP_NAME" +DESKTOP_DIR="/usr/share/applications" +ICON_DIR="/usr/share/pixmaps" + +if [ "$EUID" -ne 0 ]; then + echo "❌ 请使用 root 权限运行此脚本 (例如: sudo ./install.sh)" + exit 1 +fi + +echo "🚀 开始安装 $APP_NAME..." + +echo "📦 正在复制文件到 $OPT_DIR..." +rm -rf "$OPT_DIR" +mkdir -p "$OPT_DIR" +cp -r ./* "$OPT_DIR/" +chmod -R 755 "$OPT_DIR" +chmod +x "$OPT_DIR/$APP_EXEC" + +echo "🔗 正在创建软链接 $BIN_LINK..." +ln -sf "$OPT_DIR/$APP_EXEC" "$BIN_LINK" + +echo "📝 正在创建桌面快捷方式..." +cat <"$DESKTOP_DIR/${APP_NAME}.desktop" +[Desktop Entry] +Name=WeFlow +Exec=$OPT_DIR/$APP_EXEC %U +Terminal=false +Type=Application +Icon=$APP_NAME +StartupWMClass=WeFlow +Comment=A local WeChat database decryption and analysis tool +Categories=Utility; +EOF +chmod 644 "$DESKTOP_DIR/${APP_NAME}.desktop" + +echo "🖼️ 正在安装图标..." +if [ -f "$OPT_DIR/resources/icon.png" ]; then + cp "$OPT_DIR/resources/icon.png" "$ICON_DIR/${APP_NAME}.png" + chmod 644 "$ICON_DIR/${APP_NAME}.png" +elif [ -f "$OPT_DIR/icon.png" ]; then + cp "$OPT_DIR/icon.png" "$ICON_DIR/${APP_NAME}.png" + chmod 644 "$ICON_DIR/${APP_NAME}.png" +else + echo "⚠️ 警告: 未找到图标文件,跳过图标安装。" +fi + +if command -v update-desktop-database >/dev/null 2>&1; then + echo "🔄 更新桌面数据库..." + update-desktop-database "$DESKTOP_DIR" +fi + +echo "✅ 安装完成!你现在可以在应用菜单中找到 WeFlow,或者在终端输入 'weflow' 启动。" diff --git a/resources/xkey_helper_linux b/resources/xkey_helper_linux index 54f7cb3..8deb6d4 100755 Binary files a/resources/xkey_helper_linux and b/resources/xkey_helper_linux differ diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index b1b0eab..e55c30f 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -834,11 +834,13 @@ } .member-export-panel, -.member-messages-panel { +.member-messages-panel, +.member-analytics-panel { display: flex; flex-direction: column; gap: 16px; min-height: 0; + flex: 1; .member-export-empty { padding: 20px; @@ -1521,29 +1523,73 @@ } } - .stats-cards { + .stats-overview { display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 12px; - margin-bottom: 20px; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 24px; + padding-top: 10px; + } - .stat-card { - background: transparent; + .stat-card { + display: flex; + align-items: center; + gap: 16px; + padding: 20px; + background: var(--card-bg); + border-radius: 12px; + border: 1px solid var(--border-color); + + .stat-icon { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: var(--primary-light); border-radius: 12px; - padding: 16px; - text-align: center; + color: var(--primary); + } - .value { - display: block; + .stat-info { + display: flex; + flex-direction: column; + gap: 4px; + + .stat-value { font-size: 24px; font-weight: 600; - color: var(--primary); - margin-bottom: 4px; + color: var(--text-primary); } - .label { + .stat-label { font-size: 13px; - color: var(--text-secondary); + color: var(--text-tertiary); + } + } + } + + .charts-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + margin-bottom: 24px; + + .chart-card { + background: var(--card-bg); + border-radius: 12px; + border: 1px solid var(--border-color); + padding: 20px; + + &.wide { + grid-column: span 2; + } + + h3 { + font-size: 15px; + font-weight: 500; + color: var(--text-primary); + margin: 0 0 16px; } } } diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index db14c4d..7a6470f 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useLocation } from 'react-router-dom' -import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare } from 'lucide-react' +import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare, Calendar, PieChart, Hash, Smile } from 'lucide-react' import { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' @@ -37,7 +37,7 @@ interface GroupMessageRank { messageCount: number } -type AnalysisFunction = 'members' | 'memberMessages' | 'ranking' | 'activeHours' | 'mediaStats' +type AnalysisFunction = 'members' | 'memberMessages' | 'memberAnalytics' | 'ranking' | 'activeHours' | 'mediaStats' type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' interface MemberMessageExportOptions { @@ -167,6 +167,8 @@ function GroupAnalyticsPage() { const [isExportingMembers, setIsExportingMembers] = useState(false) const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false) const [memberMessages, setMemberMessages] = useState([]) + const [memberAnalyticsData, setMemberAnalyticsData] = useState(null) + const [analyticsError, setAnalyticsError] = useState(null) const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false) const [memberMessagesCursor, setMemberMessagesCursor] = useState(0) const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false) @@ -524,6 +526,7 @@ function GroupAnalyticsPage() { break } case 'memberMessages': { + resetMemberMessageState(false) updateBackgroundTask(taskId, { detail: '正在读取成员列表与消息', progressText: '成员消息' @@ -566,7 +569,57 @@ function GroupAnalyticsPage() { }) break } + case 'memberAnalytics': { + setMemberAnalyticsData(null) + setAnalyticsError(null) + updateBackgroundTask(taskId, { + detail: '正在读取成员列表与消息分析', + progressText: '成员分析' + }) + const result = await window.electronAPI.groupAnalytics.getGroupMembers(targetGroup.username) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员分析未继续写入' }) + return + } + if (!result.success || !result.data) { + finishBackgroundTask(taskId, 'failed', { detail: result.error || '获取成员列表失败' }) + return + } + setMembers(result.data) + let targetMember = preferredMemberUsername + ? result.data.find(m => m.username === preferredMemberUsername) + : result.data.find(m => m.username === selectedMessageMemberUsername) + if (!targetMember && result.data.length > 0) { + targetMember = result.data[0] + setSelectedMessageMemberUsername(targetMember.username) + } + if (!targetMember) { + finishBackgroundTask(taskId, 'failed', { detail: '找不到目标成员' }) + return + } + updateBackgroundTask(taskId, { + detail: `正在分析 ${targetMember.displayName || targetMember.username} 的发言记录`, + progressText: '统计分析' + }) + const analyticsResult = await window.electronAPI.groupAnalytics.getGroupMemberAnalytics(targetGroup.username, targetMember.username, startTime, endTime) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员分析未继续写入' }) + return + } + if (analyticsResult.success && analyticsResult.data) { + setMemberAnalyticsData(analyticsResult.data) + finishBackgroundTask(taskId, 'completed', { + detail: `分析完成,共计 ${analyticsResult.data.statistics?.totalMessages || 0} 条消息`, + progressText: '已完成' + }) + } else { + setAnalyticsError(analyticsResult.error || '分析失败') + finishBackgroundTask(taskId, 'failed', { detail: analyticsResult.error || '分析失败' }) + } + break + } case 'ranking': { + setRankings([]) updateBackgroundTask(taskId, { detail: '正在计算群消息排行', progressText: '消息排行' @@ -584,6 +637,7 @@ function GroupAnalyticsPage() { break } case 'activeHours': { + setActiveHours({}) updateBackgroundTask(taskId, { detail: '正在计算群活跃时段', progressText: '活跃时段' @@ -601,6 +655,7 @@ function GroupAnalyticsPage() { break } case 'mediaStats': { + setMediaStats(null) updateBackgroundTask(taskId, { detail: '正在统计群消息类型', progressText: '消息类型' @@ -633,6 +688,12 @@ function GroupAnalyticsPage() { return num.toLocaleString() } + const formatDate = (timestamp: number | null) => { + if (!timestamp) return '-' + const date = new Date(timestamp * 1000) + return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` + } + const sanitizeFileName = (name: string) => { return name.replace(/[<>:"/\\|?*]+/g, '_').trim() } @@ -764,6 +825,16 @@ function GroupAnalyticsPage() { await loadFunctionData('memberMessages', selectedGroup, member.username) } + const handleViewMemberAnalyticsFromModal = async (member: GroupMember) => { + if (!selectedGroup) return + setSelectedMember(null) + setSelectedFunction('memberAnalytics') + setSelectedMessageMemberUsername(member.username) + setMessageMemberSearchKeyword('') + setShowMessageMemberSelect(false) + await loadFunctionData('memberAnalytics', selectedGroup, member.username) + } + const handleOpenMemberExportModal = () => { setShowMessageMemberSelect(false) setShowFormatSelect(false) @@ -982,6 +1053,14 @@ function GroupAnalyticsPage() { + + {showMessageMemberSelect && ( +
+
+ + setMessageMemberSearchKeyword(e.target.value)} + placeholder="搜索 wxid / 昵称 / 备注 / 微信号" + onClick={e => e.stopPropagation()} + /> +
+
+ {filteredMessageMemberOptions.length === 0 ? ( +
无匹配成员
+ ) : ( + filteredMessageMemberOptions.map(member => ( + + )) + )} +
+
+ )} + + + {analyticsError ? ( +
{analyticsError}
+ ) : memberAnalyticsData ? ( +
+
+
+
+
+ {formatNumber(memberAnalyticsData.statistics.sentMessages)} + 发信数量 +
+
+
+
+
+ {memberAnalyticsData.statistics.activeDays} + 活跃天数 +
+
+
+
+
+ + {formatDate(memberAnalyticsData.statistics.firstMessageTime)} - {formatDate(memberAnalyticsData.statistics.lastMessageTime)} + + 活跃周期 +
+
+
+ +
+
+

活跃时段

+
+ `${i}时`) }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', data: Array.from({ length: 24 }, (_, i) => memberAnalyticsData.timeDistribution[i] || 0), itemStyle: { color: '#07c160', borderRadius: [4, 4, 0, 0] } }] + }} + style={{ height: '300px', width: '100%' }} + /> +
+
+ +
+

消息类型分布

+
+ item.value > 0), + label: { show: true, formatter: '{b} {d}%' } + }] + }} + style={{ height: '300px', width: '100%' }} + /> +
+
+
+
+

常用语

+
+ {memberAnalyticsData.commonPhrases && memberAnalyticsData.commonPhrases.length > 0 ? ( + memberAnalyticsData.commonPhrases.map((item: any, idx: number) => ( +
+ {item.phrase} + {item.count}次 +
+ )) + ) : ( + 暂无常用语数据 + )} +
+
+
+

常用表情

+
+ {memberAnalyticsData.commonEmojis && memberAnalyticsData.commonEmojis.length > 0 ? ( + memberAnalyticsData.commonEmojis.map((item: any, idx: number) => ( +
+ {item.emoji} + {item.count}次 +
+ )) + ) : ( + 暂无表情包数据 + )} +
+
+
+
+
+ ) : ( +
+ )} + + )} + + )} {selectedFunction === 'ranking' && (
{rankings.map((item, index) => ( diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index d7c8899..36e1776 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -103,6 +103,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 }) const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null) + const [httpApiToken, setHttpApiToken] = useState('') + const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B'; const k = 1024; @@ -111,6 +113,25 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; + const generateRandomToken = async () => { + // 生成 32 字符的十六进制随机字符串 (16 bytes) + const array = new Uint8Array(16) + crypto.getRandomValues(array) + const token = Array.from(array).map(b => b.toString(16).padStart(2, '0')).join('') + + setHttpApiToken(token) + await configService.setHttpApiToken(token) + showMessage('已生成并保存新的 Access Token', true) + } + + const clearApiToken = async () => { + setHttpApiToken('') + await configService.setHttpApiToken('') + showMessage('已清除 Access Token,API 将允许无鉴权访问', true) + } + + + const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false) const [transcribeLanguages, setTranscribeLanguages] = useState(['zh']) @@ -169,6 +190,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { // HTTP API 设置 state const [httpApiEnabled, setHttpApiEnabled] = useState(false) const [httpApiPort, setHttpApiPort] = useState(5031) + const [httpApiHost, setHttpApiHost] = useState('127.0.0.1') const [httpApiRunning, setHttpApiRunning] = useState(false) const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('') const [isTogglingApi, setIsTogglingApi] = useState(false) @@ -192,6 +214,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { checkWaylandStatus() }, []) + + // 检查 Hello 可用性 useEffect(() => { setHelloAvailable(isWindows) @@ -319,6 +343,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() const savedAuthUseHello = await configService.getAuthUseHello() const savedIsLockMode = await window.electronAPI.auth.isLockMode() + + const savedHttpApiToken = await configService.getHttpApiToken() + if (savedHttpApiToken) setHttpApiToken(savedHttpApiToken) + + const savedApiPort = await configService.getHttpApiPort() + if (savedApiPort) setHttpApiPort(savedApiPort) + + const savedApiHost = await configService.getHttpApiHost() + if (savedApiHost) setHttpApiHost(savedApiHost) + setAuthEnabled(savedAuthEnabled) setAuthUseHello(savedAuthUseHello) setIsLockMode(savedIsLockMode) @@ -361,6 +395,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedAnalyticsConsent = await configService.getAnalyticsConsent() setAnalyticsConsent(savedAnalyticsConsent ?? false) + + // 如果语言列表为空,保存默认值 if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) { const defaultLanguages = ['zh'] @@ -1825,6 +1861,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { try { await window.electronAPI.http.stop() setHttpApiRunning(false) + await configService.setHttpApiEnabled(false) showMessage('API 服务已停止', true) } catch (e: any) { showMessage(`操作失败: ${e}`, false) @@ -1838,10 +1875,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setShowApiWarning(false) setIsTogglingApi(true) try { - const result = await window.electronAPI.http.start(httpApiPort) + const result = await window.electronAPI.http.start(httpApiPort, httpApiHost) if (result.success) { setHttpApiRunning(true) if (result.port) setHttpApiPort(result.port) + + await configService.setHttpApiEnabled(true) + await configService.setHttpApiPort(result.port || httpApiPort) + showMessage(`API 服务已启动,端口 ${result.port}`, true) } else { showMessage(`启动失败: ${result.error}`, false) @@ -1854,7 +1895,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { } const handleCopyApiUrl = () => { - const url = `http://127.0.0.1:${httpApiPort}` + const url = `http://${httpApiHost}:${httpApiPort}` navigator.clipboard.writeText(url) showMessage('已复制 API 地址', true) } @@ -1886,21 +1927,75 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
+
+ + + API 服务绑定的主机地址。默认 127.0.0.1 仅本机访问;Docker/N8N 等容器场景请改为 0.0.0.0 以允许外部访问(注意配合 Token 鉴权) + + { + const host = e.target.value.trim() || '127.0.0.1' + setHttpApiHost(host) + scheduleConfigSave('httpApiHost', () => configService.setHttpApiHost(host)) + }} + disabled={httpApiRunning} + style={{ width: 180, fontFamily: 'monospace' }} + /> +
+
API 服务监听的端口号(1024-65535) setHttpApiPort(parseInt(e.target.value, 10) || 5031)} - disabled={httpApiRunning} - style={{ width: 120 }} - min={1024} - max={65535} + type="number" + className="field-input" + value={httpApiPort} + onChange={(e) => { + const port = parseInt(e.target.value, 10) || 5031 + setHttpApiPort(port) + scheduleConfigSave('httpApiPort', () => configService.setHttpApiPort(port)) + }} + disabled={httpApiRunning} + style={{ width: 120 }} + min={1024} + max={65535} />
+
+ + + 设置后,请求头需携带 Authorization: Bearer <token>, + 或者参数中携带 ?access_token=<token> + +
+ { + const val = e.target.value + setHttpApiToken(val) + scheduleConfigSave('httpApiToken', () => configService.setHttpApiToken(val)) + }} + style={{ flex: 1, fontFamily: 'monospace' }} + /> + + {httpApiToken && ( + + )} +
+
+ {httpApiRunning && (
@@ -1909,7 +2004,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { @@ -1981,7 +2076,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
GET - {`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`} + {`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages`}

通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。

diff --git a/src/services/config.ts b/src/services/config.ts index acbbdf9..7ff663e 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -64,6 +64,10 @@ export const CONFIG_KEYS = { NOTIFICATION_POSITION: 'notificationPosition', NOTIFICATION_FILTER_MODE: 'notificationFilterMode', NOTIFICATION_FILTER_LIST: 'notificationFilterList', + HTTP_API_TOKEN: 'httpApiToken', + HTTP_API_ENABLED: 'httpApiEnabled', + HTTP_API_PORT: 'httpApiPort', + HTTP_API_HOST: 'httpApiHost', MESSAGE_PUSH_ENABLED: 'messagePushEnabled', WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior', QUOTE_LAYOUT: 'quoteLayout', @@ -117,6 +121,17 @@ export async function getDbPath(): Promise { return value as string | null } +// 获取api access_token +export async function getHttpApiToken(): Promise { + const value = await config.get(CONFIG_KEYS.HTTP_API_TOKEN) + return (value as string) || '' +} + +// 设置access_token +export async function setHttpApiToken(token: string): Promise { + await config.set(CONFIG_KEYS.HTTP_API_TOKEN, token) +} + // 设置数据库路径 export async function setDbPath(path: string): Promise { await config.set(CONFIG_KEYS.DB_PATH, path) @@ -1472,3 +1487,35 @@ export async function getAnalyticsDenyCount(): Promise { export async function setAnalyticsDenyCount(count: number): Promise { await config.set(CONFIG_KEYS.ANALYTICS_DENY_COUNT, count) } + + +// 获取 HTTP API 自动启动状态 +export async function getHttpApiEnabled(): Promise { + const value = await config.get(CONFIG_KEYS.HTTP_API_ENABLED) + return value === true +} + +// 设置 HTTP API 自动启动状态 +export async function setHttpApiEnabled(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.HTTP_API_ENABLED, enabled) +} + +// 获取 HTTP API 端口 +export async function getHttpApiPort(): Promise { + const value = await config.get(CONFIG_KEYS.HTTP_API_PORT) + return typeof value === 'number' ? value : 5031 +} + +// 设置 HTTP API 端口 +export async function setHttpApiPort(port: number): Promise { + await config.set(CONFIG_KEYS.HTTP_API_PORT, port) +} + +export async function getHttpApiHost(): Promise { + const value = await config.get(CONFIG_KEYS.HTTP_API_HOST) + return typeof value === 'string' && value.trim() ? value.trim() : '127.0.0.1' +} + +export async function setHttpApiHost(host: string): Promise { + await config.set(CONFIG_KEYS.HTTP_API_HOST, host) +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 7d06386..31e64a5 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -496,6 +496,28 @@ export interface ElectronAPI { } error?: string }> + getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => Promise<{ + success: boolean + data?: { + statistics: { + totalMessages: number + textMessages: number + imageMessages: number + voiceMessages: number + videoMessages: number + emojiMessages: number + otherMessages: number + sentMessages: number + receivedMessages: number + firstMessageTime: number | null + lastMessageTime: number | null + activeDays: number + messageTypeCounts: Record + } + timeDistribution: Record + } + error?: string + }> getGroupMemberMessages: ( chatroomId: string, memberUsername: string, @@ -838,7 +860,7 @@ export interface ElectronAPI { getLogs: () => Promise } http: { - start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }> + start: (port?: number, host?: string) => Promise<{ success: boolean; port?: number; error?: string }> stop: () => Promise<{ success: boolean }> status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }> }