Files
WeFlow/electron/services/messagePushService.ts

726 lines
24 KiB
TypeScript

import { ConfigService } from './config'
import { chatService, type ChatSession, type Message } from './chatService'
import { wcdbService } from './wcdbService'
import { httpService } from './httpService'
import { promises as fs } from 'fs'
import path from 'path'
import { createHash } from 'crypto'
import { pathToFileURL } from 'url'
interface SessionBaseline {
lastTimestamp: number
unreadCount: number
}
interface PushSessionResult {
fetched: boolean
maxFetchedTimestamp: number
incomingCandidateCount: number
observedIncomingCount: number
expectedIncomingCount: number
retry: boolean
}
interface MessagePushPayload {
event: 'message.new'
sessionId: string
sessionType: 'private' | 'group' | 'official' | 'other'
messageKey: string
avatarUrl?: string
sourceName: string
groupName?: string
content: string | null
timestamp: number
}
const PUSH_CONFIG_KEYS = new Set([
'messagePushEnabled',
'messagePushFilterMode',
'messagePushFilterList',
'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 seenMessageKeys = new Map<string, number>()
private readonly seenPrimedSessions = new Set<string>()
private readonly groupNicknameCache = new Map<string, { nicknames: Record<string, string>; updatedAt: number }>()
private readonly pushAvatarCacheDir: string
private readonly pushAvatarDataCache = new Map<string, string>()
private readonly debounceMs = 350
private readonly lookbackSeconds = 2
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
private messageTableScanRequested = false
constructor() {
this.configService = ConfigService.getInstance()
this.pushAvatarCacheDir = path.join(this.configService.getCacheBasePath(), 'push-avatar-files')
}
start(): void {
if (this.started) return
this.started = true
void this.refreshConfiguration('startup')
}
stop(): void {
this.started = false
this.processing = false
this.rerunRequested = false
this.resetRuntimeState()
}
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()
if (this.isSessionTableChange(tableName)) {
this.scheduleSync()
return
}
if (!tableName || this.isMessageTableChange(tableName)) {
this.scheduleSync({ scanMessageBackedSessions: true })
}
}
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.seenMessageKeys.clear()
this.seenPrimedSessions.clear()
this.groupNicknameCache.clear()
this.baselineReady = false
this.messageTableScanRequested = 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(options: { scanMessageBackedSessions?: boolean } = {}): void {
if (options.scanMessageBackedSessions) {
this.messageTableScanRequested = true
}
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 scanMessageBackedSessions = this.messageTableScanRequested
this.messageTableScanRequested = false
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)
const candidates = sessions.filter((session) => {
const previous = previousBaseline.get(session.username)
if (this.shouldInspectSession(previous, session)) {
return true
}
return scanMessageBackedSessions && this.shouldScanMessageBackedSession(previous, session)
})
const candidateIds = new Set<string>()
for (const session of candidates) {
const sessionId = String(session.username || '').trim()
if (sessionId) candidateIds.add(sessionId)
const result = await this.pushSessionMessages(
session,
previousBaseline.get(session.username) || this.sessionBaseline.get(session.username)
)
this.updateInspectedBaseline(session, previousBaseline.get(session.username), result)
if (result.retry) {
this.rerunRequested = true
}
}
for (const session of sessions) {
const sessionId = String(session.username || '').trim()
if (!sessionId || candidateIds.has(sessionId)) continue
this.updateObservedBaseline(session, previousBaseline.get(sessionId))
}
} finally {
this.processing = false
if (this.rerunRequested) {
this.rerunRequested = false
this.scheduleSync({ scanMessageBackedSessions: this.messageTableScanRequested })
}
}
}
private setBaseline(sessions: ChatSession[]): void {
const previousBaseline = new Map(this.sessionBaseline)
const nextBaseline = new Map<string, SessionBaseline>()
const nowSeconds = Math.floor(Date.now() / 1000)
this.sessionBaseline.clear()
for (const session of sessions) {
const username = String(session.username || '').trim()
if (!username) continue
const previous = previousBaseline.get(username)
const sessionTimestamp = Number(session.lastTimestamp || 0)
const initialTimestamp = sessionTimestamp > 0 ? sessionTimestamp : nowSeconds
nextBaseline.set(username, {
lastTimestamp: Math.max(sessionTimestamp, Number(previous?.lastTimestamp || 0), previous ? 0 : initialTimestamp),
unreadCount: Number(session.unreadCount || 0)
})
}
for (const [username, baseline] of nextBaseline.entries()) {
this.sessionBaseline.set(username, baseline)
}
}
private updateObservedBaseline(session: ChatSession, previous: SessionBaseline | undefined): void {
const username = String(session.username || '').trim()
if (!username) return
const sessionTimestamp = Number(session.lastTimestamp || 0)
const previousTimestamp = Number(previous?.lastTimestamp || 0)
this.sessionBaseline.set(username, {
lastTimestamp: Math.max(sessionTimestamp, previousTimestamp),
unreadCount: Number(session.unreadCount ?? previous?.unreadCount ?? 0)
})
}
private updateInspectedBaseline(
session: ChatSession,
previous: SessionBaseline | undefined,
result: PushSessionResult
): void {
const username = String(session.username || '').trim()
if (!username) return
const previousTimestamp = Number(previous?.lastTimestamp || 0)
const current = this.sessionBaseline.get(username) || previous || { lastTimestamp: 0, unreadCount: 0 }
const nextTimestamp = result.retry
? previousTimestamp
: Math.max(previousTimestamp, current.lastTimestamp, result.maxFetchedTimestamp)
this.sessionBaseline.set(username, {
lastTimestamp: nextTimestamp,
unreadCount: result.retry
? Number(previous?.unreadCount || 0)
: 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
}
return lastTimestamp > previous.lastTimestamp || unreadCount > previous.unreadCount
}
private shouldScanMessageBackedSession(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 sessionType = this.getSessionType(sessionId, session)
if (sessionType === 'private') {
return false
}
return Boolean(previous) || Number(session.lastTimestamp || 0) > 0
}
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<PushSessionResult> {
const previousTimestamp = Math.max(0, Number(previous?.lastTimestamp || 0))
const previousUnreadCount = Math.max(0, Number(previous?.unreadCount || 0))
const currentUnreadCount = Math.max(0, Number(session.unreadCount || 0))
const expectedIncomingCount = previous
? Math.max(0, currentUnreadCount - previousUnreadCount)
: 0
const since = previous
? Math.max(0, previousTimestamp - this.lookbackSeconds)
: 0
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
return {
fetched: false,
maxFetchedTimestamp: previousTimestamp,
incomingCandidateCount: 0,
observedIncomingCount: 0,
expectedIncomingCount,
retry: expectedIncomingCount > 0
}
}
const sessionId = String(session.username || '').trim()
const maxFetchedTimestamp = newMessagesResult.messages.reduce((max, message) => {
const createTime = Number(message.createTime || 0)
return Number.isFinite(createTime) && createTime > max ? createTime : max
}, previousTimestamp)
const seenPrimed = sessionId ? this.seenPrimedSessions.has(sessionId) : false
const sameTimestampIncoming: Message[] = []
const candidateMessages: Message[] = []
let observedIncomingCount = 0
for (const message of newMessagesResult.messages) {
const messageKey = String(message.messageKey || '').trim()
if (!messageKey) continue
const createTime = Number(message.createTime || 0)
const seen = this.isSeenMessage(messageKey)
const recent = this.isRecentMessage(messageKey)
if (message.isSend !== 1) {
if (!previous || createTime > previousTimestamp || (seenPrimed && createTime === previousTimestamp)) {
observedIncomingCount += 1
}
}
if (previous && !seenPrimed && createTime < previousTimestamp) {
this.rememberSeenMessageKey(messageKey)
continue
}
if (seen || recent) {
continue
}
if (message.isSend === 1) continue
if (previous && !seenPrimed && createTime === previousTimestamp) {
sameTimestampIncoming.push(message)
continue
}
candidateMessages.push(message)
}
const futureIncomingCount = candidateMessages.filter((message) => {
const createTime = Number(message.createTime || 0)
return !previous || createTime > previousTimestamp || seenPrimed
}).length
const sameTimestampAllowance = previous && !seenPrimed
? Math.max(0, expectedIncomingCount - futureIncomingCount)
: 0
const selectedSameTimestamp = sameTimestampAllowance > 0
? sameTimestampIncoming.slice(-sameTimestampAllowance)
: []
const messagesToPush = [...selectedSameTimestamp, ...candidateMessages]
const incomingCandidateCount = messagesToPush.length
for (const message of messagesToPush) {
const messageKey = String(message.messageKey || '').trim()
if (!messageKey) continue
const payload = await this.buildPayload(session, message)
if (!payload) continue
if (!this.shouldPushPayload(payload)) continue
httpService.broadcastMessagePush(payload)
this.rememberMessageKey(messageKey)
this.bumpSessionBaseline(session.username, message)
}
for (const message of newMessagesResult.messages) {
const messageKey = String(message.messageKey || '').trim()
if (messageKey) this.rememberSeenMessageKey(messageKey)
}
if (sessionId) this.seenPrimedSessions.add(sessionId)
return {
fetched: true,
maxFetchedTimestamp,
incomingCandidateCount,
observedIncomingCount,
expectedIncomingCount,
retry: expectedIncomingCount > 0 && observedIncomingCount < expectedIncomingCount
}
}
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 sessionType = this.getSessionType(sessionId, session)
const content = this.getMessageDisplayContent(message)
const createTime = Number(message.createTime || 0)
if (isGroup) {
const groupInfo = await chatService.getContactAvatar(sessionId)
const groupName = session.displayName || groupInfo?.displayName || sessionId
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || groupInfo?.avatarUrl)
return {
event: 'message.new',
sessionId,
sessionType,
messageKey,
avatarUrl,
groupName,
sourceName,
content,
timestamp: createTime
}
}
const contactInfo = await chatService.getContactAvatar(sessionId)
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || contactInfo?.avatarUrl)
return {
event: 'message.new',
sessionId,
sessionType,
messageKey,
avatarUrl,
sourceName: session.displayName || contactInfo?.displayName || sessionId,
content,
timestamp: createTime
}
}
private async normalizePushAvatarUrl(avatarUrl?: string): Promise<string | undefined> {
const normalized = String(avatarUrl || '').trim()
if (!normalized) return undefined
if (!normalized.startsWith('data:image/')) {
return normalized
}
const cached = this.pushAvatarDataCache.get(normalized)
if (cached) return cached
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(normalized)
if (!match) return undefined
try {
const mimeType = match[1].toLowerCase()
const base64Data = match[2]
const imageBuffer = Buffer.from(base64Data, 'base64')
if (!imageBuffer.length) return undefined
const ext = this.getImageExtFromMime(mimeType)
const hash = createHash('sha1').update(normalized).digest('hex')
const filePath = path.join(this.pushAvatarCacheDir, `avatar_${hash}.${ext}`)
await fs.mkdir(this.pushAvatarCacheDir, { recursive: true })
try {
await fs.access(filePath)
} catch {
await fs.writeFile(filePath, imageBuffer)
}
const fileUrl = pathToFileURL(filePath).toString()
this.pushAvatarDataCache.set(normalized, fileUrl)
return fileUrl
} catch {
return undefined
}
}
private getImageExtFromMime(mimeType: string): string {
if (mimeType === 'image/png') return 'png'
if (mimeType === 'image/gif') return 'gif'
if (mimeType === 'image/webp') return 'webp'
return 'jpg'
}
private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] {
if (sessionId.endsWith('@chatroom')) {
return 'group'
}
if (sessionId.startsWith('gh_') || session.type === 'official') {
return 'official'
}
if (session.type === 'friend') {
return 'private'
}
return 'other'
}
private shouldPushPayload(payload: MessagePushPayload): boolean {
const sessionId = String(payload.sessionId || '').trim()
const filterMode = this.getMessagePushFilterMode()
if (filterMode === 'all') {
return true
}
const filterList = this.getMessagePushFilterList()
const listed = filterList.has(sessionId)
if (filterMode === 'whitelist') {
return listed
}
return !listed
}
private getMessagePushFilterMode(): 'all' | 'whitelist' | 'blacklist' {
const value = this.configService.get('messagePushFilterMode')
if (value === 'whitelist' || value === 'blacklist') return value
return 'all'
}
private getMessagePushFilterList(): Set<string> {
const value = this.configService.get('messagePushFilterList')
if (!Array.isArray(value)) return new Set()
return new Set(value.map((item) => String(item || '').trim()).filter(Boolean))
}
private isSessionTableChange(tableName: string): boolean {
return String(tableName || '').trim().toLowerCase() === 'session'
}
private isMessageTableChange(tableName: string): boolean {
const normalized = String(tableName || '').trim().toLowerCase()
if (!normalized) return false
return normalized === 'message' ||
normalized === 'msg' ||
normalized.startsWith('message_') ||
normalized.startsWith('msg_') ||
normalized.includes('message')
}
private bumpSessionBaseline(sessionId: string, message: Message): void {
const key = String(sessionId || '').trim()
if (!key) return
const createTime = Number(message.createTime || 0)
if (!Number.isFinite(createTime) || createTime <= 0) return
const current = this.sessionBaseline.get(key) || { lastTimestamp: 0, unreadCount: 0 }
if (createTime > current.lastTimestamp) {
this.sessionBaseline.set(key, {
...current,
lastTimestamp: createTime
})
}
}
private getMessageDisplayContent(message: Message): string | null {
const cleanOfficialPrefix = (value: string | null): string | null => {
if (!value) return value
return value.replace(/^\s*\[\]\s*/u, '').trim() || value
}
switch (Number(message.localType || 0)) {
case 1:
return cleanOfficialPrefix(message.rawContent || null)
case 3:
return '[图片]'
case 34:
return '[语音]'
case 43:
return '[视频]'
case 47:
return '[表情]'
case 42:
return cleanOfficialPrefix(message.cardNickname || '[名片]')
case 48:
return '[位置]'
case 49:
return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]')
default:
return cleanOfficialPrefix(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 senderKey = senderUsername.toLowerCase()
const nickname = groupNicknames[senderKey]
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
? this.sanitizeGroupNicknames(result.nicknames)
: {}
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
return nicknames
}
private sanitizeGroupNicknames(nicknames: Record<string, string>): Record<string, string> {
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
const slot = buckets.get(memberId)
if (slot) {
slot.add(nickname)
} else {
buckets.set(memberId, new Set([nickname]))
}
}
const trusted: Record<string, string> = {}
for (const [memberId, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted[memberId] = Array.from(nicknameSet)[0]
}
return trusted
}
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 isSeenMessage(messageKey: string): boolean {
this.pruneSeenMessageKeys()
const timestamp = this.seenMessageKeys.get(messageKey)
return typeof timestamp === 'number' && Date.now() - timestamp < this.recentMessageTtlMs
}
private rememberSeenMessageKey(messageKey: string): void {
this.seenMessageKeys.set(messageKey, Date.now())
this.pruneSeenMessageKeys()
}
private pruneRecentMessageKeys(): void {
const now = Date.now()
for (const [key, timestamp] of this.recentMessageKeys.entries()) {
if (now - timestamp > this.recentMessageTtlMs) {
this.recentMessageKeys.delete(key)
}
}
}
private pruneSeenMessageKeys(): void {
const now = Date.now()
for (const [key, timestamp] of this.seenMessageKeys.entries()) {
if (now - timestamp > this.recentMessageTtlMs) {
this.seenMessageKeys.delete(key)
}
}
}
}
export const messagePushService = new MessagePushService()