mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-12 15:08:36 +00:00
Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev
This commit is contained in:
@@ -13,6 +13,7 @@ export interface BizAccount {
|
||||
type: number
|
||||
last_time: number
|
||||
formatted_last_time: string
|
||||
unread_count?: number
|
||||
}
|
||||
|
||||
export interface BizMessage {
|
||||
@@ -104,19 +105,24 @@ export class BizService {
|
||||
if (!root || !accountWxid) return []
|
||||
|
||||
const bizLatestTime: Record<string, number> = {}
|
||||
const bizUnreadCount: Record<string, number> = {}
|
||||
|
||||
try {
|
||||
const sessionsRes = await wcdbService.getSessions()
|
||||
const sessionsRes = await chatService.getSessions()
|
||||
if (sessionsRes.success && sessionsRes.sessions) {
|
||||
for (const session of sessionsRes.sessions) {
|
||||
const uname = session.username || session.strUsrName || session.userName || session.id
|
||||
// 适配日志中发现的字段,注意转为整型数字
|
||||
const timeStr = session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
|
||||
const timeStr = session.lastTimestamp || session.sortTimestamp || session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
|
||||
const time = parseInt(timeStr.toString(), 10)
|
||||
|
||||
if (usernames.includes(uname) && time > 0) {
|
||||
bizLatestTime[uname] = time
|
||||
}
|
||||
if (usernames.includes(uname)) {
|
||||
const unread = Number(session.unreadCount ?? session.unread_count ?? 0)
|
||||
bizUnreadCount[uname] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -152,7 +158,8 @@ export class BizService {
|
||||
avatar: info?.avatarUrl || '',
|
||||
type: 0,
|
||||
last_time: lastTime,
|
||||
formatted_last_time: formatBizTime(lastTime)
|
||||
formatted_last_time: formatBizTime(lastTime),
|
||||
unread_count: bizUnreadCount[uname] || 0
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -232,6 +232,16 @@ interface SessionDetailExtra {
|
||||
|
||||
type SessionDetail = SessionDetailFast & SessionDetailExtra
|
||||
|
||||
interface SyntheticUnreadState {
|
||||
readTimestamp: number
|
||||
scannedTimestamp: number
|
||||
latestTimestamp: number
|
||||
unreadCount: number
|
||||
summaryTimestamp?: number
|
||||
summary?: string
|
||||
lastMsgType?: number
|
||||
}
|
||||
|
||||
interface MyFootprintSummary {
|
||||
private_inbound_people: number
|
||||
private_replied_people: number
|
||||
@@ -378,6 +388,7 @@ class ChatService {
|
||||
private readonly messageDbCountSnapshotCacheTtlMs = 8000
|
||||
private sessionMessageCountCache = new Map<string, { count: number; updatedAt: number }>()
|
||||
private sessionMessageCountHintCache = new Map<string, number>()
|
||||
private syntheticUnreadState = new Map<string, SyntheticUnreadState>()
|
||||
private sessionMessageCountBatchCache: {
|
||||
dbSignature: string
|
||||
sessionIdsKey: string
|
||||
@@ -865,6 +876,10 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.addMissingOfficialSessions(sessions, myWxid)
|
||||
await this.applySyntheticUnreadCounts(sessions)
|
||||
sessions.sort((a, b) => Number(b.sortTimestamp || b.lastTimestamp || 0) - Number(a.sortTimestamp || a.lastTimestamp || 0))
|
||||
|
||||
// 不等待联系人信息加载,直接返回基础会话列表
|
||||
// 前端可以异步调用 enrichSessionsWithContacts 来补充信息
|
||||
return { success: true, sessions }
|
||||
@@ -874,6 +889,242 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
private async addMissingOfficialSessions(sessions: ChatSession[], myWxid?: string): Promise<void> {
|
||||
const existing = new Set(sessions.map((session) => String(session.username || '').trim()).filter(Boolean))
|
||||
try {
|
||||
const contactResult = await wcdbService.getContactsCompact()
|
||||
if (!contactResult.success || !Array.isArray(contactResult.contacts)) return
|
||||
|
||||
for (const row of contactResult.contacts as Record<string, any>[]) {
|
||||
const username = String(row.username || '').trim()
|
||||
if (!username.startsWith('gh_') || existing.has(username)) continue
|
||||
|
||||
sessions.push({
|
||||
username,
|
||||
type: 0,
|
||||
unreadCount: 0,
|
||||
summary: '查看公众号历史消息',
|
||||
sortTimestamp: 0,
|
||||
lastTimestamp: 0,
|
||||
lastMsgType: 0,
|
||||
displayName: row.remark || row.nick_name || row.alias || username,
|
||||
avatarUrl: undefined,
|
||||
selfWxid: myWxid
|
||||
})
|
||||
existing.add(username)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ChatService] 补充公众号会话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private shouldUseSyntheticUnread(sessionId: string): boolean {
|
||||
const normalized = String(sessionId || '').trim()
|
||||
return normalized.startsWith('gh_')
|
||||
}
|
||||
|
||||
private async getSessionMessageStatsSnapshot(sessionId: string): Promise<{ total: number; latestTimestamp: number }> {
|
||||
const tableStatsResult = await wcdbService.getMessageTableStats(sessionId)
|
||||
if (!tableStatsResult.success || !Array.isArray(tableStatsResult.tables)) {
|
||||
return { total: 0, latestTimestamp: 0 }
|
||||
}
|
||||
|
||||
let total = 0
|
||||
let latestTimestamp = 0
|
||||
for (const row of tableStatsResult.tables as Record<string, any>[]) {
|
||||
const count = Number(row.count ?? row.message_count ?? row.messageCount ?? 0)
|
||||
if (Number.isFinite(count) && count > 0) {
|
||||
total += Math.floor(count)
|
||||
}
|
||||
|
||||
const latest = Number(
|
||||
row.last_timestamp ??
|
||||
row.lastTimestamp ??
|
||||
row.last_time ??
|
||||
row.lastTime ??
|
||||
row.max_create_time ??
|
||||
row.maxCreateTime ??
|
||||
0
|
||||
)
|
||||
if (Number.isFinite(latest) && latest > latestTimestamp) {
|
||||
latestTimestamp = Math.floor(latest)
|
||||
}
|
||||
}
|
||||
|
||||
return { total, latestTimestamp }
|
||||
}
|
||||
|
||||
private async applySyntheticUnreadCounts(sessions: ChatSession[]): Promise<void> {
|
||||
const candidates = sessions.filter((session) => this.shouldUseSyntheticUnread(session.username))
|
||||
if (candidates.length === 0) return
|
||||
|
||||
for (const session of candidates) {
|
||||
try {
|
||||
const snapshot = await this.getSessionMessageStatsSnapshot(session.username)
|
||||
const latestTimestamp = Math.max(
|
||||
Number(session.lastTimestamp || 0),
|
||||
Number(session.sortTimestamp || 0),
|
||||
snapshot.latestTimestamp
|
||||
)
|
||||
if (latestTimestamp > 0) {
|
||||
session.lastTimestamp = latestTimestamp
|
||||
session.sortTimestamp = Math.max(Number(session.sortTimestamp || 0), latestTimestamp)
|
||||
}
|
||||
if (snapshot.total > 0) {
|
||||
session.messageCountHint = Math.max(Number(session.messageCountHint || 0), snapshot.total)
|
||||
this.sessionMessageCountHintCache.set(session.username, session.messageCountHint)
|
||||
}
|
||||
|
||||
let state = this.syntheticUnreadState.get(session.username)
|
||||
if (!state) {
|
||||
const initialUnread = await this.getInitialSyntheticUnreadState(session.username, latestTimestamp)
|
||||
state = {
|
||||
readTimestamp: latestTimestamp,
|
||||
scannedTimestamp: latestTimestamp,
|
||||
latestTimestamp,
|
||||
unreadCount: initialUnread.count
|
||||
}
|
||||
if (initialUnread.latestMessage) {
|
||||
state.summary = this.getSessionSummaryFromMessage(initialUnread.latestMessage)
|
||||
state.summaryTimestamp = Number(initialUnread.latestMessage.createTime || latestTimestamp)
|
||||
state.lastMsgType = Number(initialUnread.latestMessage.localType || 0)
|
||||
}
|
||||
this.syntheticUnreadState.set(session.username, state)
|
||||
}
|
||||
|
||||
let latestMessageForSummary: Message | undefined
|
||||
if (latestTimestamp > state.scannedTimestamp) {
|
||||
const newMessagesResult = await this.getNewMessages(
|
||||
session.username,
|
||||
Math.max(0, state.scannedTimestamp),
|
||||
1000
|
||||
)
|
||||
if (newMessagesResult.success && Array.isArray(newMessagesResult.messages)) {
|
||||
let nextUnread = state.unreadCount
|
||||
let nextScannedTimestamp = state.scannedTimestamp
|
||||
for (const message of newMessagesResult.messages) {
|
||||
const createTime = Number(message.createTime || 0)
|
||||
if (!Number.isFinite(createTime) || createTime <= state.scannedTimestamp) continue
|
||||
if (message.isSend === 1) continue
|
||||
nextUnread += 1
|
||||
latestMessageForSummary = message
|
||||
if (createTime > nextScannedTimestamp) {
|
||||
nextScannedTimestamp = Math.floor(createTime)
|
||||
}
|
||||
}
|
||||
state.unreadCount = nextUnread
|
||||
state.scannedTimestamp = Math.max(nextScannedTimestamp, latestTimestamp)
|
||||
} else {
|
||||
state.scannedTimestamp = latestTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
state.latestTimestamp = Math.max(state.latestTimestamp, latestTimestamp)
|
||||
if (latestMessageForSummary) {
|
||||
const summary = this.getSessionSummaryFromMessage(latestMessageForSummary)
|
||||
if (summary) {
|
||||
state.summary = summary
|
||||
state.summaryTimestamp = Number(latestMessageForSummary.createTime || latestTimestamp)
|
||||
state.lastMsgType = Number(latestMessageForSummary.localType || 0)
|
||||
}
|
||||
}
|
||||
if (state.summary) {
|
||||
session.summary = state.summary
|
||||
session.lastMsgType = Number(state.lastMsgType || session.lastMsgType || 0)
|
||||
}
|
||||
session.unreadCount = Math.max(Number(session.unreadCount || 0), state.unreadCount)
|
||||
} catch (error) {
|
||||
console.warn(`[ChatService] 合成公众号未读失败: ${session.username}`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getSessionSummaryFromMessage(message: Message): string {
|
||||
const cleanOfficialPrefix = (value: string): string => value.replace(/^\s*\[视频号\]\s*/u, '').trim()
|
||||
let summary = ''
|
||||
switch (Number(message.localType || 0)) {
|
||||
case 1:
|
||||
summary = message.parsedContent || message.rawContent || ''
|
||||
break
|
||||
case 3:
|
||||
summary = '[图片]'
|
||||
break
|
||||
case 34:
|
||||
summary = '[语音]'
|
||||
break
|
||||
case 43:
|
||||
summary = '[视频]'
|
||||
break
|
||||
case 47:
|
||||
summary = '[表情]'
|
||||
break
|
||||
case 42:
|
||||
summary = message.cardNickname || '[名片]'
|
||||
break
|
||||
case 48:
|
||||
summary = '[位置]'
|
||||
break
|
||||
case 49:
|
||||
summary = message.linkTitle || message.fileName || message.parsedContent || '[消息]'
|
||||
break
|
||||
default:
|
||||
summary = message.parsedContent || message.rawContent || this.getMessageTypeLabel(Number(message.localType || 0))
|
||||
break
|
||||
}
|
||||
return cleanOfficialPrefix(this.cleanString(summary))
|
||||
}
|
||||
|
||||
private async getInitialSyntheticUnreadState(sessionId: string, latestTimestamp: number): Promise<{
|
||||
count: number
|
||||
latestMessage?: Message
|
||||
}> {
|
||||
const normalizedLatest = Number(latestTimestamp || 0)
|
||||
if (!Number.isFinite(normalizedLatest) || normalizedLatest <= 0) return { count: 0 }
|
||||
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
if (Math.abs(nowSeconds - normalizedLatest) > 10 * 60) {
|
||||
return { count: 0 }
|
||||
}
|
||||
|
||||
const result = await this.getNewMessages(sessionId, Math.max(0, Math.floor(normalizedLatest) - 1), 20)
|
||||
if (!result.success || !Array.isArray(result.messages)) return { count: 0 }
|
||||
const unreadMessages = result.messages.filter((message) => {
|
||||
const createTime = Number(message.createTime || 0)
|
||||
return Number.isFinite(createTime) &&
|
||||
createTime >= normalizedLatest &&
|
||||
message.isSend !== 1
|
||||
})
|
||||
return {
|
||||
count: unreadMessages.length,
|
||||
latestMessage: unreadMessages[unreadMessages.length - 1]
|
||||
}
|
||||
}
|
||||
|
||||
private markSyntheticUnreadRead(sessionId: string, messages: Message[] = []): void {
|
||||
const normalized = String(sessionId || '').trim()
|
||||
if (!this.shouldUseSyntheticUnread(normalized)) return
|
||||
|
||||
let latestTimestamp = 0
|
||||
const state = this.syntheticUnreadState.get(normalized)
|
||||
if (state) latestTimestamp = Math.max(latestTimestamp, state.latestTimestamp, state.scannedTimestamp)
|
||||
for (const message of messages) {
|
||||
const createTime = Number(message.createTime || 0)
|
||||
if (Number.isFinite(createTime) && createTime > latestTimestamp) {
|
||||
latestTimestamp = Math.floor(createTime)
|
||||
}
|
||||
}
|
||||
|
||||
this.syntheticUnreadState.set(normalized, {
|
||||
readTimestamp: latestTimestamp,
|
||||
scannedTimestamp: latestTimestamp,
|
||||
latestTimestamp,
|
||||
unreadCount: 0,
|
||||
summary: state?.summary,
|
||||
summaryTimestamp: state?.summaryTimestamp,
|
||||
lastMsgType: state?.lastMsgType
|
||||
})
|
||||
}
|
||||
|
||||
async getSessionStatuses(usernames: string[]): Promise<{
|
||||
success: boolean
|
||||
map?: Record<string, { isFolded?: boolean; isMuted?: boolean }>
|
||||
@@ -1814,6 +2065,9 @@ class ChatService {
|
||||
releaseMessageCursorMutex?.()
|
||||
|
||||
this.messageCacheService.set(sessionId, filtered)
|
||||
if (offset === 0 && startTime === 0 && endTime === 0) {
|
||||
this.markSyntheticUnreadRead(sessionId, filtered)
|
||||
}
|
||||
console.log(
|
||||
`[ChatService] getMessages session=${sessionId} rawRowsConsumed=${rawRowsConsumed} visibleMessagesReturned=${filtered.length} filteredOut=${collected.filteredOut || 0} nextOffset=${state.fetched} hasMore=${hasMore}`
|
||||
)
|
||||
@@ -4416,6 +4670,8 @@ class ChatService {
|
||||
case '57':
|
||||
// 引用消息,title 就是回复的内容
|
||||
return title
|
||||
case '53':
|
||||
return `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}`
|
||||
case '2000':
|
||||
return `[转账] ${title}`
|
||||
case '2001':
|
||||
@@ -4445,6 +4701,8 @@ class ChatService {
|
||||
return '[链接]'
|
||||
case '87':
|
||||
return '[群公告]'
|
||||
case '53':
|
||||
return '[接龙]'
|
||||
default:
|
||||
return '[消息]'
|
||||
}
|
||||
@@ -5044,6 +5302,8 @@ class ChatService {
|
||||
const quoteInfo = this.parseQuoteMessage(content)
|
||||
result.quotedContent = quoteInfo.content
|
||||
result.quotedSender = quoteInfo.sender
|
||||
} else if (xmlType === '53') {
|
||||
result.appMsgKind = 'solitaire'
|
||||
} else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) {
|
||||
result.appMsgKind = 'official-link'
|
||||
} else if (url) {
|
||||
|
||||
@@ -61,6 +61,8 @@ interface ConfigSchema {
|
||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
notificationFilterList: string[]
|
||||
messagePushEnabled: boolean
|
||||
messagePushFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
messagePushFilterList: string[]
|
||||
httpApiEnabled: boolean
|
||||
httpApiPort: number
|
||||
httpApiHost: string
|
||||
@@ -177,6 +179,8 @@ export class ConfigService {
|
||||
httpApiPort: 5031,
|
||||
httpApiHost: '127.0.0.1',
|
||||
messagePushEnabled: false,
|
||||
messagePushFilterMode: 'all',
|
||||
messagePushFilterList: [],
|
||||
windowCloseBehavior: 'ask',
|
||||
quoteLayout: 'quote-top',
|
||||
wordCloudExcludeWords: [],
|
||||
|
||||
@@ -2119,6 +2119,7 @@ class ExportService {
|
||||
}
|
||||
return title || '[引用消息]'
|
||||
}
|
||||
if (xmlType === '53') return title ? `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}` : '[接龙]'
|
||||
if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]'
|
||||
|
||||
// 有 title 就返回 title
|
||||
@@ -3220,6 +3221,8 @@ class ExportService {
|
||||
appMsgKind = 'announcement'
|
||||
} else if (xmlType === '57' || hasReferMsg || localType === 244813135921) {
|
||||
appMsgKind = 'quote'
|
||||
} else if (xmlType === '53') {
|
||||
appMsgKind = 'solitaire'
|
||||
} else if (xmlType === '5' || xmlType === '49') {
|
||||
appMsgKind = 'link'
|
||||
} else if (looksLikeAppMsg) {
|
||||
|
||||
@@ -98,7 +98,12 @@ export class KeyServiceLinux {
|
||||
'xwechat',
|
||||
'/opt/wechat/wechat',
|
||||
'/usr/bin/wechat',
|
||||
'/opt/apps/com.tencent.wechat/files/wechat'
|
||||
'/usr/local/bin/wechat',
|
||||
'/usr/bin/wechat',
|
||||
'/opt/apps/com.tencent.wechat/files/wechat',
|
||||
'/usr/bin/wechat-bin',
|
||||
'/usr/local/bin/wechat-bin',
|
||||
'com.tencent.wechat'
|
||||
]
|
||||
|
||||
for (const binName of wechatBins) {
|
||||
@@ -152,7 +157,7 @@ export class KeyServiceLinux {
|
||||
}
|
||||
|
||||
if (!pid) {
|
||||
const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动并登录。'
|
||||
const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动微信,看到登录窗口后点击确认。'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface SessionBaseline {
|
||||
interface MessagePushPayload {
|
||||
event: 'message.new'
|
||||
sessionId: string
|
||||
sessionType: 'private' | 'group' | 'official' | 'other'
|
||||
messageKey: string
|
||||
avatarUrl?: string
|
||||
sourceName: string
|
||||
@@ -20,6 +21,8 @@ interface MessagePushPayload {
|
||||
|
||||
const PUSH_CONFIG_KEYS = new Set([
|
||||
'messagePushEnabled',
|
||||
'messagePushFilterMode',
|
||||
'messagePushFilterList',
|
||||
'dbPath',
|
||||
'decryptKey',
|
||||
'myWxid'
|
||||
@@ -38,6 +41,7 @@ class MessagePushService {
|
||||
private rerunRequested = false
|
||||
private started = false
|
||||
private baselineReady = false
|
||||
private messageTableScanRequested = false
|
||||
|
||||
constructor() {
|
||||
this.configService = ConfigService.getInstance()
|
||||
@@ -60,12 +64,15 @@ class MessagePushService {
|
||||
payload = null
|
||||
}
|
||||
|
||||
const tableName = String(payload?.table || '').trim().toLowerCase()
|
||||
if (tableName && tableName !== 'session') {
|
||||
const tableName = String(payload?.table || '').trim()
|
||||
if (this.isSessionTableChange(tableName)) {
|
||||
this.scheduleSync()
|
||||
return
|
||||
}
|
||||
|
||||
this.scheduleSync()
|
||||
if (!tableName || this.isMessageTableChange(tableName)) {
|
||||
this.scheduleSync({ scanMessageBackedSessions: true })
|
||||
}
|
||||
}
|
||||
|
||||
async handleConfigChanged(key: string): Promise<void> {
|
||||
@@ -91,6 +98,7 @@ class MessagePushService {
|
||||
this.recentMessageKeys.clear()
|
||||
this.groupNicknameCache.clear()
|
||||
this.baselineReady = false
|
||||
this.messageTableScanRequested = false
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
@@ -121,7 +129,11 @@ class MessagePushService {
|
||||
this.baselineReady = true
|
||||
}
|
||||
|
||||
private scheduleSync(): void {
|
||||
private scheduleSync(options: { scanMessageBackedSessions?: boolean } = {}): void {
|
||||
if (options.scanMessageBackedSessions) {
|
||||
this.messageTableScanRequested = true
|
||||
}
|
||||
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
}
|
||||
@@ -141,6 +153,8 @@ class MessagePushService {
|
||||
this.processing = true
|
||||
try {
|
||||
if (!this.isPushEnabled()) return
|
||||
const scanMessageBackedSessions = this.messageTableScanRequested
|
||||
this.messageTableScanRequested = false
|
||||
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) {
|
||||
@@ -163,27 +177,47 @@ class MessagePushService {
|
||||
const previousBaseline = new Map(this.sessionBaseline)
|
||||
this.setBaseline(sessions)
|
||||
|
||||
const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session))
|
||||
const candidates = sessions.filter((session) => {
|
||||
const previous = previousBaseline.get(session.username)
|
||||
if (this.shouldInspectSession(previous, session)) {
|
||||
return true
|
||||
}
|
||||
return scanMessageBackedSessions && this.shouldScanMessageBackedSession(previous, session)
|
||||
})
|
||||
for (const session of candidates) {
|
||||
await this.pushSessionMessages(session, previousBaseline.get(session.username))
|
||||
await this.pushSessionMessages(
|
||||
session,
|
||||
previousBaseline.get(session.username) || this.sessionBaseline.get(session.username)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
this.processing = false
|
||||
if (this.rerunRequested) {
|
||||
this.rerunRequested = false
|
||||
this.scheduleSync()
|
||||
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) {
|
||||
this.sessionBaseline.set(session.username, {
|
||||
lastTimestamp: Number(session.lastTimestamp || 0),
|
||||
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 shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
||||
@@ -204,16 +238,30 @@ class MessagePushService {
|
||||
return unreadCount > 0 && lastTimestamp > 0
|
||||
}
|
||||
|
||||
if (lastTimestamp <= previous.lastTimestamp) {
|
||||
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
|
||||
}
|
||||
|
||||
// unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送
|
||||
return unreadCount > previous.unreadCount
|
||||
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<void> {
|
||||
const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1)
|
||||
const since = Math.max(0, Number(previous?.lastTimestamp || 0))
|
||||
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
|
||||
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
|
||||
return
|
||||
@@ -224,7 +272,7 @@ class MessagePushService {
|
||||
if (!messageKey) continue
|
||||
if (message.isSend === 1) continue
|
||||
|
||||
if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) {
|
||||
if (previous && Number(message.createTime || 0) <= Number(previous.lastTimestamp || 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -234,9 +282,11 @@ class MessagePushService {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +296,7 @@ class MessagePushService {
|
||||
if (!sessionId || !messageKey) return null
|
||||
|
||||
const isGroup = sessionId.endsWith('@chatroom')
|
||||
const sessionType = this.getSessionType(sessionId, session)
|
||||
const content = this.getMessageDisplayContent(message)
|
||||
|
||||
if (isGroup) {
|
||||
@@ -255,6 +306,7 @@ class MessagePushService {
|
||||
return {
|
||||
event: 'message.new',
|
||||
sessionId,
|
||||
sessionType,
|
||||
messageKey,
|
||||
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
|
||||
groupName,
|
||||
@@ -267,6 +319,7 @@ class MessagePushService {
|
||||
return {
|
||||
event: 'message.new',
|
||||
sessionId,
|
||||
sessionType,
|
||||
messageKey,
|
||||
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
|
||||
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
||||
@@ -274,10 +327,84 @@ class MessagePushService {
|
||||
}
|
||||
}
|
||||
|
||||
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 message.rawContent || null
|
||||
return cleanOfficialPrefix(message.rawContent || null)
|
||||
case 3:
|
||||
return '[图片]'
|
||||
case 34:
|
||||
@@ -287,13 +414,13 @@ class MessagePushService {
|
||||
case 47:
|
||||
return '[表情]'
|
||||
case 42:
|
||||
return message.cardNickname || '[名片]'
|
||||
return cleanOfficialPrefix(message.cardNickname || '[名片]')
|
||||
case 48:
|
||||
return '[位置]'
|
||||
case 49:
|
||||
return message.linkTitle || message.fileName || '[消息]'
|
||||
return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]')
|
||||
default:
|
||||
return message.parsedContent || message.rawContent || null
|
||||
return cleanOfficialPrefix(message.parsedContent || message.rawContent || null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user