From e0b2f152b0cf1802a1318e23ff427ee3462c1ff2 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Tue, 17 Mar 2026 23:29:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BA=86=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=8E=A8=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/HTTP-API.md | 64 +++- electron/main.ts | 20 +- electron/services/chatService.ts | 15 + electron/services/config.ts | 2 + electron/services/httpService.ts | 81 ++++++ electron/services/messagePushService.ts | 371 ++++++++++++++++++++++++ src/pages/SettingsPage.tsx | 73 +++++ src/services/config.ts | 10 + 8 files changed, 621 insertions(+), 15 deletions(-) create mode 100644 electron/services/messagePushService.ts diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index c82725b..ca2a89a 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -1,6 +1,6 @@ -# WeFlow HTTP API 文档 +# WeFlow HTTP API / Push 文档 -WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件。 +WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。 ## 启用方式 @@ -9,12 +9,15 @@ WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、 - 默认监听地址:`127.0.0.1` - 默认端口:`5031` - 基础地址:`http://127.0.0.1:5031` +- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端 ## 接口列表 - `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` @@ -46,7 +49,50 @@ GET /api/v1/health --- -## 2. 获取消息 +## 2. 主动推送 + +通过 SSE 长连接接收新消息事件,端口与 HTTP API 共用。 + +**请求** + +```http +GET /api/v1/push/messages +``` + +### 说明 + +- 需要先在设置页开启 `HTTP API 服务` +- 同时需要开启 `主动推送` +- 响应类型为 `text/event-stream` +- 新消息事件名固定为 `message.new` +- 建议接收端按 `messageKey` 去重 + +### 事件字段 + +- `event` +- `sessionId` +- `messageKey` +- `avatarUrl` +- `sourceName` +- `groupName`(仅群聊) +- `content` + +### 示例 + +```bash +curl -N "http://127.0.0.1:5031/api/v1/push/messages" +``` + +示例事件: + +```text +event: message.new +data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123456:1760000123:1760000123000:321:wxid_member:1","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]"} +``` + +--- + +## 3. 获取消息 读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。 @@ -183,7 +229,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1& --- -## 3. 获取会话列表 +## 4. 获取会话列表 **请求** @@ -228,7 +274,7 @@ GET /api/v1/sessions --- -## 4. 获取联系人列表 +## 5. 获取联系人列表 **请求** @@ -277,7 +323,7 @@ GET /api/v1/contacts --- -## 5. 获取群成员列表 +## 6. 获取群成员列表 返回群成员的 `wxid`、群昵称、备注、微信号等信息。 @@ -369,7 +415,7 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include --- -## 6. 访问导出媒体 +## 7. 访问导出媒体 通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。 @@ -410,7 +456,7 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif" --- -## 7. 使用示例 +## 8. 使用示例 ### PowerShell @@ -453,7 +499,7 @@ print(members) --- -## 8. 注意事项 +## 9. 注意事项 1. API 仅监听本机 `127.0.0.1`,不对外网开放。 2. 使用前需要先在 WeFlow 中完成数据库连接。 diff --git a/electron/main.ts b/electron/main.ts index 5066c98..62e5574 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -17,7 +17,6 @@ import { annualReportService } from './services/annualReportService' import { exportService, ExportOptions, ExportProgress } from './services/exportService' import { KeyService } from './services/keyService' import { KeyServiceMac } from './services/keyServiceMac' -import { KeyServiceLinux } from './services/keyServiceLinux'; import { voiceTranscribeService } from './services/voiceTranscribeService' import { videoService } from './services/videoService' import { snsService, isVideoUrl } from './services/snsService' @@ -28,6 +27,7 @@ import { cloudControlService } from './services/cloudControlService' import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow' import { httpService } from './services/httpService' +import { messagePushService } from './services/messagePushService' // 配置自动更新 @@ -91,13 +91,14 @@ let splashWindow: BrowserWindow | null = null const sessionChatWindows = new Map() const sessionChatWindowSources = new Map() -let keyService: KeyService | KeyServiceMac | KeyServiceLinux; +let keyService: any if (process.platform === 'darwin') { - keyService = new KeyServiceMac(); + keyService = new KeyServiceMac() } else if (process.platform === 'linux') { - keyService = new KeyServiceLinux(); + const { KeyServiceLinux } = require('./services/keyServiceLinux') + keyService = new KeyServiceLinux() } else { - keyService = new KeyService(); + keyService = new KeyService() } let mainWindowReady = false @@ -972,11 +973,14 @@ function registerIpcHandlers() { }) ipcMain.handle('config:set', async (_, key: string, value: any) => { - return configService?.set(key as any, value) + const result = configService?.set(key as any, value) + void messagePushService.handleConfigChanged(key) + return result }) ipcMain.handle('config:clear', async () => { configService?.clear() + messagePushService.handleConfigCleared() return true }) @@ -2515,6 +2519,10 @@ app.whenReady().then(async () => { // 注册 IPC 处理器 updateSplashProgress(25, '正在初始化...') registerIpcHandlers() + chatService.addDbMonitorListener((type, json) => { + messagePushService.handleDbMonitorChange(type, json) + }) + messagePushService.start() await delay(200) // 检查配置状态 diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index f2f508d..f5d17e2 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -202,6 +202,7 @@ const FRIEND_EXCLUDE_USERNAMES = new Set(['medianote', 'floatbottle', 'qmessage' class ChatService { private configService: ConfigService private connected = false + private readonly dbMonitorListeners = new Set<(type: string, json: string) => void>() private messageCursors: Map = new Map() private messageCursorMutex: boolean = false private readonly messageBatchDefault = 50 @@ -354,6 +355,13 @@ class ChatService { private monitorSetup = false + addDbMonitorListener(listener: (type: string, json: string) => void): () => void { + this.dbMonitorListeners.add(listener) + return () => { + this.dbMonitorListeners.delete(listener) + } + } + private setupDbMonitor() { if (this.monitorSetup) return this.monitorSetup = true @@ -362,6 +370,13 @@ class ChatService { // 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更 wcdbService.setMonitor((type, json) => { this.handleSessionStatsMonitorChange(type, json) + for (const listener of this.dbMonitorListeners) { + try { + listener(type, json) + } catch (error) { + console.error('[ChatService] 数据库监听回调失败:', error) + } + } const windows = BrowserWindow.getAllWindows() // 广播给所有渲染进程窗口 windows.forEach((win) => { diff --git a/electron/services/config.ts b/electron/services/config.ts index 141d216..38f3255 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -50,6 +50,7 @@ interface ConfigSchema { notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' notificationFilterMode: 'all' | 'whitelist' | 'blacklist' notificationFilterList: string[] + messagePushEnabled: boolean windowCloseBehavior: 'ask' | 'tray' | 'quit' wordCloudExcludeWords: string[] } @@ -117,6 +118,7 @@ export class ConfigService { notificationPosition: 'top-right', notificationFilterMode: 'all', notificationFilterList: [], + messagePushEnabled: false, windowCloseBehavior: 'ask', wordCloudExcludeWords: [] } diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 47f3f8c..7b95996 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -103,6 +103,8 @@ class HttpService { private port: number = 5031 private running: boolean = false private connections: Set = new Set() + private messagePushClients: Set = new Set() + private messagePushHeartbeatTimer: ReturnType | null = null private connectionMutex: boolean = false constructor() { @@ -153,6 +155,7 @@ class HttpService { this.server.listen(this.port, '127.0.0.1', () => { this.running = true + this.startMessagePushHeartbeat() console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`) resolve({ success: true, port: this.port }) }) @@ -165,6 +168,16 @@ class HttpService { async stop(): Promise { return new Promise((resolve) => { if (this.server) { + for (const client of this.messagePushClients) { + try { + client.end() + } catch {} + } + this.messagePushClients.clear() + if (this.messagePushHeartbeatTimer) { + clearInterval(this.messagePushHeartbeatTimer) + this.messagePushHeartbeatTimer = null + } // 使用互斥锁保护连接集合操作 this.connectionMutex = true const socketsToClose = Array.from(this.connections) @@ -211,6 +224,28 @@ class HttpService { return this.getApiMediaExportPath() } + getMessagePushStreamUrl(): string { + return `http://127.0.0.1:${this.port}/api/v1/push/messages` + } + + broadcastMessagePush(payload: Record): void { + if (!this.running || this.messagePushClients.size === 0) return + const eventBody = `event: message.new\ndata: ${JSON.stringify(payload)}\n\n` + + for (const client of Array.from(this.messagePushClients)) { + try { + if (client.writableEnded || client.destroyed) { + this.messagePushClients.delete(client) + continue + } + client.write(eventBody) + } catch { + this.messagePushClients.delete(client) + try { client.end() } catch {} + } + } + } + /** * 处理 HTTP 请求 */ @@ -233,6 +268,8 @@ class HttpService { // 路由处理 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') { @@ -252,6 +289,50 @@ class HttpService { } } + private startMessagePushHeartbeat(): void { + if (this.messagePushHeartbeatTimer) return + this.messagePushHeartbeatTimer = setInterval(() => { + for (const client of Array.from(this.messagePushClients)) { + try { + if (client.writableEnded || client.destroyed) { + this.messagePushClients.delete(client) + continue + } + client.write(': ping\n\n') + } catch { + this.messagePushClients.delete(client) + try { client.end() } catch {} + } + } + }, 25000) + } + + private handleMessagePushStream(req: http.IncomingMessage, res: http.ServerResponse): void { + if (this.configService.get('messagePushEnabled') !== true) { + this.sendError(res, 403, 'Message push is disabled') + return + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }) + res.flushHeaders?.() + res.write(`event: ready\ndata: ${JSON.stringify({ success: true, stream: this.getMessagePushStreamUrl() })}\n\n`) + + this.messagePushClients.add(res) + + const cleanup = () => { + this.messagePushClients.delete(res) + } + + req.on('close', cleanup) + res.on('close', cleanup) + res.on('error', cleanup) + } + private handleMediaRequest(pathname: string, res: http.ServerResponse): void { const mediaBasePath = this.getApiMediaExportPath() const relativePath = pathname.replace('/api/v1/media/', '') diff --git a/electron/services/messagePushService.ts b/electron/services/messagePushService.ts new file mode 100644 index 0000000..07b219b --- /dev/null +++ b/electron/services/messagePushService.ts @@ -0,0 +1,371 @@ +import { ConfigService } from './config' +import { chatService, type ChatSession, type Message } from './chatService' +import { wcdbService } from './wcdbService' +import { httpService } from './httpService' + +interface SessionBaseline { + lastTimestamp: number + unreadCount: number +} + +interface MessagePushPayload { + event: 'message.new' + sessionId: string + messageKey: string + avatarUrl?: string + sourceName: string + groupName?: string + content: string | null +} + +const PUSH_CONFIG_KEYS = new Set([ + 'messagePushEnabled', + 'dbPath', + 'decryptKey', + 'myWxid' +]) + +class MessagePushService { + private readonly configService: ConfigService + private readonly sessionBaseline = new Map() + private readonly recentMessageKeys = new Map() + private readonly groupNicknameCache = new Map; updatedAt: number }>() + private readonly debounceMs = 350 + private readonly recentMessageTtlMs = 10 * 60 * 1000 + private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000 + private debounceTimer: ReturnType | null = null + private processing = false + private rerunRequested = false + private started = false + private baselineReady = false + + constructor() { + this.configService = ConfigService.getInstance() + } + + start(): void { + if (this.started) return + this.started = true + void this.refreshConfiguration('startup') + } + + handleDbMonitorChange(type: string, json: string): void { + if (!this.started) return + if (!this.isPushEnabled()) return + + let payload: Record | null = null + try { + payload = JSON.parse(json) + } catch { + payload = null + } + + const tableName = String(payload?.table || '').trim().toLowerCase() + if (tableName && tableName !== 'session') { + return + } + + this.scheduleSync() + } + + async handleConfigChanged(key: string): Promise { + if (!PUSH_CONFIG_KEYS.has(String(key || '').trim())) return + if (key === 'dbPath' || key === 'decryptKey' || key === 'myWxid') { + this.resetRuntimeState() + chatService.close() + } + await this.refreshConfiguration(`config:${key}`) + } + + handleConfigCleared(): void { + this.resetRuntimeState() + chatService.close() + } + + private isPushEnabled(): boolean { + return this.configService.get('messagePushEnabled') === true + } + + private resetRuntimeState(): void { + this.sessionBaseline.clear() + this.recentMessageKeys.clear() + this.groupNicknameCache.clear() + this.baselineReady = false + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = null + } + } + + private async refreshConfiguration(reason: string): Promise { + if (!this.isPushEnabled()) { + this.resetRuntimeState() + return + } + + const connectResult = await chatService.connect() + if (!connectResult.success) { + console.warn(`[MessagePushService] Bootstrap connect failed (${reason}):`, connectResult.error) + return + } + + await this.bootstrapBaseline() + } + + private async bootstrapBaseline(): Promise { + const sessionsResult = await chatService.getSessions() + if (!sessionsResult.success || !sessionsResult.sessions) { + return + } + this.setBaseline(sessionsResult.sessions as ChatSession[]) + this.baselineReady = true + } + + private scheduleSync(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + } + + this.debounceTimer = setTimeout(() => { + this.debounceTimer = null + void this.flushPendingChanges() + }, this.debounceMs) + } + + private async flushPendingChanges(): Promise { + if (this.processing) { + this.rerunRequested = true + return + } + + this.processing = true + try { + if (!this.isPushEnabled()) return + + const connectResult = await chatService.connect() + if (!connectResult.success) { + console.warn('[MessagePushService] Sync connect failed:', connectResult.error) + return + } + + const sessionsResult = await chatService.getSessions() + if (!sessionsResult.success || !sessionsResult.sessions) { + return + } + + const sessions = sessionsResult.sessions as ChatSession[] + if (!this.baselineReady) { + this.setBaseline(sessions) + this.baselineReady = true + return + } + + const previousBaseline = new Map(this.sessionBaseline) + this.setBaseline(sessions) + + const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session)) + for (const session of candidates) { + await this.pushSessionMessages(session, previousBaseline.get(session.username)) + } + } finally { + this.processing = false + if (this.rerunRequested) { + this.rerunRequested = false + this.scheduleSync() + } + } + } + + private setBaseline(sessions: ChatSession[]): void { + this.sessionBaseline.clear() + for (const session of sessions) { + this.sessionBaseline.set(session.username, { + lastTimestamp: Number(session.lastTimestamp || 0), + unreadCount: Number(session.unreadCount || 0) + }) + } + } + + private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean { + const sessionId = String(session.username || '').trim() + if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) { + return false + } + + const summary = String(session.summary || '').trim() + if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) { + return false + } + + const lastTimestamp = Number(session.lastTimestamp || 0) + const unreadCount = Number(session.unreadCount || 0) + + if (!previous) { + return unreadCount > 0 && lastTimestamp > 0 + } + + if (lastTimestamp <= previous.lastTimestamp) { + return false + } + + // unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送 + return unreadCount > previous.unreadCount + } + + private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise { + const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1) + const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000) + if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) { + return + } + + for (const message of newMessagesResult.messages) { + const messageKey = String(message.messageKey || '').trim() + if (!messageKey) continue + if (message.isSend === 1) continue + + if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) { + continue + } + + if (this.isRecentMessage(messageKey)) { + continue + } + + const payload = await this.buildPayload(session, message) + if (!payload) continue + + httpService.broadcastMessagePush(payload) + this.rememberMessageKey(messageKey) + } + } + + private async buildPayload(session: ChatSession, message: Message): Promise { + const sessionId = String(session.username || '').trim() + const messageKey = String(message.messageKey || '').trim() + if (!sessionId || !messageKey) return null + + const isGroup = sessionId.endsWith('@chatroom') + const content = this.getMessageDisplayContent(message) + + if (isGroup) { + const groupInfo = await chatService.getContactAvatar(sessionId) + const groupName = session.displayName || groupInfo?.displayName || sessionId + const sourceName = await this.resolveGroupSourceName(sessionId, message, session) + return { + event: 'message.new', + sessionId, + messageKey, + avatarUrl: session.avatarUrl || groupInfo?.avatarUrl, + groupName, + sourceName, + content + } + } + + const contactInfo = await chatService.getContactAvatar(sessionId) + return { + event: 'message.new', + sessionId, + messageKey, + avatarUrl: session.avatarUrl || contactInfo?.avatarUrl, + sourceName: session.displayName || contactInfo?.displayName || sessionId, + content + } + } + + private getMessageDisplayContent(message: Message): string | null { + switch (Number(message.localType || 0)) { + case 1: + return message.rawContent || null + case 3: + return '[图片]' + case 34: + return '[语音]' + case 43: + return '[视频]' + case 47: + return '[表情]' + case 42: + return message.cardNickname || '[名片]' + case 48: + return '[位置]' + case 49: + return message.linkTitle || message.fileName || '[消息]' + default: + return message.parsedContent || message.rawContent || null + } + } + + private async resolveGroupSourceName(chatroomId: string, message: Message, session: ChatSession): Promise { + const senderUsername = String(message.senderUsername || '').trim() + if (!senderUsername) { + return session.lastSenderDisplayName || '未知发送者' + } + + const groupNicknames = await this.getGroupNicknames(chatroomId) + const normalizedSender = this.normalizeAccountId(senderUsername) + const nickname = groupNicknames[senderUsername] + || groupNicknames[senderUsername.toLowerCase()] + || groupNicknames[normalizedSender] + || groupNicknames[normalizedSender.toLowerCase()] + + if (nickname) { + return nickname + } + + const contactInfo = await chatService.getContactAvatar(senderUsername) + return contactInfo?.displayName || senderUsername + } + + private async getGroupNicknames(chatroomId: string): Promise> { + const cacheKey = String(chatroomId || '').trim() + if (!cacheKey) return {} + + const cached = this.groupNicknameCache.get(cacheKey) + if (cached && Date.now() - cached.updatedAt < this.groupNicknameCacheTtlMs) { + return cached.nicknames + } + + const result = await wcdbService.getGroupNicknames(cacheKey) + const nicknames = result.success && result.nicknames ? result.nicknames : {} + this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() }) + return nicknames + } + + private normalizeAccountId(value: string): string { + const trimmed = String(value || '').trim() + if (!trimmed) return trimmed + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + return match ? match[1] : trimmed + } + + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed + } + + private isRecentMessage(messageKey: string): boolean { + this.pruneRecentMessageKeys() + const timestamp = this.recentMessageKeys.get(messageKey) + return typeof timestamp === 'number' && Date.now() - timestamp < this.recentMessageTtlMs + } + + private rememberMessageKey(messageKey: string): void { + this.recentMessageKeys.set(messageKey, Date.now()) + this.pruneRecentMessageKeys() + } + + private pruneRecentMessageKeys(): void { + const now = Date.now() + for (const [key, timestamp] of this.recentMessageKeys.entries()) { + if (now - timestamp > this.recentMessageTtlMs) { + this.recentMessageKeys.delete(key) + } + } + } + +} + +export const messagePushService = new MessagePushService() diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 127f092..b010d93 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -171,6 +171,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('') const [isTogglingApi, setIsTogglingApi] = useState(false) const [showApiWarning, setShowApiWarning] = useState(false) + const [messagePushEnabled, setMessagePushEnabled] = useState(false) const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache @@ -296,6 +297,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedNotificationPosition = await configService.getNotificationPosition() const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterList = await configService.getNotificationFilterList() + const savedMessagePushEnabled = await configService.getMessagePushEnabled() const savedWindowCloseBehavior = await configService.getWindowCloseBehavior() const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() @@ -332,6 +334,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setNotificationPosition(savedNotificationPosition) setNotificationFilterMode(savedNotificationFilterMode) setNotificationFilterList(savedNotificationFilterList) + setMessagePushEnabled(savedMessagePushEnabled) setWindowCloseBehavior(savedWindowCloseBehavior) const savedExcludeWords = await configService.getWordCloudExcludeWords() @@ -1746,6 +1749,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { showMessage('已复制 API 地址', true) } + const handleToggleMessagePush = async (enabled: boolean) => { + setMessagePushEnabled(enabled) + await configService.setMessagePushEnabled(enabled) + showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true) + } + const renderApiTab = () => (
@@ -1812,6 +1821,70 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { />
+
+ +
+ + 检测到新收到的消息后,会通过当前 API 端口下的固定 SSE 地址主动推送给外部订阅端 +
+ + {messagePushEnabled ? '已开启' : '已关闭'} + + +
+
+ +
+ + 外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务` +
+ + +
+
+ +
+ + SSE 事件名为 `message.new`;私聊推送 `avatarUrl/sourceName/content`,群聊额外附带 `groupName` +
+
+
+ GET + {`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`} +
+

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

+
+ {['event', 'sessionId', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => ( + + {param} + + ))} +
+
+
+
+ {showApiWarning && (
setShowApiWarning(false)}>
e.stopPropagation()}> diff --git a/src/services/config.ts b/src/services/config.ts index 76d4b62..ee85acd 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -63,6 +63,7 @@ export const CONFIG_KEYS = { NOTIFICATION_POSITION: 'notificationPosition', NOTIFICATION_FILTER_MODE: 'notificationFilterMode', NOTIFICATION_FILTER_LIST: 'notificationFilterList', + MESSAGE_PUSH_ENABLED: 'messagePushEnabled', WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior', // 词云 @@ -1362,6 +1363,15 @@ export async function setNotificationFilterList(list: string[]): Promise { await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list) } +export async function getMessagePushEnabled(): Promise { + const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_ENABLED) + return value === true +} + +export async function setMessagePushEnabled(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.MESSAGE_PUSH_ENABLED, enabled) +} + export async function getWindowCloseBehavior(): Promise { const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR) if (value === 'tray' || value === 'quit') return value