mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
新增了一个消息推送
This commit is contained in:
@@ -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`
|
- 默认监听地址:`127.0.0.1`
|
||||||
- 默认端口:`5031`
|
- 默认端口:`5031`
|
||||||
- 基础地址:`http://127.0.0.1:5031`
|
- 基础地址:`http://127.0.0.1:5031`
|
||||||
|
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
||||||
|
|
||||||
## 接口列表
|
## 接口列表
|
||||||
|
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
- `GET /api/v1/health`
|
- `GET /api/v1/health`
|
||||||
|
- `GET /api/v1/push/messages`
|
||||||
- `GET /api/v1/messages`
|
- `GET /api/v1/messages`
|
||||||
|
- `GET /api/v1/messages/new`
|
||||||
- `GET /api/v1/sessions`
|
- `GET /api/v1/sessions`
|
||||||
- `GET /api/v1/contacts`
|
- `GET /api/v1/contacts`
|
||||||
- `GET /api/v1/group-members`
|
- `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 格式。
|
读取指定会话的消息,支持原始 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`、群昵称、备注、微信号等信息。
|
返回群成员的 `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 地址。
|
通过消息接口启用 `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
|
### PowerShell
|
||||||
|
|
||||||
@@ -453,7 +499,7 @@ print(members)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 注意事项
|
## 9. 注意事项
|
||||||
|
|
||||||
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
||||||
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { annualReportService } from './services/annualReportService'
|
|||||||
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||||
import { KeyService } from './services/keyService'
|
import { KeyService } from './services/keyService'
|
||||||
import { KeyServiceMac } from './services/keyServiceMac'
|
import { KeyServiceMac } from './services/keyServiceMac'
|
||||||
import { KeyServiceLinux } from './services/keyServiceLinux';
|
|
||||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||||
import { videoService } from './services/videoService'
|
import { videoService } from './services/videoService'
|
||||||
import { snsService, isVideoUrl } from './services/snsService'
|
import { snsService, isVideoUrl } from './services/snsService'
|
||||||
@@ -28,6 +27,7 @@ import { cloudControlService } from './services/cloudControlService'
|
|||||||
|
|
||||||
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||||
import { httpService } from './services/httpService'
|
import { httpService } from './services/httpService'
|
||||||
|
import { messagePushService } from './services/messagePushService'
|
||||||
|
|
||||||
|
|
||||||
// 配置自动更新
|
// 配置自动更新
|
||||||
@@ -91,13 +91,14 @@ let splashWindow: BrowserWindow | null = null
|
|||||||
const sessionChatWindows = new Map<string, BrowserWindow>()
|
const sessionChatWindows = new Map<string, BrowserWindow>()
|
||||||
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
|
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
|
||||||
|
|
||||||
let keyService: KeyService | KeyServiceMac | KeyServiceLinux;
|
let keyService: any
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
keyService = new KeyServiceMac();
|
keyService = new KeyServiceMac()
|
||||||
} else if (process.platform === 'linux') {
|
} else if (process.platform === 'linux') {
|
||||||
keyService = new KeyServiceLinux();
|
const { KeyServiceLinux } = require('./services/keyServiceLinux')
|
||||||
|
keyService = new KeyServiceLinux()
|
||||||
} else {
|
} else {
|
||||||
keyService = new KeyService();
|
keyService = new KeyService()
|
||||||
}
|
}
|
||||||
|
|
||||||
let mainWindowReady = false
|
let mainWindowReady = false
|
||||||
@@ -972,11 +973,14 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('config:set', async (_, key: string, value: any) => {
|
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 () => {
|
ipcMain.handle('config:clear', async () => {
|
||||||
configService?.clear()
|
configService?.clear()
|
||||||
|
messagePushService.handleConfigCleared()
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2515,6 +2519,10 @@ app.whenReady().then(async () => {
|
|||||||
// 注册 IPC 处理器
|
// 注册 IPC 处理器
|
||||||
updateSplashProgress(25, '正在初始化...')
|
updateSplashProgress(25, '正在初始化...')
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
|
chatService.addDbMonitorListener((type, json) => {
|
||||||
|
messagePushService.handleDbMonitorChange(type, json)
|
||||||
|
})
|
||||||
|
messagePushService.start()
|
||||||
await delay(200)
|
await delay(200)
|
||||||
|
|
||||||
// 检查配置状态
|
// 检查配置状态
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ const FRIEND_EXCLUDE_USERNAMES = new Set(['medianote', 'floatbottle', 'qmessage'
|
|||||||
class ChatService {
|
class ChatService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private connected = false
|
private connected = false
|
||||||
|
private readonly dbMonitorListeners = new Set<(type: string, json: string) => void>()
|
||||||
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean; bufferedMessages?: any[] }> = new Map()
|
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean; bufferedMessages?: any[] }> = new Map()
|
||||||
private messageCursorMutex: boolean = false
|
private messageCursorMutex: boolean = false
|
||||||
private readonly messageBatchDefault = 50
|
private readonly messageBatchDefault = 50
|
||||||
@@ -354,6 +355,13 @@ class ChatService {
|
|||||||
|
|
||||||
private monitorSetup = false
|
private monitorSetup = false
|
||||||
|
|
||||||
|
addDbMonitorListener(listener: (type: string, json: string) => void): () => void {
|
||||||
|
this.dbMonitorListeners.add(listener)
|
||||||
|
return () => {
|
||||||
|
this.dbMonitorListeners.delete(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private setupDbMonitor() {
|
private setupDbMonitor() {
|
||||||
if (this.monitorSetup) return
|
if (this.monitorSetup) return
|
||||||
this.monitorSetup = true
|
this.monitorSetup = true
|
||||||
@@ -362,6 +370,13 @@ class ChatService {
|
|||||||
// 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
|
// 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
|
||||||
wcdbService.setMonitor((type, json) => {
|
wcdbService.setMonitor((type, json) => {
|
||||||
this.handleSessionStatsMonitorChange(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()
|
const windows = BrowserWindow.getAllWindows()
|
||||||
// 广播给所有渲染进程窗口
|
// 广播给所有渲染进程窗口
|
||||||
windows.forEach((win) => {
|
windows.forEach((win) => {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ interface ConfigSchema {
|
|||||||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
notificationFilterList: string[]
|
notificationFilterList: string[]
|
||||||
|
messagePushEnabled: boolean
|
||||||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||||
wordCloudExcludeWords: string[]
|
wordCloudExcludeWords: string[]
|
||||||
}
|
}
|
||||||
@@ -117,6 +118,7 @@ export class ConfigService {
|
|||||||
notificationPosition: 'top-right',
|
notificationPosition: 'top-right',
|
||||||
notificationFilterMode: 'all',
|
notificationFilterMode: 'all',
|
||||||
notificationFilterList: [],
|
notificationFilterList: [],
|
||||||
|
messagePushEnabled: false,
|
||||||
windowCloseBehavior: 'ask',
|
windowCloseBehavior: 'ask',
|
||||||
wordCloudExcludeWords: []
|
wordCloudExcludeWords: []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ class HttpService {
|
|||||||
private port: number = 5031
|
private port: number = 5031
|
||||||
private running: boolean = false
|
private running: boolean = false
|
||||||
private connections: Set<import('net').Socket> = new Set()
|
private connections: Set<import('net').Socket> = new Set()
|
||||||
|
private messagePushClients: Set<http.ServerResponse> = new Set()
|
||||||
|
private messagePushHeartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||||
private connectionMutex: boolean = false
|
private connectionMutex: boolean = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -153,6 +155,7 @@ class HttpService {
|
|||||||
|
|
||||||
this.server.listen(this.port, '127.0.0.1', () => {
|
this.server.listen(this.port, '127.0.0.1', () => {
|
||||||
this.running = true
|
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://127.0.0.1:${this.port}`)
|
||||||
resolve({ success: true, port: this.port })
|
resolve({ success: true, port: this.port })
|
||||||
})
|
})
|
||||||
@@ -165,6 +168,16 @@ class HttpService {
|
|||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (this.server) {
|
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
|
this.connectionMutex = true
|
||||||
const socketsToClose = Array.from(this.connections)
|
const socketsToClose = Array.from(this.connections)
|
||||||
@@ -211,6 +224,28 @@ class HttpService {
|
|||||||
return this.getApiMediaExportPath()
|
return this.getApiMediaExportPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMessagePushStreamUrl(): string {
|
||||||
|
return `http://127.0.0.1:${this.port}/api/v1/push/messages`
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastMessagePush(payload: Record<string, unknown>): 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 请求
|
* 处理 HTTP 请求
|
||||||
*/
|
*/
|
||||||
@@ -233,6 +268,8 @@ class HttpService {
|
|||||||
// 路由处理
|
// 路由处理
|
||||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||||
this.sendJson(res, { status: 'ok' })
|
this.sendJson(res, { status: 'ok' })
|
||||||
|
} else if (pathname === '/api/v1/push/messages') {
|
||||||
|
this.handleMessagePushStream(req, res)
|
||||||
} else if (pathname === '/api/v1/messages') {
|
} else if (pathname === '/api/v1/messages') {
|
||||||
await this.handleMessages(url, res)
|
await this.handleMessages(url, res)
|
||||||
} else if (pathname === '/api/v1/sessions') {
|
} 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 {
|
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
|
||||||
const mediaBasePath = this.getApiMediaExportPath()
|
const mediaBasePath = this.getApiMediaExportPath()
|
||||||
const relativePath = pathname.replace('/api/v1/media/', '')
|
const relativePath = pathname.replace('/api/v1/media/', '')
|
||||||
|
|||||||
371
electron/services/messagePushService.ts
Normal file
371
electron/services/messagePushService.ts
Normal file
@@ -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<string, SessionBaseline>()
|
||||||
|
private readonly recentMessageKeys = new Map<string, number>()
|
||||||
|
private readonly groupNicknameCache = new Map<string, { nicknames: Record<string, string>; updatedAt: number }>()
|
||||||
|
private readonly debounceMs = 350
|
||||||
|
private readonly recentMessageTtlMs = 10 * 60 * 1000
|
||||||
|
private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000
|
||||||
|
private debounceTimer: ReturnType<typeof setTimeout> | 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<string, unknown> | 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<MessagePushPayload | null> {
|
||||||
|
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<string> {
|
||||||
|
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<Record<string, string>> {
|
||||||
|
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()
|
||||||
@@ -171,6 +171,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
|
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
|
||||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||||
const [showApiWarning, setShowApiWarning] = useState(false)
|
const [showApiWarning, setShowApiWarning] = useState(false)
|
||||||
|
const [messagePushEnabled, setMessagePushEnabled] = useState(false)
|
||||||
|
|
||||||
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
|
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
|
||||||
|
|
||||||
@@ -296,6 +297,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const savedNotificationPosition = await configService.getNotificationPosition()
|
const savedNotificationPosition = await configService.getNotificationPosition()
|
||||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||||
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
||||||
|
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
|
||||||
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
||||||
|
|
||||||
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
||||||
@@ -332,6 +334,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setNotificationPosition(savedNotificationPosition)
|
setNotificationPosition(savedNotificationPosition)
|
||||||
setNotificationFilterMode(savedNotificationFilterMode)
|
setNotificationFilterMode(savedNotificationFilterMode)
|
||||||
setNotificationFilterList(savedNotificationFilterList)
|
setNotificationFilterList(savedNotificationFilterList)
|
||||||
|
setMessagePushEnabled(savedMessagePushEnabled)
|
||||||
setWindowCloseBehavior(savedWindowCloseBehavior)
|
setWindowCloseBehavior(savedWindowCloseBehavior)
|
||||||
|
|
||||||
const savedExcludeWords = await configService.getWordCloudExcludeWords()
|
const savedExcludeWords = await configService.getWordCloudExcludeWords()
|
||||||
@@ -1746,6 +1749,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
showMessage('已复制 API 地址', true)
|
showMessage('已复制 API 地址', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleToggleMessagePush = async (enabled: boolean) => {
|
||||||
|
setMessagePushEnabled(enabled)
|
||||||
|
await configService.setMessagePushEnabled(enabled)
|
||||||
|
showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true)
|
||||||
|
}
|
||||||
|
|
||||||
const renderApiTab = () => (
|
const renderApiTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -1812,6 +1821,70 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>主动推送</label>
|
||||||
|
<span className="form-hint">检测到新收到的消息后,会通过当前 API 端口下的固定 SSE 地址主动推送给外部订阅端</span>
|
||||||
|
<div className="log-toggle-line">
|
||||||
|
<span className="log-status">
|
||||||
|
{messagePushEnabled ? '已开启' : '已关闭'}
|
||||||
|
</span>
|
||||||
|
<label className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={messagePushEnabled}
|
||||||
|
onChange={(e) => { void handleToggleMessagePush(e.target.checked) }}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>推送地址</label>
|
||||||
|
<span className="form-hint">外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务`</span>
|
||||||
|
<div className="api-url-display">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input"
|
||||||
|
value={`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`)
|
||||||
|
showMessage('已复制推送地址', true)
|
||||||
|
}}
|
||||||
|
title="复制"
|
||||||
|
>
|
||||||
|
<Copy size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>推送内容</label>
|
||||||
|
<span className="form-hint">SSE 事件名为 `message.new`;私聊推送 `avatarUrl/sourceName/content`,群聊额外附带 `groupName`</span>
|
||||||
|
<div className="api-docs">
|
||||||
|
<div className="api-item">
|
||||||
|
<div className="api-endpoint">
|
||||||
|
<span className="method get">GET</span>
|
||||||
|
<code>{`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}</code>
|
||||||
|
</div>
|
||||||
|
<p className="api-desc">通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。</p>
|
||||||
|
<div className="api-params">
|
||||||
|
{['event', 'sessionId', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => (
|
||||||
|
<span key={param} className="param">
|
||||||
|
<code>{param}</code>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showApiWarning && (
|
{showApiWarning && (
|
||||||
<div className="modal-overlay" onClick={() => setShowApiWarning(false)}>
|
<div className="modal-overlay" onClick={() => setShowApiWarning(false)}>
|
||||||
<div className="api-warning-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="api-warning-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const CONFIG_KEYS = {
|
|||||||
NOTIFICATION_POSITION: 'notificationPosition',
|
NOTIFICATION_POSITION: 'notificationPosition',
|
||||||
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
||||||
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
||||||
|
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
|
||||||
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
||||||
|
|
||||||
// 词云
|
// 词云
|
||||||
@@ -1362,6 +1363,15 @@ export async function setNotificationFilterList(list: string[]): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
|
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMessagePushEnabled(): Promise<boolean> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_ENABLED)
|
||||||
|
return value === true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setMessagePushEnabled(enabled: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.MESSAGE_PUSH_ENABLED, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getWindowCloseBehavior(): Promise<WindowCloseBehavior> {
|
export async function getWindowCloseBehavior(): Promise<WindowCloseBehavior> {
|
||||||
const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR)
|
const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR)
|
||||||
if (value === 'tray' || value === 'quit') return value
|
if (value === 'tray' || value === 'quit') return value
|
||||||
|
|||||||
Reference in New Issue
Block a user