mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:07:40 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99c6e923bc | ||
|
|
afb876250e | ||
|
|
19bf497af0 | ||
|
|
784bed0ee0 | ||
|
|
52c56bfc98 | ||
|
|
e69c3626d6 | ||
|
|
7864cf001f | ||
|
|
264d25b539 | ||
|
|
62c1b0063f | ||
|
|
4aef093802 | ||
|
|
88ee3f19bb | ||
|
|
8503c92f3a | ||
|
|
95b3ba6121 |
Binary file not shown.
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 153 KiB |
@@ -1172,13 +1172,37 @@ const renderExportScript = (name: string): string => `
|
|||||||
}
|
}
|
||||||
const renderPaymentContent = (data, kind) => {
|
const renderPaymentContent = (data, kind) => {
|
||||||
const isTransfer = kind === 'transfer'
|
const isTransfer = kind === 'transfer'
|
||||||
|
const pay = (isTransfer ? data.transfer : data.pay) || {}
|
||||||
|
const amount = pay.amountText
|
||||||
|
const memo = pay.payMemo
|
||||||
|
const statusText = isTransfer ? pay.transferStatusText : pay.redPacketStatusText
|
||||||
|
const meta = [amount, memo ? '备注:' + memo : '', statusText]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
const title = isTransfer
|
||||||
|
? (data.title || '微信转账')
|
||||||
|
: (pay.sendTitle || pay.receiveTitle || data.title || '微信红包')
|
||||||
|
const blurb =
|
||||||
|
data.description ||
|
||||||
|
data.des ||
|
||||||
|
(isTransfer ? '转账消息' : (pay.sceneText || '恭喜发财,大吉大利'))
|
||||||
|
// 状态/金额/备注始终单独露出,避免被消息原文 des 盖住。
|
||||||
|
const description = meta
|
||||||
|
? (blurb && blurb !== meta ? blurb + ' · ' + meta : meta)
|
||||||
|
: blurb
|
||||||
|
const footerBits = isTransfer
|
||||||
|
? [pay.paySubtype === '3' ? '收款' : pay.paySubtype === '1' || pay.paySubtype === '4' ? '转账' : '',
|
||||||
|
pay.transferId ? '单号 ' + pay.transferId : '']
|
||||||
|
: [pay.hbType ? '类型 ' + pay.hbType : '', pay.sendId ? 'sendid ' + pay.sendId : '']
|
||||||
return '<div class="structured-content payment-content ' + (isTransfer ? 'transfer' : 'red-packet') +
|
return '<div class="structured-content payment-content ' + (isTransfer ? 'transfer' : 'red-packet') +
|
||||||
'" data-rich-kind="' + (isTransfer ? 'transfer' : 'redPacket') + '">' +
|
'" data-rich-kind="' + (isTransfer ? 'transfer' : 'redPacket') + '">' +
|
||||||
'<div class="structured-kicker">' + (isTransfer ? '微信转账' : '微信红包') + '</div>' +
|
'<div class="structured-kicker">' + (isTransfer ? '微信转账' : '微信红包') + '</div>' +
|
||||||
'<div class="structured-title">' + displayText(data.title || (isTransfer ? '微信转账' : '微信红包')) + '</div>' +
|
'<div class="structured-title">' + displayText(title) + '</div>' +
|
||||||
'<div class="structured-description">' +
|
'<div class="structured-description">' + displayText(description) + '</div>' +
|
||||||
displayText(data.description || data.des || (isTransfer ? '转账消息' : '恭喜发财,大吉大利')) +
|
(footerBits.filter(Boolean).length
|
||||||
'</div></div>'
|
? '<div class="structured-footer">' + displayText(footerBits.filter(Boolean).join(' · ')) + '</div>'
|
||||||
|
: '') +
|
||||||
|
'</div>'
|
||||||
}
|
}
|
||||||
const renderVoipContent = (data) => {
|
const renderVoipContent = (data) => {
|
||||||
const title = Number(data.roomType) === 1 ? '视频通话' : '语音通话'
|
const title = Number(data.roomType) === 1 ? '视频通话' : '语音通话'
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-c
|
|||||||
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
||||||
import { imageFileQuality } from '../shared/image-quality'
|
import { imageFileQuality } from '../shared/image-quality'
|
||||||
import { resolveMemberName } from '../shared/member-names'
|
import { resolveMemberName } from '../shared/member-names'
|
||||||
|
import { FavoritesService } from './favorites-service'
|
||||||
import { filesystemSafeName } from '../shared/contact-name'
|
import { filesystemSafeName } from '../shared/contact-name'
|
||||||
|
|
||||||
const jobs = new Set<string>()
|
const jobs = new Set<string>()
|
||||||
@@ -853,6 +854,28 @@ async function runSingleExport(
|
|||||||
percent: Math.max(1, Math.round(((targetOrder + 1) / targets.length) * 10))
|
percent: Math.max(1, Math.round(((targetOrder + 1) / targets.length) * 10))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (request.includeFavorites) {
|
||||||
|
const wcdb = chat.getChatDb()?.getWcdb4Client()
|
||||||
|
if (wcdb) {
|
||||||
|
try {
|
||||||
|
const favoriteMessages = await new FavoritesService(wcdb).listExportMessages(500)
|
||||||
|
for (const [messageOrder, message] of favoriteMessages.entries()) {
|
||||||
|
if (!request.kinds.includes(kindOf(message))) continue
|
||||||
|
messageEntries.push({
|
||||||
|
message: {
|
||||||
|
...message,
|
||||||
|
exportConversationId: 'favorites',
|
||||||
|
exportConversationName: '收藏'
|
||||||
|
},
|
||||||
|
targetOrder: targets.length,
|
||||||
|
messageOrder
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[export] favorites merge skipped:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const messages = messageEntries
|
const messages = messageEntries
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const byTime = Number(left.message.createTime || 0) - Number(right.message.createTime || 0)
|
const byTime = Number(left.message.createTime || 0) - Number(right.message.createTime || 0)
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Message, ParsedContent } from '../shared/types'
|
||||||
|
import { favoriteRowToContent } from '../shared/favorites'
|
||||||
|
import type { Wcdb4Client } from './wcdb4-client'
|
||||||
|
|
||||||
|
/** 收藏只读服务:favorite.db → 可读卡片 / 导出消息。 */
|
||||||
|
export class FavoritesService {
|
||||||
|
constructor(private readonly wcdb4Client: Wcdb4Client) {}
|
||||||
|
|
||||||
|
async listContents(limit = 200): Promise<ParsedContent[]> {
|
||||||
|
const rows = await this.wcdb4Client.listFavoriteItems(limit)
|
||||||
|
return rows.map((row) => favoriteRowToContent(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收藏转成导出/搜索可用的 Message 列表(合成会话「收藏」)。 */
|
||||||
|
async listExportMessages(limit = 200): Promise<Message[]> {
|
||||||
|
const rows = await this.wcdb4Client.listFavoriteItems(limit)
|
||||||
|
return rows.map((row, index) => {
|
||||||
|
const contentData = favoriteRowToContent(row)
|
||||||
|
const createTime = Number(row.update_time) || 0
|
||||||
|
return {
|
||||||
|
id: `fav-${row.local_id ?? index}`,
|
||||||
|
from: 'favorite',
|
||||||
|
type: '收藏',
|
||||||
|
datetime: createTime ? new Date(createTime * 1000).toISOString() : '',
|
||||||
|
content: textOf(contentData),
|
||||||
|
isSender: false,
|
||||||
|
createTime,
|
||||||
|
name: '收藏',
|
||||||
|
contentData
|
||||||
|
} as Message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只读关键词搜索收藏文本(title/desc/正文)。 */
|
||||||
|
async search(query: string, limit = 50): Promise<ParsedContent[]> {
|
||||||
|
const needle = String(query || '').trim().toLowerCase()
|
||||||
|
if (!needle) return []
|
||||||
|
const items = await this.listContents(500)
|
||||||
|
return items.filter((item) => textOf(item).toLowerCase().includes(needle)).slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local Query API 适配:只要文本与时间戳。 */
|
||||||
|
async searchHits(
|
||||||
|
query: string,
|
||||||
|
limit = 20
|
||||||
|
): Promise<Array<{ text: string; timestamp?: number }>> {
|
||||||
|
const rows = await this.wcdb4Client.listFavoriteItems(500)
|
||||||
|
const needle = String(query || '').trim().toLowerCase()
|
||||||
|
if (!needle) return []
|
||||||
|
return rows
|
||||||
|
.map((row) => {
|
||||||
|
const content = favoriteRowToContent(row)
|
||||||
|
return {
|
||||||
|
text: textOf(content),
|
||||||
|
timestamp: Number(row.update_time) || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((hit) => hit.text.toLowerCase().includes(needle))
|
||||||
|
.slice(0, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textOf(content: ParsedContent): string {
|
||||||
|
if (content.type === 'text') return content.content
|
||||||
|
if (content.type === 'system') return content.content
|
||||||
|
if (content.type === 'share') return [content.title, content.des].filter(Boolean).join(' · ')
|
||||||
|
if (content.type === 'location') return content.poiname || content.label || '[位置]'
|
||||||
|
if (content.type === 'miniProgram') return content.title || '[小程序]'
|
||||||
|
if (content.type === 'forwardBundle') return content.title || '[聊天记录]'
|
||||||
|
return '[收藏]'
|
||||||
|
}
|
||||||
@@ -771,6 +771,13 @@ app.whenReady().then(async () => {
|
|||||||
localQueryApiService.setImageOcrEntryProvider((conversationId, messageId) =>
|
localQueryApiService.setImageOcrEntryProvider((conversationId, messageId) =>
|
||||||
imageTextIndexService.getConversationOcr(conversationId).get(messageId)
|
imageTextIndexService.getConversationOcr(conversationId).get(messageId)
|
||||||
)
|
)
|
||||||
|
// 收藏只读检索:search_messages 合并 favorite.db 命中(无库时返回空)。
|
||||||
|
localQueryApiService.setFavoritesSearchProvider(async (query, limit) => {
|
||||||
|
const wcdb = chat.getChatDb()?.getWcdb4Client()
|
||||||
|
if (!wcdb) return []
|
||||||
|
const { FavoritesService } = await import('./favorites-service')
|
||||||
|
return new FavoritesService(wcdb).searchHits(query, limit)
|
||||||
|
})
|
||||||
setLocalQueryApiService(localQueryApiService)
|
setLocalQueryApiService(localQueryApiService)
|
||||||
// Query Agent:生产 Runtime 只在这里实例化一次,桌面问问微信与 Agent Hub 共用同一个实例。
|
// Query Agent:生产 Runtime 只在这里实例化一次,桌面问问微信与 Agent Hub 共用同一个实例。
|
||||||
queryAgentService = new QueryAgentService(
|
queryAgentService = new QueryAgentService(
|
||||||
|
|||||||
+482
-9
@@ -1,3 +1,5 @@
|
|||||||
|
import { describeRedPacketStatus, describeTransferStatus } from '../shared/payment-status'
|
||||||
|
|
||||||
type TextContent = { type: 'text'; content: string }
|
type TextContent = { type: 'text'; content: string }
|
||||||
type VoiceContent = { type: 'voice'; duration?: number }
|
type VoiceContent = { type: 'voice'; duration?: number }
|
||||||
type LocationContent = {
|
type LocationContent = {
|
||||||
@@ -22,6 +24,44 @@ type ShareContent = {
|
|||||||
appname?: string
|
appname?: string
|
||||||
typeVal?: string
|
typeVal?: string
|
||||||
articles?: ShareArticle[]
|
articles?: ShareArticle[]
|
||||||
|
transfer?: TransferPaymentInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransferPaymentInfo = {
|
||||||
|
paySubtype?: string
|
||||||
|
amountText?: string
|
||||||
|
transcationId?: string
|
||||||
|
transferId?: string
|
||||||
|
invalidTime?: string
|
||||||
|
beginTransferTime?: string
|
||||||
|
effectiveDate?: string
|
||||||
|
payMemo?: string
|
||||||
|
receiverUsername?: string
|
||||||
|
payerUsername?: string
|
||||||
|
transferStatus?: string
|
||||||
|
/** `transfer_status` 展示文案(只读)。 */
|
||||||
|
transferStatusText?: string
|
||||||
|
transId?: string
|
||||||
|
feeType?: string
|
||||||
|
transferAttach?: string
|
||||||
|
refundBankType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedPacketPaymentInfo = {
|
||||||
|
templateId?: string
|
||||||
|
receiveTitle?: string
|
||||||
|
sendTitle?: string
|
||||||
|
sceneText?: string
|
||||||
|
senderDes?: string
|
||||||
|
receiverDes?: string
|
||||||
|
iconUrl?: string
|
||||||
|
nativeUrl?: string
|
||||||
|
sendId?: string
|
||||||
|
hbType?: string
|
||||||
|
hbStatus?: string
|
||||||
|
receiveStatus?: string
|
||||||
|
/** `hb_status` / `receive_status` 展示文案(只读)。 */
|
||||||
|
redPacketStatusText?: string
|
||||||
}
|
}
|
||||||
type ForwardedMessageItem = {
|
type ForwardedMessageItem = {
|
||||||
messageType: number
|
messageType: number
|
||||||
@@ -51,6 +91,7 @@ type RedPacketContent = {
|
|||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
url?: string
|
url?: string
|
||||||
|
pay?: RedPacketPaymentInfo
|
||||||
}
|
}
|
||||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||||
type ImageContent = {
|
type ImageContent = {
|
||||||
@@ -122,10 +163,26 @@ export type ParsedContent =
|
|||||||
| SystemContent
|
| SystemContent
|
||||||
| UnknownContent
|
| UnknownContent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `local_type` 可能是打包 u64:低 32 位为类型枚举,高 32 位为标志/子类型。
|
||||||
|
*/
|
||||||
|
export function normalizeLocalType(messageType: number | string): number {
|
||||||
|
try {
|
||||||
|
const value =
|
||||||
|
typeof messageType === 'number'
|
||||||
|
? BigInt(Math.trunc(messageType))
|
||||||
|
: BigInt(String(messageType).trim())
|
||||||
|
return Number(value & 0xffffffffn)
|
||||||
|
} catch {
|
||||||
|
const fallback = Number(messageType)
|
||||||
|
return Number.isFinite(fallback) ? Math.trunc(fallback) : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function parseMessageContent(content: string, messageType: number): ParsedContent {
|
export function parseMessageContent(content: string, messageType: number): ParsedContent {
|
||||||
// Voice rows may keep their binary payload outside msgContent, so an empty
|
const localType = normalizeLocalType(messageType)
|
||||||
// content string is still a valid voice message.
|
// content string is still a valid voice message.
|
||||||
if (messageType === 34) {
|
if (localType === 34) {
|
||||||
const duration = parseVoiceDurationSeconds(content)
|
const duration = parseVoiceDurationSeconds(content)
|
||||||
return duration === undefined ? { type: 'voice' } : { type: 'voice', duration }
|
return duration === undefined ? { type: 'voice' } : { type: 'voice', duration }
|
||||||
}
|
}
|
||||||
@@ -135,11 +192,19 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
|||||||
|
|
||||||
const normalized = content.trim()
|
const normalized = content.trim()
|
||||||
|
|
||||||
switch (messageType) {
|
switch (localType) {
|
||||||
case 1:
|
case 1:
|
||||||
return { type: 'text', content: normalized }
|
return { type: 'text', content: normalized }
|
||||||
|
case 2:
|
||||||
|
return parseLegacyType2(normalized)
|
||||||
case 3:
|
case 3:
|
||||||
return parseImageMessage(normalized)
|
return parseImageMessage(normalized)
|
||||||
|
case 8:
|
||||||
|
return parseLegacyType8(normalized)
|
||||||
|
case 17:
|
||||||
|
return parseForwardBundle(normalized)
|
||||||
|
case 37:
|
||||||
|
return parseFriendVerifyMessage(normalized)
|
||||||
case 42:
|
case 42:
|
||||||
return parseCardMessage(normalized)
|
return parseCardMessage(normalized)
|
||||||
case 43:
|
case 43:
|
||||||
@@ -156,7 +221,7 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
|||||||
case 10002:
|
case 10002:
|
||||||
return parseSystemMessage(normalized)
|
return parseSystemMessage(normalized)
|
||||||
default:
|
default:
|
||||||
return { type: 'unknown', raw: normalized, messageType }
|
return { type: 'unknown', raw: normalized, messageType: localType }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,6 +545,30 @@ function parseLocationMessage(content: string): ParsedContent {
|
|||||||
function parseShareMessage(content: string): ParsedContent {
|
function parseShareMessage(content: string): ParsedContent {
|
||||||
const appMsgType = extractAppMsgType(content)
|
const appMsgType = extractAppMsgType(content)
|
||||||
const isFileMessage = appMsgType === '6' || appMsgType === '74'
|
const isFileMessage = appMsgType === '6' || appMsgType === '74'
|
||||||
|
if (/<patMsg\b/i.test(content) || /sysmsg[^>]+type=["']pat["']/i.test(content)) {
|
||||||
|
return parsePatMessage(content)
|
||||||
|
}
|
||||||
|
if (/<findernamecard\b/i.test(content)) {
|
||||||
|
return parseFinderNameCard(content)
|
||||||
|
}
|
||||||
|
if (/<productitem\b/i.test(content) && !extractXmlValue(content, 'title')) {
|
||||||
|
return parseProductItem(content)
|
||||||
|
}
|
||||||
|
if (looksLikeMusicShare(content)) {
|
||||||
|
return parseMusicShareCard(content)
|
||||||
|
}
|
||||||
|
if (looksLikeSubscribeCard(content)) {
|
||||||
|
return parseSubscribeCard(content)
|
||||||
|
}
|
||||||
|
if (looksLikeKefuCard(content)) {
|
||||||
|
return parseKefuCard(content)
|
||||||
|
}
|
||||||
|
if (looksLikeStreamVideo(content)) {
|
||||||
|
return parseStreamVideoCard(content)
|
||||||
|
}
|
||||||
|
if (looksLikeGiftCard(content)) {
|
||||||
|
return parseGiftCard(content)
|
||||||
|
}
|
||||||
if (appMsgType === '19') {
|
if (appMsgType === '19') {
|
||||||
return parseForwardBundle(content)
|
return parseForwardBundle(content)
|
||||||
}
|
}
|
||||||
@@ -522,12 +611,25 @@ function parseShareMessage(content: string): ParsedContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appMsgType === '2001') {
|
if (appMsgType === '2001' || /mmpayhb|receivehongbao|wxpay:\/\/c2cbizmessagehandler\/hongbao/i.test(content)) {
|
||||||
|
const pay = parseWcpayInfo(content)
|
||||||
|
const url = decodeXmlUrl(extractXmlValue(content, 'url')) || undefined
|
||||||
return {
|
return {
|
||||||
type: 'redPacket',
|
type: 'redPacket',
|
||||||
title: extractXmlValue(content, 'title') || '微信红包',
|
title:
|
||||||
description: extractXmlValue(content, 'des') || '恭喜发财,大吉大利',
|
pay.sendTitle ||
|
||||||
url: decodeXmlUrl(extractXmlValue(content, 'url')) || undefined
|
pay.receiveTitle ||
|
||||||
|
extractXmlValue(content, 'title') ||
|
||||||
|
'微信红包',
|
||||||
|
description:
|
||||||
|
extractXmlValue(content, 'des') ||
|
||||||
|
pay.sceneText ||
|
||||||
|
'恭喜发财,大吉大利',
|
||||||
|
url,
|
||||||
|
pay: {
|
||||||
|
...pay,
|
||||||
|
sendId: pay.sendId || extractSendIdFromUrl(url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,6 +649,12 @@ function parseShareMessage(content: string): ParsedContent {
|
|||||||
const typeVal = extractXmlValue(content, 'type') || ''
|
const typeVal = extractXmlValue(content, 'type') || ''
|
||||||
|
|
||||||
if (!title && !url) {
|
if (!title && !url) {
|
||||||
|
if (/<productitem\b/i.test(content)) return parseProductItem(content)
|
||||||
|
if (looksLikeMusicShare(content)) return parseMusicShareCard(content)
|
||||||
|
if (looksLikeSubscribeCard(content)) return parseSubscribeCard(content)
|
||||||
|
if (looksLikeKefuCard(content)) return parseKefuCard(content)
|
||||||
|
if (looksLikeStreamVideo(content)) return parseStreamVideoCard(content)
|
||||||
|
if (looksLikeGiftCard(content)) return parseGiftCard(content)
|
||||||
return { type: 'unknown', raw: content }
|
return { type: 'unknown', raw: content }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,7 +665,372 @@ function parseShareMessage(content: string): ParsedContent {
|
|||||||
url,
|
url,
|
||||||
appname,
|
appname,
|
||||||
typeVal,
|
typeVal,
|
||||||
articles: articles.length > 1 ? articles : undefined
|
articles: articles.length > 1 ? articles : undefined,
|
||||||
|
transfer: typeVal === '2000' ? parseWcpayInfo(content) : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只读解析 `<wcpayinfo>`(转账 / 红包展示字段;不做支付)。 */
|
||||||
|
function parseWcpayInfo(content: string): TransferPaymentInfo & RedPacketPaymentInfo {
|
||||||
|
const block = /<wcpayinfo>([\s\S]*?)<\/wcpayinfo>/i.exec(content)?.[1] || content
|
||||||
|
const val = (tag: string): string | undefined => {
|
||||||
|
const raw = extractXmlValue(block, tag)
|
||||||
|
return raw ? decodeXmlEntities(raw) || undefined : undefined
|
||||||
|
}
|
||||||
|
const transferStatus = val('transfer_status')
|
||||||
|
const hbStatus = val('hb_status')
|
||||||
|
const receiveStatus = val('receive_status')
|
||||||
|
return {
|
||||||
|
paySubtype: val('paysubtype'),
|
||||||
|
amountText: val('feedesc'),
|
||||||
|
transcationId: val('transcationid'),
|
||||||
|
transferId: val('transferid'),
|
||||||
|
invalidTime: val('invalidtime'),
|
||||||
|
beginTransferTime: val('begintransfertime'),
|
||||||
|
effectiveDate: val('effectivedate'),
|
||||||
|
payMemo: val('pay_memo') || undefined,
|
||||||
|
receiverUsername: val('receiver_username'),
|
||||||
|
payerUsername: val('payer_username'),
|
||||||
|
transferStatus,
|
||||||
|
transferStatusText: describeTransferStatus(transferStatus),
|
||||||
|
transId: val('trans_id'),
|
||||||
|
feeType: val('fee_type'),
|
||||||
|
transferAttach: val('transfer_attach'),
|
||||||
|
refundBankType: val('refund_bank_type'),
|
||||||
|
templateId: val('templateid'),
|
||||||
|
receiveTitle: val('receivertitle'),
|
||||||
|
sendTitle: val('sendertitle'),
|
||||||
|
sceneText: val('scenetext'),
|
||||||
|
senderDes: val('senderdes'),
|
||||||
|
receiverDes: val('receiverdes'),
|
||||||
|
iconUrl: decodeXmlUrl(val('iconurl') || '') || undefined,
|
||||||
|
nativeUrl: val('nativeurl'),
|
||||||
|
hbType: val('hb_type'),
|
||||||
|
hbStatus,
|
||||||
|
receiveStatus,
|
||||||
|
redPacketStatusText: describeRedPacketStatus(hbStatus, receiveStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSendIdFromUrl(url?: string): string | undefined {
|
||||||
|
if (!url) return undefined
|
||||||
|
try {
|
||||||
|
return new URL(url).searchParams.get('sendid') || undefined
|
||||||
|
} catch {
|
||||||
|
return /sendid=(\d+)/i.exec(url)?.[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拍一拍(`HandlePatMsg` / `<patMsg>`)→ 系统文案。 */
|
||||||
|
function parsePatMessage(content: string): ParsedContent {
|
||||||
|
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||||
|
const template =
|
||||||
|
extractXmlValue(decoded, 'template') ||
|
||||||
|
extractXmlValue(decoded, 'pattemplate') ||
|
||||||
|
extractXmlValue(decoded, 'plain') ||
|
||||||
|
''
|
||||||
|
const from = extractXmlValue(decoded, 'fromusername') || extractXmlValue(decoded, 'fromuser')
|
||||||
|
const to = extractXmlValue(decoded, 'tousername') || extractXmlValue(decoded, 'touser')
|
||||||
|
const pat = extractXmlValue(decoded, 'pat') || extractXmlValue(decoded, 'patted')
|
||||||
|
const text =
|
||||||
|
normalizeSystemText(
|
||||||
|
template.replace(/\$from\$/g, from || '').replace(/\$to\$/g, to || '').replace(/\$pat\$/g, pat || '')
|
||||||
|
) || normalizeSystemText(decoded.replace(/<[^>]+>/g, ' ')) || '拍了拍'
|
||||||
|
return { type: 'system', content: text, raw: content }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视频号名片(`<findernamecard>`)→ 只读 share。 */
|
||||||
|
function parseFinderNameCard(content: string): ParsedContent {
|
||||||
|
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||||
|
const username = extractXmlValue(decoded, 'username') || extractXmlValue(decoded, 'finderUsername')
|
||||||
|
const nickname = extractXmlValue(decoded, 'nickname') || extractXmlValue(decoded, 'finderNickname')
|
||||||
|
const title = nickname || username || extractXmlValue(decoded, 'title') || '视频号名片'
|
||||||
|
const url = decodeXmlUrl(extractXmlValue(decoded, 'url') || extractXmlValue(decoded, 'appPageUrl') || '')
|
||||||
|
return {
|
||||||
|
type: 'share',
|
||||||
|
title,
|
||||||
|
des: username && nickname ? username : undefined,
|
||||||
|
url: url || '',
|
||||||
|
appname: '视频号',
|
||||||
|
typeVal: extractAppMsgType(content) || 'findernamecard'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 商品卡(`<productitem>`)→ 只读 share。 */
|
||||||
|
function parseProductItem(content: string): ParsedContent {
|
||||||
|
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||||
|
const title =
|
||||||
|
extractXmlValue(decoded, 'productName') ||
|
||||||
|
extractXmlValue(decoded, 'product_name') ||
|
||||||
|
extractXmlValue(decoded, 'title') ||
|
||||||
|
'商品'
|
||||||
|
const des =
|
||||||
|
extractXmlValue(decoded, 'productDesc') ||
|
||||||
|
extractXmlValue(decoded, 'sellingPrice') ||
|
||||||
|
extractXmlValue(decoded, 'referdes')
|
||||||
|
const url = decodeXmlUrl(extractXmlValue(decoded, 'url') || extractXmlValue(decoded, 'productUrl') || '')
|
||||||
|
return {
|
||||||
|
type: 'share',
|
||||||
|
title,
|
||||||
|
des: des || undefined,
|
||||||
|
url: url || '',
|
||||||
|
appname: extractXmlValue(decoded, 'sellername') || '商品',
|
||||||
|
typeVal: extractAppMsgType(content) || 'productitem'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 好友验证(local_type=37)→ 系统文案,避免 unknown 黑块。 */
|
||||||
|
function parseFriendVerifyMessage(content: string): ParsedContent {
|
||||||
|
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||||
|
const text = extractSysmsgTemplateText(decoded)
|
||||||
|
if (text) return { type: 'system', content: text, raw: content }
|
||||||
|
const nickname = extractXmlValue(decoded, 'nickname') || extractXmlValue(decoded, 'NickName')
|
||||||
|
const username = extractXmlValue(decoded, 'username') || extractXmlValue(decoded, 'UserName')
|
||||||
|
const contentText =
|
||||||
|
extractXmlValue(decoded, 'content') ||
|
||||||
|
extractXmlValue(decoded, 'bighead') ||
|
||||||
|
extractXmlValue(decoded, 'source')
|
||||||
|
const label = normalizeSystemText(
|
||||||
|
[nickname || username ? `${nickname || username}` : '', contentText || '好友验证消息']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
)
|
||||||
|
return { type: 'system', content: label || '好友验证消息', raw: content }
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeMusicShare(content: string): boolean {
|
||||||
|
return /songalbumurl|songlyric|musicShareItem|tingListenItem|ListenItem|music_share_item|musicurl|<songlyri|<songalbu/i.test(
|
||||||
|
content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeSubscribeCard(content: string): boolean {
|
||||||
|
return /subscribeMessage|subscribe_msg|SubscribeMsg|updatablemsg|wadynamicpageinf|OnSubscriptionCustom/i.test(
|
||||||
|
content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeKefuCard(content: string): boolean {
|
||||||
|
return /opencustomerservicemsg|wa_app_kefu_message|kefumenu|kf_order|kf_user_|ChatKfTemplate|AppReaderTemplate|template_header/i.test(
|
||||||
|
content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流视频 / 长视频卡(`streamvideotitle` / `finderMegaVideo`)。 */
|
||||||
|
function looksLikeStreamVideo(content: string): boolean {
|
||||||
|
return /streamvideotitle|streamvideoword|streamvideoweburl|streamvideothumb|finderMegaVideo|finderLiveInvite/i.test(
|
||||||
|
content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 礼物 / 礼品卡(`csgift` / `giftcarditem`)。
|
||||||
|
* 只读展示;**不**调用 `acceptgiftcard` / `preacceptgiftcard` / `getcardgiftinfo`。
|
||||||
|
*/
|
||||||
|
function looksLikeGiftCard(content: string): boolean {
|
||||||
|
return /<csgift\b|<ecsgift\b|<giftcarditem\b|<giftcard\b|giftcarditem|acceptgiftcard/i.test(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流视频卡 → 只读 share。 */
|
||||||
|
function parseStreamVideoCard(content: string): ParsedContent {
|
||||||
|
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||||
|
const title =
|
||||||
|
extractXmlValue(decoded, 'streamvideotitle') ||
|
||||||
|
extractXmlValue(decoded, 'title') ||
|
||||||
|
extractXmlValue(decoded, 'sourcetitle') ||
|
||||||
|
'视频'
|
||||||
|
const des =
|
||||||
|
extractXmlValue(decoded, 'streamvideoword') ||
|
||||||
|
extractXmlValue(decoded, 'des') ||
|
||||||
|
extractXmlValue(decoded, 'contentdescshowtext') ||
|
||||||
|
undefined
|
||||||
|
const url =
|
||||||
|
decodeXmlUrl(
|
||||||
|
extractXmlValue(decoded, 'streamvideoweburl') ||
|
||||||
|
extractXmlValue(decoded, 'url') ||
|
||||||
|
extractXmlValue(decoded, 'weburl')
|
||||||
|
) || ''
|
||||||
|
return {
|
||||||
|
type: 'share',
|
||||||
|
title,
|
||||||
|
des,
|
||||||
|
url,
|
||||||
|
appname: /finderMegaVideo|finderLiveInvite/i.test(content) ? '视频号' : '视频',
|
||||||
|
typeVal: extractAppMsgType(content) || 'streamvideo'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 礼物 / 礼品卡 → 只读 share(无 title 时 system 文案)。 */
|
||||||
|
function parseGiftCard(content: string): ParsedContent {
|
||||||
|
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||||
|
const title =
|
||||||
|
extractXmlValue(decoded, 'title') ||
|
||||||
|
extractXmlValue(decoded, 'gifttitle') ||
|
||||||
|
extractXmlValue(decoded, 'cardtitle') ||
|
||||||
|
extractXmlValue(decoded, 'brandname')
|
||||||
|
const des =
|
||||||
|
extractXmlValue(decoded, 'des') ||
|
||||||
|
extractXmlValue(decoded, 'giftwording') ||
|
||||||
|
extractXmlValue(decoded, 'cardwording') ||
|
||||||
|
extractXmlValue(decoded, 'description')
|
||||||
|
const url = decodeXmlUrl(extractXmlValue(decoded, 'url') || extractXmlValue(decoded, 'cardurl') || '')
|
||||||
|
if (!title && !des) {
|
||||||
|
return {
|
||||||
|
type: 'system',
|
||||||
|
content: normalizeSystemText(decoded.replace(/<[^>]+>/g, ' ')) || '礼物卡',
|
||||||
|
raw: content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'share',
|
||||||
|
title: title || '礼物卡',
|
||||||
|
des: des || undefined,
|
||||||
|
url: url || '',
|
||||||
|
appname: extractXmlValue(decoded, 'brandname') || '礼物',
|
||||||
|
typeVal: extractAppMsgType(content) || 'giftcard'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* local_type=2:真机 histogram 未采到;按内容形态尽量落到 system/text,
|
||||||
|
* 否则保留 unknown(见 wechat-message-type-coverage.md)。
|
||||||
|
*/
|
||||||
|
function parseLegacyType2(content: string): ParsedContent {
|
||||||
|
if (/<sysmsg\b|<patMsg\b|revoke/i.test(content)) {
|
||||||
|
return parseSystemMessage(content)
|
||||||
|
}
|
||||||
|
if (!/<[a-zA-Z!]/.test(content)) {
|
||||||
|
return { type: 'text', content }
|
||||||
|
}
|
||||||
|
return parseSystemMessage(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* local_type=8:社区多见于 GIF/大表情变体;无真机样本时按 sticker → image → system
|
||||||
|
* 逐级尝试,避免直接黑块。
|
||||||
|
*/
|
||||||
|
function parseLegacyType8(content: string): ParsedContent {
|
||||||
|
const sticker = parseStickerMessage(content)
|
||||||
|
if (sticker.type === 'sticker' && sticker.md5) return sticker
|
||||||
|
const image = parseImageMessage(content)
|
||||||
|
if (image.type === 'image' && (image.md5 || image.datName)) return image
|
||||||
|
if (/<sysmsg\b|<emoji\b|<msg\b/i.test(content)) {
|
||||||
|
const system = parseSystemMessage(content)
|
||||||
|
if (system.type === 'system' && system.content && system.content !== content) return system
|
||||||
|
}
|
||||||
|
return { type: 'unknown', raw: content, messageType: 8 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 音乐 / 听歌分享(`musicShareItem` / `ListenItem` / `songalbumurl`)→ 只读 share。 */
|
||||||
|
function parseMusicShareCard(content: string): ParsedContent {
|
||||||
|
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||||
|
const title =
|
||||||
|
extractXmlValue(decoded, 'title') ||
|
||||||
|
extractXmlValue(decoded, 'songname') ||
|
||||||
|
extractXmlValue(decoded, 'musicTitle') ||
|
||||||
|
'音乐分享'
|
||||||
|
const des =
|
||||||
|
extractXmlValue(decoded, 'des') ||
|
||||||
|
extractXmlValue(decoded, 'singername') ||
|
||||||
|
extractXmlValue(decoded, 'albumname') ||
|
||||||
|
undefined
|
||||||
|
const url =
|
||||||
|
decodeXmlUrl(
|
||||||
|
extractXmlValue(decoded, 'url') ||
|
||||||
|
extractXmlValue(decoded, 'musicurl') ||
|
||||||
|
extractXmlValue(decoded, 'streamweburl') ||
|
||||||
|
extractXmlValue(decoded, 'weburl')
|
||||||
|
) || ''
|
||||||
|
const appname =
|
||||||
|
extractXmlValue(decoded, 'appname') ||
|
||||||
|
extractXmlValue(decoded, 'publisher') ||
|
||||||
|
(looksLikeListenItem(content) ? '听一听' : '音乐')
|
||||||
|
return {
|
||||||
|
type: 'share',
|
||||||
|
title,
|
||||||
|
des,
|
||||||
|
url,
|
||||||
|
appname,
|
||||||
|
typeVal: extractAppMsgType(content) || 'music'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeListenItem(content: string): boolean {
|
||||||
|
return /tingListenItem|ListenItem|MMLISTEN_ITEM_TYPE/i.test(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅号 / 可更新消息模板(`subscribeMessage` / `updatablemsg`)→ 只读 share。
|
||||||
|
* 模板头 `template_header` / `template_detail` 作标题与摘要。
|
||||||
|
*/
|
||||||
|
function parseSubscribeCard(content: string): ParsedContent {
|
||||||
|
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||||
|
const title =
|
||||||
|
extractXmlValue(decoded, 'template_header') ||
|
||||||
|
extractXmlValue(decoded, 'title') ||
|
||||||
|
extractXmlValue(decoded, 'templatetitle') ||
|
||||||
|
'订阅消息'
|
||||||
|
const des =
|
||||||
|
extractXmlValue(decoded, 'template_detail') ||
|
||||||
|
extractXmlValue(decoded, 'des') ||
|
||||||
|
extractXmlValue(decoded, 'digest') ||
|
||||||
|
undefined
|
||||||
|
const url =
|
||||||
|
decodeXmlUrl(
|
||||||
|
extractXmlValue(decoded, 'url') ||
|
||||||
|
extractXmlValue(decoded, 'jumpUrl') ||
|
||||||
|
extractXmlValue(decoded, 'weburl')
|
||||||
|
) || ''
|
||||||
|
return {
|
||||||
|
type: 'share',
|
||||||
|
title,
|
||||||
|
des,
|
||||||
|
url,
|
||||||
|
appname: extractXmlValue(decoded, 'appname') || '订阅消息',
|
||||||
|
typeVal: extractAppMsgType(content) || 'subscribe'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客服 / 门店模板卡(`opencustomerservicemsg` / `wa_app_kefu_message` / `kefumenu`)
|
||||||
|
* → 只读 share;无 title 时退化为 system 文案,避免 unknown 黑块。
|
||||||
|
*/
|
||||||
|
function parseKefuCard(content: string): ParsedContent {
|
||||||
|
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||||
|
const title =
|
||||||
|
extractXmlValue(decoded, 'template_header') ||
|
||||||
|
extractXmlValue(decoded, 'title') ||
|
||||||
|
extractXmlValue(decoded, 'kf_title') ||
|
||||||
|
extractXmlValue(decoded, 'templatetitle')
|
||||||
|
const des =
|
||||||
|
extractXmlValue(decoded, 'template_detail') ||
|
||||||
|
extractXmlValue(decoded, 'kf_order_text') ||
|
||||||
|
extractXmlValue(decoded, 'des') ||
|
||||||
|
extractXmlValue(decoded, 'content')
|
||||||
|
const url =
|
||||||
|
decodeXmlUrl(
|
||||||
|
extractXmlValue(decoded, 'url') ||
|
||||||
|
extractXmlValue(decoded, 'jumpUrl') ||
|
||||||
|
extractXmlValue(decoded, 'weburl')
|
||||||
|
) || ''
|
||||||
|
const appname =
|
||||||
|
extractXmlValue(decoded, 'kf_user_name') ||
|
||||||
|
extractXmlValue(decoded, 'appname') ||
|
||||||
|
'客服消息'
|
||||||
|
if (!title && !des) {
|
||||||
|
return {
|
||||||
|
type: 'system',
|
||||||
|
content: normalizeSystemText(decoded.replace(/<[^>]+>/g, ' ')) || '客服消息',
|
||||||
|
raw: content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'share',
|
||||||
|
title: title || '客服消息',
|
||||||
|
des: des || undefined,
|
||||||
|
url: url || '',
|
||||||
|
appname,
|
||||||
|
typeVal: extractAppMsgType(content) || 'kefu'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ export interface GroupSnapshot {
|
|||||||
roomId: string
|
roomId: string
|
||||||
memberCount: number
|
memberCount: number
|
||||||
groupName?: string
|
groupName?: string
|
||||||
|
/** 只读 roominfo(contact.db / chatroom);字段可能随版本缺失。 */
|
||||||
|
roomInfo?: GroupRoomInfo
|
||||||
members: {
|
members: {
|
||||||
wxid: string
|
wxid: string
|
||||||
nickname: string
|
nickname: string
|
||||||
@@ -190,6 +192,18 @@ export interface GroupSnapshot {
|
|||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 群 roominfo 只读字段(列名兼容多版本;缺失则为 undefined)。 */
|
||||||
|
export interface GroupRoomInfo {
|
||||||
|
roomId: string
|
||||||
|
owner?: string
|
||||||
|
announcement?: string
|
||||||
|
announcementEditor?: string
|
||||||
|
maxMemberCount?: number
|
||||||
|
chatName?: string
|
||||||
|
openImAccountType?: string
|
||||||
|
isOpenIm?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupMembershipSnapshot {
|
export interface GroupMembershipSnapshot {
|
||||||
roomId: string
|
roomId: string
|
||||||
memberIds: string[]
|
memberIds: string[]
|
||||||
@@ -1007,7 +1021,7 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
|||||||
avatar: member.m_nsHeadImgUrl || ''
|
avatar: member.m_nsHeadImgUrl || ''
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return { roomId, memberCount: members.length, members }
|
return { roomId, memberCount: members.length, members, roomInfo: wcdb4Client.getRoomInfo(roomId) || undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnapshot | null> {
|
export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnapshot | null> {
|
||||||
@@ -1032,7 +1046,8 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnaps
|
|||||||
roomId,
|
roomId,
|
||||||
groupName: session?.nickname || undefined,
|
groupName: session?.nickname || undefined,
|
||||||
memberCount: members.length,
|
memberCount: members.length,
|
||||||
members
|
members,
|
||||||
|
roomInfo: (await wcdb4Client.getRoomInfoAsync(roomId)) || undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -320,11 +320,23 @@ export class LocalQueryApiService {
|
|||||||
| ((conversationId: string, messageId: string) => { state: string; text: string } | undefined)
|
| ((conversationId: string, messageId: string) => { state: string; text: string } | undefined)
|
||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
|
/** 收藏关键词检索(favorite.db,只读)。 */
|
||||||
|
private favoritesSearch:
|
||||||
|
| ((query: string, limit: number) => Promise<Array<{ text: string; timestamp?: number }>>)
|
||||||
|
| undefined
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly knowledge?: KnowledgeSearchService,
|
private readonly knowledge?: KnowledgeSearchService,
|
||||||
private readonly nowProvider: () => Date = () => new Date()
|
private readonly nowProvider: () => Date = () => new Date()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** 注入收藏检索(`FavoritesService.searchHits`),供 `search_messages` 合并只读命中。 */
|
||||||
|
setFavoritesSearchProvider(
|
||||||
|
provider: (query: string, limit: number) => Promise<Array<{ text: string; timestamp?: number }>>
|
||||||
|
): void {
|
||||||
|
this.favoritesSearch = provider
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注入图片文字索引覆盖度提供者。
|
* 注入图片文字索引覆盖度提供者。
|
||||||
*
|
*
|
||||||
@@ -507,14 +519,30 @@ export class LocalQueryApiService {
|
|||||||
enrichmentMs,
|
enrichmentMs,
|
||||||
...(knowledgeTiming ? { knowledge: knowledgeTiming } : {})
|
...(knowledgeTiming ? { knowledge: knowledgeTiming } : {})
|
||||||
}
|
}
|
||||||
|
let favoriteEvidence: QueryEvidenceItem[] = []
|
||||||
|
if (this.favoritesSearch && !targetView) {
|
||||||
|
try {
|
||||||
|
const hits = await this.favoritesSearch(request.query, Math.min(10, limit))
|
||||||
|
favoriteEvidence = hits.map((hit, index) => ({
|
||||||
|
messageRef: `fav-${index}`,
|
||||||
|
conversationName: '收藏',
|
||||||
|
timestamp: hit.timestamp ?? 0,
|
||||||
|
sender: '收藏',
|
||||||
|
sourceKind: 'other' as QueryMessageType,
|
||||||
|
text: hit.text
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
favoriteEvidence = []
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
status: 'completed' as const,
|
status: 'completed' as const,
|
||||||
...(targetView ? { target: targetView } : {}),
|
...(targetView ? { target: targetView } : {}),
|
||||||
resolvedTimeRange: range,
|
resolvedTimeRange: range,
|
||||||
coverage: { state: this.searchCoverage(found, requestedEnd) },
|
coverage: { state: this.searchCoverage(found, requestedEnd) },
|
||||||
probeCount: probes.length,
|
probeCount: probes.length,
|
||||||
evidenceCount: found.evidence.size,
|
evidenceCount: found.evidence.size + favoriteEvidence.length,
|
||||||
evidence: Array.from(found.evidence.values()).slice(0, limit),
|
evidence: [...Array.from(found.evidence.values()).slice(0, limit), ...favoriteEvidence],
|
||||||
scope: corpus.scope,
|
scope: corpus.scope,
|
||||||
indexLatestAt: found.indexLatestAt,
|
indexLatestAt: found.indexLatestAt,
|
||||||
sourceLatestAt: found.sourceLatestAt,
|
sourceLatestAt: found.sourceLatestAt,
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ const EXECUTIONS_FILE = 'executions.json'
|
|||||||
const NOTIFICATIONS_FILE = 'notifications.json'
|
const NOTIFICATIONS_FILE = 'notifications.json'
|
||||||
const SETTINGS_FILE = 'settings.json'
|
const SETTINGS_FILE = 'settings.json'
|
||||||
const TICK_MS = 15_000
|
const TICK_MS = 15_000
|
||||||
|
const AUTO_RESEND_MAX_ATTEMPTS = 6
|
||||||
|
const AUTO_RESEND_BASE_BACKOFF_MS = 5 * 60_000
|
||||||
|
const AUTO_RESEND_MAX_BACKOFF_MS = 60 * 60_000
|
||||||
const NOTIFICATION_TEST_MESSAGE = `✅ TraceMemo 定时日报通知已开启
|
const NOTIFICATION_TEST_MESSAGE = `✅ TraceMemo 定时日报通知已开启
|
||||||
|
|
||||||
以后定时日报生成或发送出现异常时,
|
以后定时日报生成或发送出现异常时,
|
||||||
@@ -77,28 +80,6 @@ export interface ScheduledReportSendActionInput {
|
|||||||
taskId?: string
|
taskId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildScheduledReportActionRequest(
|
|
||||||
input: ScheduledReportSendActionInput
|
|
||||||
): WechatActionRequest {
|
|
||||||
return {
|
|
||||||
idempotencyKey:
|
|
||||||
input.retryCount && input.retryCount > 0
|
|
||||||
? `scheduled_report:${input.executionId}:retry:${input.retryCount}`
|
|
||||||
: `scheduled_report:${input.executionId}`,
|
|
||||||
origin: 'scheduled_report',
|
|
||||||
purpose: 'scheduled_report',
|
|
||||||
triggerType: input.triggerType === 'scheduled' ? 'automation' : 'user',
|
|
||||||
sourceId: input.executionId,
|
|
||||||
executionId: input.executionId,
|
|
||||||
recipient: { type: 'group', id: input.target },
|
|
||||||
content: { type: 'image', path: input.filePath },
|
|
||||||
metadata: {
|
|
||||||
taskId: input.taskId,
|
|
||||||
retryCount: input.retryCount || 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultDependencies = (): ScheduledReportDependencies => ({
|
const defaultDependencies = (): ScheduledReportDependencies => ({
|
||||||
getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(),
|
getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(),
|
||||||
generateReport: generateAgentGroupReport,
|
generateReport: generateAgentGroupReport,
|
||||||
@@ -161,6 +142,29 @@ interface ScheduledReportNotificationPayload {
|
|||||||
suggestedAction?: string
|
suggestedAction?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildScheduledReportActionRequest(
|
||||||
|
input: ScheduledReportSendActionInput
|
||||||
|
): WechatActionRequest {
|
||||||
|
return {
|
||||||
|
idempotencyKey:
|
||||||
|
input.retryCount && input.retryCount > 0
|
||||||
|
? `scheduled_report:${input.executionId}:retry:${input.retryCount}`
|
||||||
|
: `scheduled_report:${input.executionId}`,
|
||||||
|
origin: 'scheduled_report',
|
||||||
|
purpose: 'scheduled_report',
|
||||||
|
triggerType: input.triggerType === 'scheduled' ? 'automation' : 'user',
|
||||||
|
sourceId: input.executionId,
|
||||||
|
executionId: input.executionId,
|
||||||
|
// filehelper 是文件传输助手:唯一的真实发送验收对象,走 contact 而不是群。
|
||||||
|
recipient: { type: input.target === 'filehelper' ? 'contact' : 'group', id: input.target },
|
||||||
|
content: { type: 'image', path: input.filePath },
|
||||||
|
metadata: {
|
||||||
|
taskId: input.taskId,
|
||||||
|
retryCount: input.retryCount || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function validateScheduleTime(value: string): boolean {
|
export function validateScheduleTime(value: string): boolean {
|
||||||
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(String(value || '').trim())
|
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(String(value || '').trim())
|
||||||
}
|
}
|
||||||
@@ -498,11 +502,7 @@ export class ScheduledReportService {
|
|||||||
await this.load()
|
await this.load()
|
||||||
const execution = this.executions!.find((item) => item.id === executionId)
|
const execution = this.executions!.find((item) => item.id === executionId)
|
||||||
if (!execution) return { success: false, error: '未找到定时日报执行记录' }
|
if (!execution) return { success: false, error: '未找到定时日报执行记录' }
|
||||||
const existing = this.retrying.get(executionId)
|
const result = await this.startRetry(execution)
|
||||||
if (existing) return { success: true, data: await existing }
|
|
||||||
const promise = this.retrySend(execution).finally(() => this.retrying.delete(executionId))
|
|
||||||
this.retrying.set(executionId, promise)
|
|
||||||
const result = await promise
|
|
||||||
return {
|
return {
|
||||||
success: result.status !== 'failed',
|
success: result.status !== 'failed',
|
||||||
data: result,
|
data: result,
|
||||||
@@ -568,6 +568,7 @@ export class ScheduledReportService {
|
|||||||
async tick(at = this.deps.now?.() || new Date()): Promise<void> {
|
async tick(at = this.deps.now?.() || new Date()): Promise<void> {
|
||||||
await this.load()
|
await this.load()
|
||||||
await this.flushNotifications()
|
await this.flushNotifications()
|
||||||
|
await this.flushPendingSends(at)
|
||||||
if (!this.deps.isDatabaseReady()) return
|
if (!this.deps.isDatabaseReady()) return
|
||||||
const nowMs = at.getTime()
|
const nowMs = at.getTime()
|
||||||
for (const task of [...this.tasks!]) {
|
for (const task of [...this.tasks!]) {
|
||||||
@@ -959,6 +960,15 @@ export class ScheduledReportService {
|
|||||||
return completed
|
return completed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 为同一执行记录复用唯一的重发 Promise,合并自动重发和手动重试。 */
|
||||||
|
private startRetry(execution: ScheduledReportExecution): Promise<ScheduledReportExecution> {
|
||||||
|
const existing = this.retrying.get(execution.id)
|
||||||
|
if (existing) return existing
|
||||||
|
const promise = this.retrySend(execution).finally(() => this.retrying.delete(execution.id))
|
||||||
|
this.retrying.set(execution.id, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
private clearFailureFields(
|
private clearFailureFields(
|
||||||
execution: ScheduledReportExecution,
|
execution: ScheduledReportExecution,
|
||||||
patch: Partial<ScheduledReportExecution>
|
patch: Partial<ScheduledReportExecution>
|
||||||
@@ -1190,6 +1200,40 @@ export class ScheduledReportService {
|
|||||||
await Promise.all([this.saveNotifications(), this.saveExecutions()])
|
await Promise.all([this.saveNotifications(), this.saveExecutions()])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** waiting_to_send 执行的下一次自动重发时间:自 finishedAt 起按指数退避。 */
|
||||||
|
private autoResendDueAt(execution: ScheduledReportExecution): number {
|
||||||
|
const base = Date.parse(execution.finishedAt || execution.startedAt)
|
||||||
|
const attempt = Math.max(execution.retryCount || 0, 0)
|
||||||
|
return base + Math.min(AUTO_RESEND_BASE_BACKOFF_MS * 2 ** attempt, AUTO_RESEND_MAX_BACKOFF_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 能力恢复后自动重发挂起的日报;重试有上限,超限后仍可手动重试。 */
|
||||||
|
private async flushPendingSends(at: Date): Promise<void> {
|
||||||
|
await this.load()
|
||||||
|
const nowMs = at.getTime()
|
||||||
|
const candidates = this.executions!.filter(
|
||||||
|
(item) =>
|
||||||
|
item.status === 'waiting_to_send' &&
|
||||||
|
item.retryable !== false &&
|
||||||
|
Boolean(item.pngPath) &&
|
||||||
|
(item.retryCount || 0) < AUTO_RESEND_MAX_ATTEMPTS &&
|
||||||
|
!this.retrying.has(item.id) &&
|
||||||
|
this.autoResendDueAt(item) <= nowMs
|
||||||
|
)
|
||||||
|
const retries: Promise<ScheduledReportExecution>[] = []
|
||||||
|
for (const execution of candidates) {
|
||||||
|
const task = this.tasks!.find((item) => item.id === execution.taskId)
|
||||||
|
if (!task || !task.enabled) continue
|
||||||
|
retries.push(this.startRetry(execution))
|
||||||
|
}
|
||||||
|
const results = await Promise.allSettled(retries)
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
console.warn('[ScheduledReport] auto resend failed:', result.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private notificationCapabilityReasonForSend(
|
private notificationCapabilityReasonForSend(
|
||||||
result: AgentHubNotificationResult
|
result: AgentHubNotificationResult
|
||||||
): ScheduledReportNotificationCapabilityReason {
|
): ScheduledReportNotificationCapabilityReason {
|
||||||
@@ -1219,6 +1263,8 @@ export class ScheduledReportService {
|
|||||||
private resolveTarget(task: ScheduledReportTask): string | undefined {
|
private resolveTarget(task: ScheduledReportTask): string | undefined {
|
||||||
const explicit = String(task.target || '').trim()
|
const explicit = String(task.target || '').trim()
|
||||||
if (explicit.endsWith('@chatroom')) return explicit
|
if (explicit.endsWith('@chatroom')) return explicit
|
||||||
|
// 文件传输助手是唯一允许真实发送验收的个人目标。
|
||||||
|
if (explicit.toLowerCase() === 'filehelper') return 'filehelper'
|
||||||
const contact = resolveMd5(task.group || explicit)
|
const contact = resolveMd5(task.group || explicit)
|
||||||
return contact?.m_nsUsrName?.endsWith('@chatroom') ? contact.m_nsUsrName : undefined
|
return contact?.m_nsUsrName?.endsWith('@chatroom') ? contact.m_nsUsrName : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,5 +126,54 @@ export class AudioDecoderRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultAudioDecoderRegistry(): AudioDecoderRegistry {
|
export function createDefaultAudioDecoderRegistry(): AudioDecoderRegistry {
|
||||||
return new AudioDecoderRegistry().register(new SilkAudioDecoder())
|
return new AudioDecoderRegistry()
|
||||||
|
.register(new SilkAudioDecoder())
|
||||||
|
.register(new AmrAudioDecoder())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V3 AMR:外部 `ffmpeg` 解码为 PCM(MultiMediaDyn `CAMRDecoder` 未导出)。
|
||||||
|
*/
|
||||||
|
export class AmrAudioDecoder implements VoiceAudioDecoder {
|
||||||
|
readonly codec = 'amr'
|
||||||
|
|
||||||
|
async decode(source: EncodedVoiceSource): Promise<DecodedVoiceAudio> {
|
||||||
|
const { spawn } = await import('child_process')
|
||||||
|
const { tmpdir } = await import('os')
|
||||||
|
const { promises: fs } = await import('fs')
|
||||||
|
const { randomBytes } = await import('crypto')
|
||||||
|
const stamp = randomBytes(8).toString('hex')
|
||||||
|
const inPath = join(tmpdir(), `tracememo-amr-in-${stamp}.amr`)
|
||||||
|
const outPath = join(tmpdir(), `tracememo-amr-out-${stamp}.wav`)
|
||||||
|
await fs.writeFile(inPath, source.data)
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(
|
||||||
|
'ffmpeg',
|
||||||
|
['-y', '-i', inPath, '-f', 'wav', '-acodec', 'pcm_s16le', '-ar', '8000', '-ac', '1', outPath],
|
||||||
|
{ stdio: ['ignore', 'ignore', 'pipe'] }
|
||||||
|
)
|
||||||
|
let err = ''
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
err += String(chunk)
|
||||||
|
})
|
||||||
|
child.on('error', reject)
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) resolve()
|
||||||
|
else reject(new Error(`ffmpeg amr decode failed: ${err.slice(-200)}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const wav = await fs.readFile(outPath)
|
||||||
|
const pcm = wav.length > 44 && wav.toString('ascii', 0, 4) === 'RIFF' ? wav.subarray(44) : wav
|
||||||
|
return {
|
||||||
|
pcm,
|
||||||
|
sampleRate: 8000,
|
||||||
|
channels: 1,
|
||||||
|
sourceHash: source.sourceHash
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await fs.rm(inPath, { force: true })
|
||||||
|
await fs.rm(outPath, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
import { Wcdb4Client } from './wcdb4-client'
|
import { Wcdb4Client } from './wcdb4-client'
|
||||||
import {
|
import {
|
||||||
createDefaultAudioDecoderRegistry,
|
createDefaultAudioDecoderRegistry,
|
||||||
@@ -211,6 +213,17 @@ export class VoiceService {
|
|||||||
svrId || 0
|
svrId || 0
|
||||||
)
|
)
|
||||||
if (!voiceResult.success || !voiceResult.hex) {
|
if (!voiceResult.success || !voiceResult.hex) {
|
||||||
|
const disk = await this.resolveFromDisk(sessionId, localId, createTime, svrId)
|
||||||
|
if (disk?.length) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
source: {
|
||||||
|
data: disk,
|
||||||
|
codec: 'silk',
|
||||||
|
sourceHash: createHash('sha256').update(disk).digest('hex')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return { success: false, error: voiceResult.error || '获取语音数据失败' }
|
return { success: false, error: voiceResult.error || '获取语音数据失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +252,38 @@ export class VoiceService {
|
|||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2:旁路 `wcdb_get_voice_data`,在账号目录下找语音文件
|
||||||
|
* (`GetMsgAudioPath` 同域:Message / MsgAndFiles / VoiceTemp)。
|
||||||
|
*/
|
||||||
|
private async resolveFromDisk(
|
||||||
|
sessionId: string,
|
||||||
|
localId: number,
|
||||||
|
createTime: number,
|
||||||
|
svrId?: string | number
|
||||||
|
): Promise<Buffer | null> {
|
||||||
|
const accountRoot = this.wcdb4Client.getAccountRoot?.()
|
||||||
|
if (!accountRoot) return null
|
||||||
|
const needles = [
|
||||||
|
String(localId),
|
||||||
|
String(createTime),
|
||||||
|
svrId !== undefined && svrId !== null && String(svrId) !== '0' ? String(svrId) : '',
|
||||||
|
sessionId.replace(/@chatroom$/, '')
|
||||||
|
].filter(Boolean)
|
||||||
|
const files = await listVoiceDiskCandidates(accountRoot)
|
||||||
|
for (const file of files) {
|
||||||
|
const name = file.toLowerCase()
|
||||||
|
if (!needles.some((needle) => needle && name.includes(needle.toLowerCase()))) continue
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(file)
|
||||||
|
if (data.length) return data
|
||||||
|
} catch {
|
||||||
|
// try next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
private decodeVoiceBlob(hex: string): Buffer | null {
|
private decodeVoiceBlob(hex: string): Buffer | null {
|
||||||
try {
|
try {
|
||||||
const hexClean = hex.replace(/\s+/g, '')
|
const hexClean = hex.replace(/\s+/g, '')
|
||||||
@@ -274,3 +319,51 @@ export class VoiceService {
|
|||||||
return Buffer.concat([header, pcmData])
|
return Buffer.concat([header, pcmData])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VOICE_DISK_EXTS = new Set(['.aud', '.silk', '.amr'])
|
||||||
|
const VOICE_DISK_DIRS = [
|
||||||
|
'msg/Message',
|
||||||
|
'msg/MsgAndFiles',
|
||||||
|
'msg/VoiceTemp',
|
||||||
|
'msg/History',
|
||||||
|
'Message',
|
||||||
|
'MsgAndFiles',
|
||||||
|
'VoiceTemp',
|
||||||
|
// 真机布局(2026-09-24):cache/YYYY-MM/Message/<md5>/VoiceTemp/
|
||||||
|
'cache'
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 语音磁盘候选路径(扩展名/目录与 GetMsgAudioPath / VoiceTemp 对齐)。 */
|
||||||
|
export async function listVoiceDiskCandidates(accountRoot: string): Promise<string[]> {
|
||||||
|
const out: string[] = []
|
||||||
|
const walk = async (dir: string, depth: number): Promise<void> => {
|
||||||
|
if (out.length > 200 || depth > 6) return
|
||||||
|
let names: string[]
|
||||||
|
try {
|
||||||
|
names = await fs.readdir(dir)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const name of names) {
|
||||||
|
const full = join(dir, name)
|
||||||
|
let stat: Awaited<ReturnType<typeof fs.stat>>
|
||||||
|
try {
|
||||||
|
stat = await fs.stat(full)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
await walk(full, depth + 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const ext = name.slice(name.lastIndexOf('.')).toLowerCase()
|
||||||
|
if (!VOICE_DISK_EXTS.has(ext)) continue
|
||||||
|
out.push(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const rel of VOICE_DISK_DIRS) {
|
||||||
|
await walk(join(accountRoot, rel), 0)
|
||||||
|
if (out.length > 200) break
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,51 @@ import crypto from 'crypto'
|
|||||||
import { createRequire } from 'module'
|
import { createRequire } from 'module'
|
||||||
import { createConnection, Socket } from 'net'
|
import { createConnection, Socket } from 'net'
|
||||||
import { getResourceRoots } from './resource-paths'
|
import { getResourceRoots } from './resource-paths'
|
||||||
|
|
||||||
|
export type RoomInfoRow = {
|
||||||
|
roomId: string
|
||||||
|
owner?: string
|
||||||
|
announcement?: string
|
||||||
|
announcementEditor?: string
|
||||||
|
maxMemberCount?: number
|
||||||
|
chatName?: string
|
||||||
|
openImAccountType?: string
|
||||||
|
isOpenIm?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickString(row: Record<string, unknown>, keys: string[]): string | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = row[key]
|
||||||
|
if (typeof value === 'string' && value.trim()) return value.trim()
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRoomInfoRow(chatroomId: string, row: Record<string, unknown>): RoomInfoRow {
|
||||||
|
const openImAccountType =
|
||||||
|
pickString(row, ['openim_acct_type', 'openimAcctType', 'openim_type']) || undefined
|
||||||
|
const maxRaw = pickString(row, ['max_member_count', 'maxMemberCount', 'member_max'])
|
||||||
|
return {
|
||||||
|
roomId: chatroomId,
|
||||||
|
owner: pickString(row, ['roomowner', 'room_owner', 'owner', 'owner_id']),
|
||||||
|
announcement: pickString(row, [
|
||||||
|
'textannouncement',
|
||||||
|
'announcement',
|
||||||
|
'announcement_',
|
||||||
|
'chatroomannouncement'
|
||||||
|
]),
|
||||||
|
announcementEditor: pickString(row, [
|
||||||
|
'announcement_editor',
|
||||||
|
'announcement_editor_',
|
||||||
|
'announcementeditor'
|
||||||
|
]),
|
||||||
|
maxMemberCount: maxRaw && Number.isFinite(Number(maxRaw)) ? Number(maxRaw) : undefined,
|
||||||
|
chatName: pickString(row, ['chatroomnick', 'chat_name', 'chatroom_name', 'displayname']),
|
||||||
|
openImAccountType,
|
||||||
|
isOpenIm: openImAccountType ? openImAccountType !== '0' && openImAccountType !== '' : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
import { wcdbDebugLog } from './wcdb-debug'
|
import { wcdbDebugLog } from './wcdb-debug'
|
||||||
import type { ImageMessageCountProbe } from '../shared/image-text-index'
|
import type { ImageMessageCountProbe } from '../shared/image-text-index'
|
||||||
import { imageTextWindowToSeconds } from '../shared/image-text-index'
|
import { imageTextWindowToSeconds } from '../shared/image-text-index'
|
||||||
@@ -2678,6 +2723,63 @@ export class Wcdb4Client {
|
|||||||
return this.accountRoot
|
return this.accountRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只读 roominfo(contact.db / chatroom 或兼容列名)。
|
||||||
|
*/
|
||||||
|
getRoomInfo(chatroomId: string): RoomInfoRow | null {
|
||||||
|
if (!this.wcdbExecQuery || !chatroomId.endsWith('@chatroom')) return null
|
||||||
|
const escaped = chatroomId.replace(/'/g, "''")
|
||||||
|
const sql = `SELECT * FROM chatroom WHERE chatroomname = '${escaped}' LIMIT 1`
|
||||||
|
try {
|
||||||
|
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||||
|
this.wcdbExecQuery!(handle, 'contact', '', sql, outJson)
|
||||||
|
)
|
||||||
|
return rows?.[0] ? normalizeRoomInfoRow(chatroomId, rows[0]) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRoomInfoAsync(chatroomId: string): Promise<RoomInfoRow | null> {
|
||||||
|
if (!this.wcdbExecQuery || !chatroomId.endsWith('@chatroom')) return null
|
||||||
|
const escaped = chatroomId.replace(/'/g, "''")
|
||||||
|
const sql = `SELECT * FROM chatroom WHERE chatroomname = '${escaped}' LIMIT 1`
|
||||||
|
try {
|
||||||
|
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||||
|
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
|
||||||
|
'contact',
|
||||||
|
'',
|
||||||
|
sql
|
||||||
|
)
|
||||||
|
return rows?.[0] ? normalizeRoomInfoRow(chatroomId, rows[0]) : null
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WCDB4] getRoomInfoAsync failed:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只读列收藏(favorite.db / fav_db_item),供导出与搜索。 */
|
||||||
|
async listFavoriteItems(limit = 200): Promise<Record<string, unknown>[]> {
|
||||||
|
if (!this.wcdbExecQuery) return []
|
||||||
|
const sql = `SELECT local_id, server_id, type, update_time, content, fromusr, realchatname FROM fav_db_item ORDER BY update_time DESC LIMIT ${Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(1000, limit)
|
||||||
|
)}`
|
||||||
|
try {
|
||||||
|
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||||
|
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
|
||||||
|
'favorite',
|
||||||
|
path.join(this.accountRoot, 'db_storage/favorite/favorite.db'),
|
||||||
|
sql
|
||||||
|
)
|
||||||
|
return Array.isArray(rows) ? rows : []
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WCDB4] listFavoriteItems failed:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getKey(): string {
|
getKey(): string {
|
||||||
return this.key
|
return this.key
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+39
-1
@@ -155,6 +155,37 @@ import type {
|
|||||||
TextToSpeechSettingsResult
|
TextToSpeechSettingsResult
|
||||||
} from '../shared/text-to-speech'
|
} from '../shared/text-to-speech'
|
||||||
|
|
||||||
|
export type TransferPaymentInfo = {
|
||||||
|
paySubtype?: string
|
||||||
|
amountText?: string
|
||||||
|
transcationId?: string
|
||||||
|
transferId?: string
|
||||||
|
invalidTime?: string
|
||||||
|
beginTransferTime?: string
|
||||||
|
effectiveDate?: string
|
||||||
|
payMemo?: string
|
||||||
|
receiverUsername?: string
|
||||||
|
payerUsername?: string
|
||||||
|
transferStatus?: string
|
||||||
|
transferStatusText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RedPacketPaymentInfo = {
|
||||||
|
templateId?: string
|
||||||
|
receiveTitle?: string
|
||||||
|
sendTitle?: string
|
||||||
|
sceneText?: string
|
||||||
|
senderDes?: string
|
||||||
|
receiverDes?: string
|
||||||
|
iconUrl?: string
|
||||||
|
nativeUrl?: string
|
||||||
|
sendId?: string
|
||||||
|
hbType?: string
|
||||||
|
hbStatus?: string
|
||||||
|
receiveStatus?: string
|
||||||
|
redPacketStatusText?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| { type: 'text'; content: string }
|
| { type: 'text'; content: string }
|
||||||
| { type: 'voice'; duration?: number }
|
| { type: 'voice'; duration?: number }
|
||||||
@@ -167,6 +198,7 @@ export type ParsedContent =
|
|||||||
url: string
|
url: string
|
||||||
appname?: string
|
appname?: string
|
||||||
typeVal?: string
|
typeVal?: string
|
||||||
|
transfer?: TransferPaymentInfo
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'miniProgram'
|
type: 'miniProgram'
|
||||||
@@ -178,7 +210,13 @@ export type ParsedContent =
|
|||||||
thumbDatName?: string
|
thumbDatName?: string
|
||||||
thumbDataUrl?: string
|
thumbDataUrl?: string
|
||||||
}
|
}
|
||||||
| { type: 'redPacket'; title: string; description?: string; url?: string }
|
| {
|
||||||
|
type: 'redPacket'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
url?: string
|
||||||
|
pay?: RedPacketPaymentInfo
|
||||||
|
}
|
||||||
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||||
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
|
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -158,7 +158,22 @@ function CardBubble({ data }: { data: Extract<ParsedContent, { type: 'card' }> }
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
|
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
|
||||||
const { title, des, url, appname, articles } = data
|
const { title, des, url, appname, articles, transfer, typeVal } = data
|
||||||
|
|
||||||
|
if (String(typeVal) === '2000' || transfer) {
|
||||||
|
const pay = transfer || {}
|
||||||
|
const meta = [pay.amountText, pay.payMemo ? `备注:${pay.payMemo}` : '', pay.transferStatusText]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
return (
|
||||||
|
<div className="transfer-message">
|
||||||
|
<div className="transfer-kicker">微信转账</div>
|
||||||
|
<div className="transfer-title">{title || '微信转账'}</div>
|
||||||
|
<div className="transfer-meta">{meta || des || '转账消息'}</div>
|
||||||
|
{pay.transferId ? <div className="transfer-footer">单号 {pay.transferId}</div> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (articles?.length) {
|
if (articles?.length) {
|
||||||
return (
|
return (
|
||||||
@@ -254,6 +269,8 @@ function RedPacketBubble({
|
|||||||
const handleClick = (): void => {
|
const handleClick = (): void => {
|
||||||
if (data.url) window.open(data.url, '_blank')
|
if (data.url) window.open(data.url, '_blank')
|
||||||
}
|
}
|
||||||
|
const statusText = data.pay?.redPacketStatusText
|
||||||
|
const blurb = data.description || data.pay?.sceneText || '恭喜发财,大吉大利'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -266,7 +283,7 @@ function RedPacketBubble({
|
|||||||
</span>
|
</span>
|
||||||
<span className="red-packet-copy">
|
<span className="red-packet-copy">
|
||||||
<strong>{data.title}</strong>
|
<strong>{data.title}</strong>
|
||||||
<small>{data.description || '恭喜发财,大吉大利'}</small>
|
<small>{statusText ? `${blurb} · ${statusText}` : blurb}</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="red-packet-footer">微信红包</div>
|
<div className="red-packet-footer">微信红包</div>
|
||||||
|
|||||||
@@ -364,6 +364,43 @@
|
|||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.transfer-message {
|
||||||
|
width: min(280px, 48vw);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-kicker {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-title {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-footer {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.red-packet-message {
|
.red-packet-message {
|
||||||
width: min(280px, 48vw);
|
width: min(280px, 48vw);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export interface ExportRequest {
|
|||||||
kinds: ExportMessageKind[]
|
kinds: ExportMessageKind[]
|
||||||
includeMedia: boolean
|
includeMedia: boolean
|
||||||
includeVoiceTranscripts?: boolean
|
includeVoiceTranscripts?: boolean
|
||||||
|
/** 合并本机收藏(favorite.db)只读卡片。 */
|
||||||
|
includeFavorites?: boolean
|
||||||
preferOriginal?: boolean
|
preferOriginal?: boolean
|
||||||
fallbackThumbnail?: boolean
|
fallbackThumbnail?: boolean
|
||||||
keepMissing?: boolean
|
keepMissing?: boolean
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import type { ParsedContent } from './types'
|
||||||
|
|
||||||
|
function xmlValue(xml: string, tag: string): string | undefined {
|
||||||
|
const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(xml)
|
||||||
|
const raw = match?.[1]?.replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/, '$1').trim()
|
||||||
|
return raw || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXmlEntities(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 `fav_db_item.content` 的 `<favitem>` XML → `FavoriteRecord`(只读)。
|
||||||
|
* 覆盖真机 2026-09-24 样本形态:desc/title/link/locitem/dataitem/appbranditem/finderFeed。
|
||||||
|
*/
|
||||||
|
export function parseFavItemXml(content: string | null | undefined): FavoriteRecord {
|
||||||
|
const xml = String(content || '')
|
||||||
|
const typeMatch = /<favitem[^>]*\stype="(\d+)"/i.exec(xml)
|
||||||
|
const type = typeMatch?.[1]
|
||||||
|
const title = decodeXmlEntities(xmlValue(xml, 'title') || '')
|
||||||
|
const desc = decodeXmlEntities(xmlValue(xml, 'desc') || xmlValue(xml, 'datadesc') || '')
|
||||||
|
const datatitle = decodeXmlEntities(xmlValue(xml, 'datatitle') || '')
|
||||||
|
const link = decodeXmlEntities(xmlValue(xml, 'link') || xmlValue(xml, 'datacdnurl') || '')
|
||||||
|
const poiname = decodeXmlEntities(xmlValue(xml, 'poiname') || '')
|
||||||
|
const label = decodeXmlEntities(xmlValue(xml, 'label') || '')
|
||||||
|
const lat = Number(xmlValue(xml, 'lat'))
|
||||||
|
const lng = Number(xmlValue(xml, 'lng'))
|
||||||
|
const dataid = xmlValue(xml, 'dataid')
|
||||||
|
const datatype = xmlValue(xml, 'datatype')
|
||||||
|
const duration = Number(xmlValue(xml, 'duration') || xmlValue(xml, 'voicelength'))
|
||||||
|
const appname =
|
||||||
|
decodeXmlEntities(
|
||||||
|
xmlValue(xml, 'appbrandnick') ||
|
||||||
|
xmlValue(xml, 'appname') ||
|
||||||
|
xmlValue(xml, 'sourcedisplayname') ||
|
||||||
|
''
|
||||||
|
) || undefined
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
dataType: datatype,
|
||||||
|
title: title || datatitle || undefined,
|
||||||
|
description: desc || undefined,
|
||||||
|
url: link || undefined,
|
||||||
|
appName: appname,
|
||||||
|
poiname: poiname || undefined,
|
||||||
|
label: label || undefined,
|
||||||
|
lat: Number.isFinite(lat) ? lat : undefined,
|
||||||
|
lng: Number.isFinite(lng) ? lng : undefined,
|
||||||
|
md5: dataid,
|
||||||
|
duration: Number.isFinite(duration) && duration > 0 ? duration : undefined,
|
||||||
|
raw: xml
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `fav_db_item` 行 → 可读卡片(导出/搜索共用)。 */
|
||||||
|
export function favoriteRowToContent(row: {
|
||||||
|
local_id?: number | string
|
||||||
|
server_id?: number | string
|
||||||
|
type?: number | string
|
||||||
|
content?: string | null
|
||||||
|
update_time?: number | string
|
||||||
|
fromusr?: string | null
|
||||||
|
realchatname?: string | null
|
||||||
|
}): ParsedContent {
|
||||||
|
const record = parseFavItemXml(row.content)
|
||||||
|
if (!record.type && row.type !== undefined) record.type = row.type
|
||||||
|
record.favId = row.local_id ?? row.server_id
|
||||||
|
return favoriteRecordToContent(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收藏类型(`MM_FAV_ITEM_TYPE_*` / `MM_FAV_DATA_TYPE_*`)只读映射。
|
||||||
|
* 名称来自 wechat.dylib 导出名;数字为枚举顺序推断,未知值原样回退。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FAV_TYPE_TEXT: Record<string, string> = {
|
||||||
|
all: '全部',
|
||||||
|
none: '未知收藏',
|
||||||
|
txt: '文字',
|
||||||
|
'1': '文字',
|
||||||
|
img: '图片',
|
||||||
|
'2': '图片',
|
||||||
|
voice: '语音',
|
||||||
|
chatvoice: '语音',
|
||||||
|
'3': '语音',
|
||||||
|
video: '视频',
|
||||||
|
'4': '视频',
|
||||||
|
webpage: '网页',
|
||||||
|
'5': '网页',
|
||||||
|
loc: '位置',
|
||||||
|
location: '位置',
|
||||||
|
'6': '位置',
|
||||||
|
music: '音乐',
|
||||||
|
'7': '音乐',
|
||||||
|
file: '文件',
|
||||||
|
'8': '文件',
|
||||||
|
// 真机 fav_db_item.type(2026-09-24 直方图):
|
||||||
|
record: '聊天记录',
|
||||||
|
embeded_record: '聊天记录',
|
||||||
|
'14': '聊天记录',
|
||||||
|
sight: '小视频/名片',
|
||||||
|
sharecard: '名片',
|
||||||
|
'16': '小视频/名片',
|
||||||
|
note: '笔记/图文',
|
||||||
|
'18': '笔记/图文',
|
||||||
|
weapp: '小程序',
|
||||||
|
liteapp: '小程序',
|
||||||
|
'19': '小程序',
|
||||||
|
finder: '视频号',
|
||||||
|
finder_feed: '视频号',
|
||||||
|
finder_live: '视频号直播',
|
||||||
|
finder_video: '视频号',
|
||||||
|
finder_name_card: '视频号名片',
|
||||||
|
'20': '视频号',
|
||||||
|
finder_shop_window_shared: '商品橱窗',
|
||||||
|
music_mv: '音乐视频',
|
||||||
|
ting: '听一听',
|
||||||
|
ting_list: '听一听',
|
||||||
|
collection: '合集',
|
||||||
|
openimkf_sharecard: '客服名片'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `MM_FAV_*` → 展示文案(未知原样返回)。 */
|
||||||
|
export function describeFavoriteType(raw?: string | number): string | undefined {
|
||||||
|
if (raw === undefined || raw === null || raw === '') return undefined
|
||||||
|
const key = String(raw).trim().toLowerCase()
|
||||||
|
if (!key) return undefined
|
||||||
|
return FAV_TYPE_TEXT[key] || String(raw).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收藏记录(只读展示字段;与 favorite.db 列名对齐时可再扩)。 */
|
||||||
|
export type FavoriteRecord = {
|
||||||
|
favId?: string | number
|
||||||
|
/** `MM_FAV_ITEM_TYPE_*` 或 `MM_FAV_DATA_TYPE_*`。 */
|
||||||
|
type?: string | number
|
||||||
|
dataType?: string | number
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
url?: string
|
||||||
|
appName?: string
|
||||||
|
poiname?: string
|
||||||
|
label?: string
|
||||||
|
lat?: number
|
||||||
|
lng?: number
|
||||||
|
md5?: string
|
||||||
|
datName?: string
|
||||||
|
duration?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
raw?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把收藏记录收成现有 `ParsedContent` 形态,复用聊天卡片/导出。
|
||||||
|
* 不做支付、不生成可点开资金动作。
|
||||||
|
*/
|
||||||
|
export function favoriteRecordToContent(record: FavoriteRecord): ParsedContent {
|
||||||
|
const rawType = record.dataType ?? record.type
|
||||||
|
const key = String(rawType ?? '').trim().toLowerCase()
|
||||||
|
const label = describeFavoriteType(rawType) || '收藏'
|
||||||
|
const title = record.title || label
|
||||||
|
const description = record.description
|
||||||
|
|
||||||
|
if (key === 'txt' || key === 'text' || key === '1') {
|
||||||
|
return { type: 'text', content: description || record.raw || title }
|
||||||
|
}
|
||||||
|
if (key === 'img' || key === 'image' || key === '2') {
|
||||||
|
return {
|
||||||
|
type: 'image',
|
||||||
|
md5: record.md5,
|
||||||
|
datName: record.datName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key === 'voice' || key === 'chatvoice' || key === '3') {
|
||||||
|
return { type: 'voice', duration: record.duration }
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
key === 'video' ||
|
||||||
|
key === 'sight' ||
|
||||||
|
key === 'tv' ||
|
||||||
|
key === 'music_mv' ||
|
||||||
|
key === '4' ||
|
||||||
|
key === '16'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
type: 'video',
|
||||||
|
md5: record.md5,
|
||||||
|
duration: record.duration,
|
||||||
|
width: record.width,
|
||||||
|
height: record.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key === 'loc' || key === 'location' || key === '6') {
|
||||||
|
return {
|
||||||
|
type: 'location',
|
||||||
|
poiname: record.poiname || title,
|
||||||
|
label: record.label || description,
|
||||||
|
lat: record.lat ?? 0,
|
||||||
|
lng: record.lng ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key === 'weapp' || key === 'liteapp' || key === '19') {
|
||||||
|
return {
|
||||||
|
type: 'miniProgram',
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
appName: record.appName || '小程序'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key === 'record' || key === 'embeded_record' || key === '14') {
|
||||||
|
return {
|
||||||
|
type: 'forwardBundle',
|
||||||
|
title: title || '聊天记录',
|
||||||
|
description,
|
||||||
|
items: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key === 'none' || key === '' || key === 'all') {
|
||||||
|
return {
|
||||||
|
type: 'system',
|
||||||
|
content: `收藏 · ${label}`,
|
||||||
|
raw: record.raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网页 / 音乐 / 文件 / 商品 / 视频号 / 笔记 / 名片 / 合集 → 只读 share
|
||||||
|
return {
|
||||||
|
type: 'share',
|
||||||
|
title,
|
||||||
|
des: description,
|
||||||
|
url: record.url || '',
|
||||||
|
appname: record.appName || label,
|
||||||
|
typeVal: key || 'favorite'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* 转账 / 红包状态文案(只读展示)。
|
||||||
|
* 文案对齐微信 UI 簇(待收款/已收款/…、待领取/已领取/…)。
|
||||||
|
* 数字枚举为推断对齐;未知值原样回退。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TRANSFER_STATUS_TEXT: Record<string, string> = {
|
||||||
|
'0': '待收款',
|
||||||
|
'1': '已收款,待入账',
|
||||||
|
'2': '已收款',
|
||||||
|
'3': '已退还',
|
||||||
|
'4': '过期未收款,已退还',
|
||||||
|
'5': '你已退还',
|
||||||
|
'6': '已过期',
|
||||||
|
'7': '此笔转账已被撤回',
|
||||||
|
paid: '已收款',
|
||||||
|
received: '已收款',
|
||||||
|
pending: '待收款',
|
||||||
|
unreceived: '待收款',
|
||||||
|
refunded: '已退还',
|
||||||
|
expired: '过期未收款,已退还',
|
||||||
|
withdrawn: '此笔转账已被撤回',
|
||||||
|
revoked: '此笔转账已被撤回',
|
||||||
|
confirmed: '朋友已确认收款',
|
||||||
|
accepted: '已被接收',
|
||||||
|
accepting: '已被接收'
|
||||||
|
}
|
||||||
|
|
||||||
|
const RED_PACKET_STATUS_TEXT: Record<string, string> = {
|
||||||
|
'0': '待领取',
|
||||||
|
'1': '已领取',
|
||||||
|
'2': '已被领完',
|
||||||
|
'3': '已过期',
|
||||||
|
'4': '已被领取',
|
||||||
|
received: '已领取',
|
||||||
|
claimed: '已领取',
|
||||||
|
expired: '已过期',
|
||||||
|
exhausted: '已被领完',
|
||||||
|
pending: '待领取',
|
||||||
|
unclaimed: '待领取'
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSFER_STATUS_KEYWORDS: Array<[RegExp, string]> = [
|
||||||
|
[/撤回|withdraw|revoke/i, '此笔转账已被撤回'],
|
||||||
|
[/过期未收款|expired/i, '过期未收款,已退还'],
|
||||||
|
[/已退还|退还|refund/i, '已退还'],
|
||||||
|
[/待入账|pending.?credit/i, '已收款,待入账'],
|
||||||
|
[/已收款|已收|paid|received/i, '已收款'],
|
||||||
|
[/已被接收|accepted|accepting/i, '已被接收'],
|
||||||
|
[/确认收款|confirmed/i, '朋友已确认收款'],
|
||||||
|
[/待收款|请收款|pending|unreceived/i, '待收款'],
|
||||||
|
[/已过期|expired/i, '已过期']
|
||||||
|
]
|
||||||
|
|
||||||
|
const RED_PACKET_STATUS_KEYWORDS: Array<[RegExp, string]> = [
|
||||||
|
[/领完|exhausted/i, '已被领完'],
|
||||||
|
[/已被领取|已领取|received|claimed/i, '已领取'],
|
||||||
|
[/过期|expired/i, '已过期'],
|
||||||
|
[/待领取|未领取|pending|unclaimed/i, '待领取']
|
||||||
|
]
|
||||||
|
|
||||||
|
function describeByMap(
|
||||||
|
raw: string | number | undefined,
|
||||||
|
table: Record<string, string>,
|
||||||
|
keywords: Array<[RegExp, string]>
|
||||||
|
): string | undefined {
|
||||||
|
if (raw === undefined || raw === null || raw === '') return undefined
|
||||||
|
const text = String(raw).trim()
|
||||||
|
if (!text) return undefined
|
||||||
|
const direct = table[text.toLowerCase()] || table[text]
|
||||||
|
if (direct) return direct
|
||||||
|
for (const [re, label] of keywords) {
|
||||||
|
if (re.test(text)) return label
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `transfer_status` → 展示文案(未知原样返回)。 */
|
||||||
|
export function describeTransferStatus(raw?: string | number): string | undefined {
|
||||||
|
return describeByMap(raw, TRANSFER_STATUS_TEXT, TRANSFER_STATUS_KEYWORDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 红包 `hb_status` / `receive_status` → 展示文案。 */
|
||||||
|
export function describeRedPacketStatus(
|
||||||
|
hbStatus?: string | number,
|
||||||
|
receiveStatus?: string | number
|
||||||
|
): string | undefined {
|
||||||
|
return (
|
||||||
|
describeByMap(hbStatus, RED_PACKET_STATUS_TEXT, RED_PACKET_STATUS_KEYWORDS) ||
|
||||||
|
describeByMap(receiveStatus, RED_PACKET_STATUS_TEXT, RED_PACKET_STATUS_KEYWORDS)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `paysubtype` 仅作辅助标签(真机见过 1/3/4;语义未在 dylib 钉死)。 */
|
||||||
|
export function describePaySubtype(raw?: string | number): string | undefined {
|
||||||
|
if (raw === undefined || raw === null || raw === '') return undefined
|
||||||
|
const text = String(raw).trim()
|
||||||
|
const table: Record<string, string> = {
|
||||||
|
'1': '转账',
|
||||||
|
'3': '收款',
|
||||||
|
'4': '转账'
|
||||||
|
}
|
||||||
|
return table[text] || text
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRANSFER_STATUS_LABELS = TRANSFER_STATUS_TEXT
|
||||||
|
export const RED_PACKET_STATUS_LABELS = RED_PACKET_STATUS_TEXT
|
||||||
@@ -97,6 +97,48 @@ type ShareContent = {
|
|||||||
appname?: string
|
appname?: string
|
||||||
typeVal?: string
|
typeVal?: string
|
||||||
articles?: ShareArticle[]
|
articles?: ShareArticle[]
|
||||||
|
/** `wcpayinfo` when typeVal === '2000' (微信转账). Display/export only. */
|
||||||
|
transfer?: TransferPaymentInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 转账 `wcpayinfo` 字段(只读展示;不含支付操作)。 */
|
||||||
|
export type TransferPaymentInfo = {
|
||||||
|
paySubtype?: string
|
||||||
|
amountText?: string
|
||||||
|
transcationId?: string
|
||||||
|
transferId?: string
|
||||||
|
invalidTime?: string
|
||||||
|
beginTransferTime?: string
|
||||||
|
effectiveDate?: string
|
||||||
|
payMemo?: string
|
||||||
|
receiverUsername?: string
|
||||||
|
payerUsername?: string
|
||||||
|
transferStatus?: string
|
||||||
|
/** `transfer_status` 展示文案(只读)。 */
|
||||||
|
transferStatusText?: string
|
||||||
|
/** `trans_id` / `fee_type` / `transfer_attach` / `refund_bank_type`(若消息带;只读透传)。 */
|
||||||
|
transId?: string
|
||||||
|
feeType?: string
|
||||||
|
transferAttach?: string
|
||||||
|
refundBankType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 红包 `wcpayinfo` 字段(只读展示)。 */
|
||||||
|
export type RedPacketPaymentInfo = {
|
||||||
|
templateId?: string
|
||||||
|
receiveTitle?: string
|
||||||
|
sendTitle?: string
|
||||||
|
sceneText?: string
|
||||||
|
senderDes?: string
|
||||||
|
receiverDes?: string
|
||||||
|
iconUrl?: string
|
||||||
|
nativeUrl?: string
|
||||||
|
sendId?: string
|
||||||
|
hbType?: string
|
||||||
|
hbStatus?: string
|
||||||
|
receiveStatus?: string
|
||||||
|
/** 展示文案(只读)。 */
|
||||||
|
redPacketStatusText?: string
|
||||||
}
|
}
|
||||||
export type ForwardedMessageItem = {
|
export type ForwardedMessageItem = {
|
||||||
messageType: number
|
messageType: number
|
||||||
@@ -126,6 +168,8 @@ type RedPacketContent = {
|
|||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
url?: string
|
url?: string
|
||||||
|
/** `wcpayinfo` 展示字段(只读)。 */
|
||||||
|
pay?: RedPacketPaymentInfo
|
||||||
}
|
}
|
||||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||||
type ImageContent = {
|
type ImageContent = {
|
||||||
|
|||||||
@@ -457,6 +457,74 @@ describe('export media', () => {
|
|||||||
expect(html).toContain("if (event.key === 'Escape')")
|
expect(html).toContain("if (event.key === 'Escape')")
|
||||||
expect(html).toContain('closeLightbox()')
|
expect(html).toContain('closeLightbox()')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('always surfaces transfer and red-packet amount, memo and status wording', () => {
|
||||||
|
const html = renderExportPage('支付可读导出')
|
||||||
|
const dom = new JSDOM(html, { runScripts: 'outside-only' })
|
||||||
|
const messages: Message[] = [
|
||||||
|
{
|
||||||
|
...messageForArchive('transfer-paid', 'fixture', '支付可读导出', '收到转账2900.00元。', 1),
|
||||||
|
type: '转账',
|
||||||
|
contentData: {
|
||||||
|
type: 'share',
|
||||||
|
typeVal: '2000',
|
||||||
|
title: '微信转账',
|
||||||
|
des: '收到转账2900.00元。',
|
||||||
|
url: '',
|
||||||
|
transfer: {
|
||||||
|
amountText: '¥2900.00',
|
||||||
|
payMemo: '房租',
|
||||||
|
transferStatus: '2',
|
||||||
|
transferStatusText: '已收款',
|
||||||
|
transferId: '1000050001202609130232821248942',
|
||||||
|
paySubtype: '3'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...messageForArchive('hb-claimed', 'fixture', '支付可读导出', '我给你发了一个红包,赶紧去拆!', 2),
|
||||||
|
type: '微信红包',
|
||||||
|
contentData: {
|
||||||
|
type: 'redPacket',
|
||||||
|
title: '中秋快乐',
|
||||||
|
description: '我给你发了一个红包,赶紧去拆!',
|
||||||
|
pay: {
|
||||||
|
sendTitle: '中秋快乐',
|
||||||
|
sceneText: '微信红包',
|
||||||
|
hbStatus: '1',
|
||||||
|
redPacketStatusText: '已领取',
|
||||||
|
sendId: '1000039801202609247176036834007',
|
||||||
|
hbType: '1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
Object.assign(dom.window, {
|
||||||
|
__WECHAT_EXPORT__: {
|
||||||
|
version: 1,
|
||||||
|
sourceId: 'fixture',
|
||||||
|
name: '支付可读导出',
|
||||||
|
exportedAt: '2026-09-24T12:00:00.000Z',
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
dom.window.eval(inlineScriptOf(html))
|
||||||
|
|
||||||
|
const cards = [...dom.window.document.querySelectorAll('.payment-content')]
|
||||||
|
expect(cards).toHaveLength(2)
|
||||||
|
const transfer = cards.find((node) => node.classList.contains('transfer'))!
|
||||||
|
const redPacket = cards.find((node) => node.classList.contains('red-packet'))!
|
||||||
|
// 即使消息带 des,金额/备注/状态也不能被盖住。
|
||||||
|
expect(transfer.textContent).toContain('¥2900.00')
|
||||||
|
expect(transfer.textContent).toContain('备注:房租')
|
||||||
|
expect(transfer.textContent).toContain('已收款')
|
||||||
|
expect(transfer.textContent).toContain('收到转账2900.00元。')
|
||||||
|
expect(transfer.textContent).toContain('单号 1000050001202609130232821248942')
|
||||||
|
expect(redPacket.textContent).toContain('已领取')
|
||||||
|
expect(redPacket.textContent).toContain('我给你发了一个红包,赶紧去拆!')
|
||||||
|
dom.window.close()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function messageForArchive(
|
function messageForArchive(
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
describeFavoriteType,
|
||||||
|
favoriteRecordToContent,
|
||||||
|
favoriteRowToContent,
|
||||||
|
parseFavItemXml
|
||||||
|
} from '../../src/shared/favorites'
|
||||||
|
|
||||||
|
describe('favorites type map', () => {
|
||||||
|
it('labels known MM_FAV names and numeric fallbacks', () => {
|
||||||
|
expect(describeFavoriteType('TXT')).toBe('文字')
|
||||||
|
expect(describeFavoriteType('IMG')).toBe('图片')
|
||||||
|
expect(describeFavoriteType('FINDER_FEED')).toBe('视频号')
|
||||||
|
expect(describeFavoriteType(2)).toBe('图片')
|
||||||
|
// 真机 fav_db_item.type
|
||||||
|
expect(describeFavoriteType(14)).toBe('聊天记录')
|
||||||
|
expect(describeFavoriteType(18)).toBe('笔记/图文')
|
||||||
|
expect(describeFavoriteType(19)).toBe('小程序')
|
||||||
|
expect(describeFavoriteType(20)).toBe('视频号')
|
||||||
|
expect(describeFavoriteType('99-custom')).toBe('99-custom')
|
||||||
|
expect(describeFavoriteType(undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps favorite records onto existing message cards', () => {
|
||||||
|
expect(
|
||||||
|
favoriteRecordToContent({ type: 'TXT', description: '收藏的文字' })
|
||||||
|
).toEqual({ type: 'text', content: '收藏的文字' })
|
||||||
|
expect(favoriteRecordToContent({ type: 'IMG', md5: 'ab'.repeat(16) })).toMatchObject({
|
||||||
|
type: 'image',
|
||||||
|
md5: 'ab'.repeat(16)
|
||||||
|
})
|
||||||
|
expect(favoriteRecordToContent({ type: 'VOICE', duration: 3 })).toEqual({
|
||||||
|
type: 'voice',
|
||||||
|
duration: 3
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
favoriteRecordToContent({
|
||||||
|
type: 'WEBPAGE',
|
||||||
|
title: '文章',
|
||||||
|
description: '摘要',
|
||||||
|
url: 'https://example.com',
|
||||||
|
appName: '公众号'
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'share',
|
||||||
|
title: '文章',
|
||||||
|
des: '摘要',
|
||||||
|
url: 'https://example.com',
|
||||||
|
appname: '公众号',
|
||||||
|
typeVal: 'webpage'
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
favoriteRecordToContent({ type: 'LOC', poiname: '公园', lat: 1.5, lng: 2.5 })
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'location',
|
||||||
|
poiname: '公园',
|
||||||
|
lat: 1.5,
|
||||||
|
lng: 2.5
|
||||||
|
})
|
||||||
|
expect(favoriteRecordToContent({ type: 'NOTE', title: '笔记' })).toMatchObject({
|
||||||
|
type: 'share',
|
||||||
|
typeVal: 'note'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses favitem XML from favorite.db content', () => {
|
||||||
|
const xml = [
|
||||||
|
'<favitem type="1"><ctrlflag>0</ctrlflag><version>0</version>',
|
||||||
|
'<desc><![CDATA[收藏文字]]></desc>',
|
||||||
|
'<source sourcetype="22"><fromusr>wxid_a</fromusr></source>',
|
||||||
|
'</favitem>'
|
||||||
|
].join('')
|
||||||
|
expect(parseFavItemXml(xml)).toMatchObject({
|
||||||
|
type: '1',
|
||||||
|
description: '收藏文字'
|
||||||
|
})
|
||||||
|
const web = [
|
||||||
|
'<favitem type="5"><source><link><![CDATA[https://example.com/a]]></link></source>',
|
||||||
|
'<datalist count="1"><dataitem datatype="5"><datatitle><![CDATA[标题]]></datatitle>',
|
||||||
|
'<datadesc><![CDATA[摘要]]></datadesc></dataitem></datalist></favitem>'
|
||||||
|
].join('')
|
||||||
|
expect(parseFavItemXml(web)).toMatchObject({
|
||||||
|
type: '5',
|
||||||
|
title: '标题',
|
||||||
|
description: '摘要',
|
||||||
|
url: 'https://example.com/a'
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
favoriteRowToContent({
|
||||||
|
local_id: 9,
|
||||||
|
type: 5,
|
||||||
|
content: web
|
||||||
|
})
|
||||||
|
).toMatchObject({ type: 'share', title: '标题', typeVal: '5' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -206,4 +206,284 @@ describe('message parser', () => {
|
|||||||
messageType: 999
|
messageType: 999
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('unmasks packed local_type low 32 bits', () => {
|
||||||
|
const packed = 1 + 5 * 0x100000000
|
||||||
|
expect(parseMessageContent('plain text body', packed)).toEqual({
|
||||||
|
type: 'text',
|
||||||
|
content: 'plain text body'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses patMsg into system text', () => {
|
||||||
|
const xml = [
|
||||||
|
'<msg><appmsg>',
|
||||||
|
'<patMsg>',
|
||||||
|
'<template><![CDATA[$from$拍了拍$to$$pat$]]></template>',
|
||||||
|
'<fromusername><![CDATA[alice]]></fromusername>',
|
||||||
|
'<tousername><![CDATA[bob]]></tousername>',
|
||||||
|
'<pat><![CDATA[的头像]]></pat>',
|
||||||
|
'</patMsg>',
|
||||||
|
'</appmsg></msg>'
|
||||||
|
].join('')
|
||||||
|
const parsed = parseMessageContent(xml, 49)
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
type: 'system',
|
||||||
|
content: 'alice拍了拍bob的头像'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses findernamecard as a read-only share card', () => {
|
||||||
|
const xml = [
|
||||||
|
'<msg><appmsg>',
|
||||||
|
'<type>51</type>',
|
||||||
|
'<title><![CDATA[视频号]]></title>',
|
||||||
|
'<findernamecard>',
|
||||||
|
'<username><![CDATA[finder_ab12]]></username>',
|
||||||
|
'<nickname><![CDATA[示例视频号]]></nickname>',
|
||||||
|
'</findernamecard>',
|
||||||
|
'</appmsg></msg>'
|
||||||
|
].join('')
|
||||||
|
const parsed = parseMessageContent(xml, 49)
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
type: 'share',
|
||||||
|
title: '示例视频号',
|
||||||
|
appname: '视频号',
|
||||||
|
typeVal: '51'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to product item share without a title', () => {
|
||||||
|
const xml = [
|
||||||
|
'<msg><appmsg>',
|
||||||
|
'<type>2000</type>',
|
||||||
|
'<productitem>',
|
||||||
|
'<productName><![CDATA[测试商品]]></productName>',
|
||||||
|
'<sellingPrice><![CDATA[¥9.9]]></sellingPrice>',
|
||||||
|
'</productitem>',
|
||||||
|
'</appmsg></msg>'
|
||||||
|
].join('')
|
||||||
|
const parsed = parseMessageContent(xml, 49)
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
type: 'share',
|
||||||
|
title: '测试商品',
|
||||||
|
des: '¥9.9',
|
||||||
|
typeVal: '2000'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders friend verify local_type 37 as system text', () => {
|
||||||
|
const xml = '<msg><nickname><![CDATA[新朋友]]></nickname><content><![CDATA[我是群里的]]></content></msg>'
|
||||||
|
const parsed = parseMessageContent(xml, 37)
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
type: 'system',
|
||||||
|
content: '新朋友 · 我是群里的'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses music share cards from song/album fields', () => {
|
||||||
|
const xml = [
|
||||||
|
'<msg><appmsg>',
|
||||||
|
'<type>3</type>',
|
||||||
|
'<title><![CDATA[晴天]]></title>',
|
||||||
|
'<des><![CDATA[周杰伦]]></des>',
|
||||||
|
'<url><![CDATA[https://music.example/song]]></url>',
|
||||||
|
'<songalbumurl><![CDATA[https://music.example/cover]]></songalbumurl>',
|
||||||
|
'<songlyric><![CDATA[故事的小黄花]]></songlyric>',
|
||||||
|
'</appmsg></msg>'
|
||||||
|
].join('')
|
||||||
|
const parsed = parseMessageContent(xml, 49)
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
type: 'share',
|
||||||
|
title: '晴天',
|
||||||
|
des: '周杰伦',
|
||||||
|
url: 'https://music.example/song',
|
||||||
|
appname: '音乐',
|
||||||
|
typeVal: '3'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses subscribe template cards', () => {
|
||||||
|
const clean = [
|
||||||
|
'<msg><appmsg>',
|
||||||
|
'<template_header><![CDATA[服务通知]]></template_header>',
|
||||||
|
'<template_detail><![CDATA[您的订单已发货]]></template_detail>',
|
||||||
|
'<url><![CDATA[https://mp.example/notice]]></url>',
|
||||||
|
'<updatablemsg></updatablemsg>',
|
||||||
|
'</appmsg></msg>'
|
||||||
|
].join('')
|
||||||
|
const parsed = parseMessageContent(clean, 49)
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
type: 'share',
|
||||||
|
title: '服务通知',
|
||||||
|
des: '您的订单已发货',
|
||||||
|
appname: '订阅消息',
|
||||||
|
typeVal: 'subscribe'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses kefu template cards and falls back to system text', () => {
|
||||||
|
const card = [
|
||||||
|
'<msg><appmsg>',
|
||||||
|
'<template_header><![CDATA[店铺客服]]></template_header>',
|
||||||
|
'<template_detail><![CDATA[点击查看订单]]></template_detail>',
|
||||||
|
'<opencustomerservicemsg></opencustomerservicemsg>',
|
||||||
|
'</appmsg></msg>'
|
||||||
|
].join('')
|
||||||
|
expect(parseMessageContent(card, 49)).toMatchObject({
|
||||||
|
type: 'share',
|
||||||
|
title: '店铺客服',
|
||||||
|
des: '点击查看订单',
|
||||||
|
typeVal: 'kefu'
|
||||||
|
})
|
||||||
|
const menu = '<msg><appmsg><kefumenu></kefumenu></appmsg></msg>'
|
||||||
|
expect(parseMessageContent(menu, 49)).toMatchObject({
|
||||||
|
type: 'system',
|
||||||
|
content: '客服消息'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses stream video cards', () => {
|
||||||
|
const xml = [
|
||||||
|
'<msg><appmsg>',
|
||||||
|
'<type>51</type>',
|
||||||
|
'<streamvideotitle><![CDATA[户外长视频]]></streamvideotitle>',
|
||||||
|
'<streamvideoword><![CDATA[点击播放]]></streamvideoword>',
|
||||||
|
'<streamvideoweburl><![CDATA[https://finder.example/v/1]]></streamvideoweburl>',
|
||||||
|
'<finderMegaVideo></finderMegaVideo>',
|
||||||
|
'</appmsg></msg>'
|
||||||
|
].join('')
|
||||||
|
expect(parseMessageContent(xml, 49)).toMatchObject({
|
||||||
|
type: 'share',
|
||||||
|
title: '户外长视频',
|
||||||
|
des: '点击播放',
|
||||||
|
url: 'https://finder.example/v/1',
|
||||||
|
appname: '视频号',
|
||||||
|
typeVal: '51'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses gift cards without enabling accept actions', () => {
|
||||||
|
const xml = [
|
||||||
|
'<msg><appmsg>',
|
||||||
|
'<title><![CDATA[生日礼物卡]]></title>',
|
||||||
|
'<des><![CDATA[送你一张礼物卡]]></des>',
|
||||||
|
'<giftcarditem><brandname><![CDATA[示例品牌]]></brandname></giftcarditem>',
|
||||||
|
'</appmsg></msg>'
|
||||||
|
].join('')
|
||||||
|
expect(parseMessageContent(xml, 49)).toMatchObject({
|
||||||
|
type: 'share',
|
||||||
|
title: '生日礼物卡',
|
||||||
|
des: '送你一张礼物卡',
|
||||||
|
appname: '示例品牌',
|
||||||
|
typeVal: 'giftcard'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps legacy local_type 2 and 8 without dropping content', () => {
|
||||||
|
expect(parseMessageContent('plain status line', 2)).toEqual({
|
||||||
|
type: 'text',
|
||||||
|
content: 'plain status line'
|
||||||
|
})
|
||||||
|
const gifLike =
|
||||||
|
'<msg><emoji md5="abcdefabcdefabcdefabcdefabcdefab" cdnurl="https://e.example/a.gif" /></msg>'
|
||||||
|
expect(parseMessageContent(gifLike, 8)).toMatchObject({
|
||||||
|
type: 'sticker',
|
||||||
|
md5: 'abcdefabcdefabcdefabcdefabcdefab'
|
||||||
|
})
|
||||||
|
expect(parseMessageContent('opaque type8', 8)).toMatchObject({
|
||||||
|
type: 'unknown',
|
||||||
|
messageType: 8
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses transfer wcpayinfo fields (type 2000)', () => {
|
||||||
|
// 2026-09 真机转账采样字段名(含微信原文 transcationid 拼写)
|
||||||
|
const xml = [
|
||||||
|
'<msg><appmsg appid="" sdkver="">',
|
||||||
|
'<title><![CDATA[微信转账]]></title>',
|
||||||
|
'<des><![CDATA[收到转账2900.00元。]]></des>',
|
||||||
|
'<type>2000</type>',
|
||||||
|
'<url><![CDATA[https://support.weixin.qq.com/upgrade]]></url>',
|
||||||
|
'<wcpayinfo>',
|
||||||
|
'<paysubtype>3</paysubtype>',
|
||||||
|
'<feedesc><![CDATA[¥2900.00]]></feedesc>',
|
||||||
|
'<transcationid><![CDATA[53010003370089202609134087894875]]></transcationid>',
|
||||||
|
'<transferid><![CDATA[1000050001202609130232821248942]]></transferid>',
|
||||||
|
'<invalidtime><![CDATA[1789348128]]></invalidtime>',
|
||||||
|
'<begintransfertime><![CDATA[1789261728]]></begintransfertime>',
|
||||||
|
'<effectivedate><![CDATA[1]]></effectivedate>',
|
||||||
|
'<pay_memo><![CDATA[房租]]></pay_memo>',
|
||||||
|
'<receiver_username><![CDATA[wxid_receiver]]></receiver_username>',
|
||||||
|
'<payer_username><![CDATA[]]></payer_username>',
|
||||||
|
'<transfer_status>2</transfer_status>',
|
||||||
|
'<trans_id><![CDATA[trans-fixture-1]]></trans_id>',
|
||||||
|
'<fee_type><![CDATA[CNY]]></fee_type>',
|
||||||
|
'<refund_bank_type><![CDATA[1]]></refund_bank_type>',
|
||||||
|
'</wcpayinfo></appmsg></msg>'
|
||||||
|
].join('')
|
||||||
|
const parsed = parseMessageContent(xml, 49)
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
type: 'share',
|
||||||
|
typeVal: '2000',
|
||||||
|
title: '微信转账',
|
||||||
|
transfer: {
|
||||||
|
paySubtype: '3',
|
||||||
|
amountText: '¥2900.00',
|
||||||
|
transcationId: '53010003370089202609134087894875',
|
||||||
|
transferId: '1000050001202609130232821248942',
|
||||||
|
invalidTime: '1789348128',
|
||||||
|
beginTransferTime: '1789261728',
|
||||||
|
effectiveDate: '1',
|
||||||
|
payMemo: '房租',
|
||||||
|
receiverUsername: 'wxid_receiver',
|
||||||
|
transferStatus: '2',
|
||||||
|
transferStatusText: '已收款',
|
||||||
|
transId: 'trans-fixture-1',
|
||||||
|
feeType: 'CNY',
|
||||||
|
refundBankType: '1'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses red packet wcpayinfo fields (type 2001 / mmpayhb)', () => {
|
||||||
|
const xml = [
|
||||||
|
'<msg><appmsg appid="" sdkver="">',
|
||||||
|
'<title><![CDATA[中秋快乐]]></title>',
|
||||||
|
'<des><![CDATA[我给你发了一个红包,赶紧去拆!]]></des>',
|
||||||
|
'<type>2001</type>',
|
||||||
|
'<wcpayinfo>',
|
||||||
|
'<templateid><![CDATA[7a2a165d31da7fce6dd77e05c300028a]]></templateid>',
|
||||||
|
'<url><![CDATA[https://wxapp.tenpay.com/mmpayhb/wxhb_personalreceive?msgtype=1&sendid=1000039801202609247176036834007&sign=abc]]></url>',
|
||||||
|
'<iconurl><![CDATA[https://wx.gtimg.com/hongbao/1800/hb.png]]></iconurl>',
|
||||||
|
'<receivertitle><![CDATA[恭喜发财,大吉大利]]></receivertitle>',
|
||||||
|
'<sendertitle><![CDATA[中秋快乐,望重置]]></sendertitle>',
|
||||||
|
'<scenetext><![CDATA[微信红包]]></scenetext>',
|
||||||
|
'<senderdes><![CDATA[查看红包]]></senderdes>',
|
||||||
|
'<receiverdes><![CDATA[领取红包]]></receiverdes>',
|
||||||
|
'<nativeurl><![CDATA[wxpay://c2cbizmessagehandler/hongbao/receivehongbao?sendid=1]]></nativeurl>',
|
||||||
|
'<hb_type>1</hb_type>',
|
||||||
|
'<hb_status>1</hb_status>',
|
||||||
|
'<receive_status>2</receive_status>',
|
||||||
|
'</wcpayinfo></appmsg></msg>'
|
||||||
|
].join('')
|
||||||
|
const parsed = parseMessageContent(xml, 49)
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
type: 'redPacket',
|
||||||
|
title: '中秋快乐,望重置',
|
||||||
|
pay: {
|
||||||
|
templateId: '7a2a165d31da7fce6dd77e05c300028a',
|
||||||
|
receiveTitle: '恭喜发财,大吉大利',
|
||||||
|
sendTitle: '中秋快乐,望重置',
|
||||||
|
sceneText: '微信红包',
|
||||||
|
senderDes: '查看红包',
|
||||||
|
receiverDes: '领取红包',
|
||||||
|
sendId: '1000039801202609247176036834007',
|
||||||
|
hbType: '1',
|
||||||
|
hbStatus: '1',
|
||||||
|
receiveStatus: '2',
|
||||||
|
redPacketStatusText: '已领取'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
describePaySubtype,
|
||||||
|
describeRedPacketStatus,
|
||||||
|
describeTransferStatus
|
||||||
|
} from '../../src/shared/payment-status'
|
||||||
|
|
||||||
|
describe('payment status labels', () => {
|
||||||
|
it('maps transfer_status numbers and keywords to UI wording', () => {
|
||||||
|
expect(describeTransferStatus(2)).toBe('已收款')
|
||||||
|
expect(describeTransferStatus('3')).toBe('已退还')
|
||||||
|
expect(describeTransferStatus('paid')).toBe('已收款')
|
||||||
|
expect(describeTransferStatus('收款方过期未收款,已退还')).toBe('过期未收款,已退还')
|
||||||
|
expect(describeTransferStatus(undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps red packet hb_status / receive_status', () => {
|
||||||
|
expect(describeRedPacketStatus('0', undefined)).toBe('待领取')
|
||||||
|
expect(describeRedPacketStatus('1', undefined)).toBe('已领取')
|
||||||
|
expect(describeRedPacketStatus(undefined, '2')).toBe('已被领完')
|
||||||
|
expect(describeRedPacketStatus('已被领取', undefined)).toBe('已领取')
|
||||||
|
expect(describeRedPacketStatus(undefined, undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes through unknown status values', () => {
|
||||||
|
expect(describeTransferStatus('99-custom')).toBe('99-custom')
|
||||||
|
expect(describeRedPacketStatus('99', '98')).toBe('99')
|
||||||
|
expect(describePaySubtype('3')).toBe('收款')
|
||||||
|
expect(describePaySubtype('9')).toBe('9')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { normalizeRoomInfoRow } from '../../src/main/wcdb4-client'
|
||||||
|
|
||||||
|
describe('normalizeRoomInfoRow', () => {
|
||||||
|
it('maps versioned chatroom column aliases', () => {
|
||||||
|
expect(
|
||||||
|
normalizeRoomInfoRow('123@chatroom', {
|
||||||
|
chatroomname: '123@chatroom',
|
||||||
|
roomowner: 'wxid_owner',
|
||||||
|
textannouncement: '群公告',
|
||||||
|
announcement_editor_: 'wxid_editor',
|
||||||
|
max_member_count: 500,
|
||||||
|
chatroomnick: '测试群',
|
||||||
|
openim_acct_type: '2'
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
roomId: '123@chatroom',
|
||||||
|
owner: 'wxid_owner',
|
||||||
|
announcement: '群公告',
|
||||||
|
announcementEditor: 'wxid_editor',
|
||||||
|
maxMemberCount: 500,
|
||||||
|
chatName: '测试群',
|
||||||
|
openImAccountType: '2',
|
||||||
|
isOpenIm: true
|
||||||
|
})
|
||||||
|
expect(normalizeRoomInfoRow('9@chatroom', { room_owner: 'wxid_b' })).toMatchObject({
|
||||||
|
owner: 'wxid_b',
|
||||||
|
isOpenIm: undefined
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,6 +4,7 @@ import { join } from 'path'
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-test-user-data' } }))
|
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-test-user-data' } }))
|
||||||
import {
|
import {
|
||||||
|
buildScheduledReportActionRequest,
|
||||||
calculateNextRunAt,
|
calculateNextRunAt,
|
||||||
ScheduledReportService,
|
ScheduledReportService,
|
||||||
validateScheduleTime
|
validateScheduleTime
|
||||||
@@ -973,3 +974,410 @@ describe('scheduled report scheduling', () => {
|
|||||||
).toHaveLength(1)
|
).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('scheduled report auto resend', () => {
|
||||||
|
const latestExecution = async (
|
||||||
|
service: ScheduledReportService,
|
||||||
|
taskId: string
|
||||||
|
): Promise<ScheduledReportExecution> => (await service.listExecutions(taskId))[0]
|
||||||
|
|
||||||
|
const waitForAutoResend = async (
|
||||||
|
predicate: () => Promise<boolean>,
|
||||||
|
timeoutMs = 3_000
|
||||||
|
): Promise<void> => {
|
||||||
|
const deadline = Date.now() + timeoutMs
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await predicate()) return
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
}
|
||||||
|
throw new Error('auto resend state did not settle in time')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentAction = {
|
||||||
|
actionId: 'send-success-action',
|
||||||
|
status: 'sent' as const,
|
||||||
|
decision: 'allow' as const,
|
||||||
|
startedAt: '2026-08-27T01:06:00.000Z',
|
||||||
|
finishedAt: '2026-08-27T01:06:01.000Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
const unavailableSendAction = () => async () => ({
|
||||||
|
actionId: 'send-unavailable-action',
|
||||||
|
status: 'failed' as const,
|
||||||
|
decision: 'allow' as const,
|
||||||
|
errorCode: 'SEND_CAPABILITY_UNAVAILABLE' as const,
|
||||||
|
reason: '当前微信发送能力不可用',
|
||||||
|
startedAt: '2026-08-27T01:00:00.000Z',
|
||||||
|
finishedAt: '2026-08-27T01:00:01.000Z'
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto resends a waiting report after backoff and notifies recovery', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-auto-resend-'))
|
||||||
|
await enableNotifications(storageDir)
|
||||||
|
const clock = { now: Date.parse('2026-08-27T01:00:00.000Z') }
|
||||||
|
const sendNotification = vi.fn(async () => ({
|
||||||
|
success: true as const,
|
||||||
|
status: 'sent' as const
|
||||||
|
}))
|
||||||
|
const sendAction = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
actionId: 'send-1',
|
||||||
|
status: 'failed' as const,
|
||||||
|
decision: 'allow' as const,
|
||||||
|
errorCode: 'SEND_CAPABILITY_UNAVAILABLE' as const,
|
||||||
|
reason: '当前微信发送能力不可用',
|
||||||
|
startedAt: '2026-08-27T01:00:00.000Z',
|
||||||
|
finishedAt: '2026-08-27T01:00:01.000Z'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
actionId: 'send-2',
|
||||||
|
status: 'sent' as const,
|
||||||
|
decision: 'allow' as const,
|
||||||
|
startedAt: '2026-08-27T01:06:00.000Z',
|
||||||
|
finishedAt: '2026-08-27T01:06:01.000Z'
|
||||||
|
})
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
now: () => new Date(clock.now),
|
||||||
|
...makeDependencies({ sendAction, sendNotification })
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '自动重发日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = await runScheduled(service, created.data!.id)
|
||||||
|
expect(first).toMatchObject({
|
||||||
|
status: 'waiting_to_send',
|
||||||
|
retryCount: 0,
|
||||||
|
pngPath: '/tmp/saved.png',
|
||||||
|
notificationStatus: 'not_needed'
|
||||||
|
})
|
||||||
|
|
||||||
|
// retryCount 0 的退避是 5 分钟:推进到到期时刻再 tick。
|
||||||
|
clock.now = Date.parse(first.finishedAt || first.startedAt) + 5 * 60_000 + 1_000
|
||||||
|
await service.tick(new Date(clock.now))
|
||||||
|
await waitForAutoResend(async () => sendAction.mock.calls.length >= 2)
|
||||||
|
|
||||||
|
const recovered = await latestExecution(service, created.data!.id)
|
||||||
|
expect(recovered).toMatchObject({
|
||||||
|
status: 'success',
|
||||||
|
retryCount: 1,
|
||||||
|
sendStatus: 'success',
|
||||||
|
notificationStatus: 'sent'
|
||||||
|
})
|
||||||
|
expect(sendAction).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
filePath: '/tmp/saved.png',
|
||||||
|
executionId: first.id,
|
||||||
|
triggerType: 'scheduled',
|
||||||
|
retryCount: 1,
|
||||||
|
taskId: created.data!.id
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(sendNotification).toHaveBeenCalledTimes(1)
|
||||||
|
expect(await service.listNotifications()).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'recovery',
|
||||||
|
status: 'sent',
|
||||||
|
title: expect.stringContaining('已恢复')
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not auto resend before the backoff elapses', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-resend-early-'))
|
||||||
|
await enableNotifications(storageDir)
|
||||||
|
const clock = { now: Date.parse('2026-08-27T01:00:00.000Z') }
|
||||||
|
const sendAction = vi.fn(unavailableSendAction())
|
||||||
|
const sendNotification = vi.fn()
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
now: () => new Date(clock.now),
|
||||||
|
...makeDependencies({ sendAction, sendNotification })
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '退避未到期日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
const first = await runScheduled(service, created.data!.id)
|
||||||
|
expect(first).toMatchObject({ status: 'waiting_to_send', retryCount: 0 })
|
||||||
|
|
||||||
|
// 仅推进 1 分钟,尚未达到 5 分钟退避。
|
||||||
|
clock.now = Date.parse(first.finishedAt || first.startedAt) + 60_000
|
||||||
|
await service.tick(new Date(clock.now))
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
expect(sendAction).toHaveBeenCalledTimes(1)
|
||||||
|
expect(await latestExecution(service, created.data!.id)).toMatchObject({
|
||||||
|
status: 'waiting_to_send',
|
||||||
|
retryCount: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops auto resending after the attempt limit but keeps manual retry', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-resend-limit-'))
|
||||||
|
await enableNotifications(storageDir)
|
||||||
|
const clock = { now: Date.parse('2026-08-27T01:00:00.000Z') }
|
||||||
|
const sendAction = vi.fn(unavailableSendAction())
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
now: () => new Date(clock.now),
|
||||||
|
...makeDependencies({ sendAction })
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '重发上限日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
let current = await runScheduled(service, created.data!.id)
|
||||||
|
expect(current).toMatchObject({ status: 'waiting_to_send', retryCount: 0 })
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
||||||
|
const backoff = Math.min(5 * 60_000 * 2 ** (current.retryCount || 0), 60 * 60_000)
|
||||||
|
clock.now = Date.parse(current.finishedAt || current.startedAt) + backoff + 1_000
|
||||||
|
await service.tick(new Date(clock.now))
|
||||||
|
await waitForAutoResend(async () => {
|
||||||
|
const execution = await latestExecution(service, created.data!.id)
|
||||||
|
return sendAction.mock.calls.length === attempt + 1 && execution.retryCount === attempt
|
||||||
|
})
|
||||||
|
// 等待后台 promise 从 retrying map 移除,避免影响下一次 tick 判定。
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||||
|
current = await latestExecution(service, created.data!.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已达 6 次自动重试上限:继续 tick 不再自动发送。
|
||||||
|
clock.now += 60 * 60_000 + 1_000
|
||||||
|
await service.tick(new Date(clock.now))
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
expect(sendAction).toHaveBeenCalledTimes(7)
|
||||||
|
expect(await latestExecution(service, created.data!.id)).toMatchObject({
|
||||||
|
status: 'waiting_to_send',
|
||||||
|
retryCount: 6
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手动重试不受自动上限限制。
|
||||||
|
const manual = await service.retryScheduledReportSend(current.id)
|
||||||
|
expect(manual.success).toBe(true)
|
||||||
|
expect(sendAction).toHaveBeenCalledTimes(8)
|
||||||
|
expect(manual.data).toMatchObject({ status: 'waiting_to_send', retryCount: 7 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips auto resend when the task is disabled', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-resend-disabled-'))
|
||||||
|
await enableNotifications(storageDir)
|
||||||
|
const clock = { now: Date.parse('2026-08-27T01:00:00.000Z') }
|
||||||
|
const sendAction = vi.fn(unavailableSendAction())
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
now: () => new Date(clock.now),
|
||||||
|
...makeDependencies({ sendAction })
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '任务禁用日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
const first = await runScheduled(service, created.data!.id)
|
||||||
|
expect(first).toMatchObject({ status: 'waiting_to_send', retryCount: 0 })
|
||||||
|
|
||||||
|
await service.setTaskEnabled(created.data!.id, false)
|
||||||
|
clock.now = Date.parse(first.finishedAt || first.startedAt) + 10 * 60_000
|
||||||
|
await service.tick(new Date(clock.now))
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
expect(sendAction).toHaveBeenCalledTimes(1)
|
||||||
|
expect(await latestExecution(service, created.data!.id)).toMatchObject({
|
||||||
|
status: 'waiting_to_send',
|
||||||
|
retryCount: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends an expired execution only once when ticks overlap', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-resend-overlap-'))
|
||||||
|
const clock = { now: Date.parse('2026-08-27T01:00:00.000Z') }
|
||||||
|
let releaseRetry: ((value: typeof sentAction) => void) | undefined
|
||||||
|
const retryPending = new Promise<typeof sentAction>((resolve) => {
|
||||||
|
releaseRetry = resolve
|
||||||
|
})
|
||||||
|
const sendAction = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(unavailableSendAction()())
|
||||||
|
.mockReturnValueOnce(retryPending)
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
now: () => new Date(clock.now),
|
||||||
|
...makeDependencies({ sendAction })
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '并发重发日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
const first = await runScheduled(service, created.data!.id)
|
||||||
|
const dueAt = Date.parse(first.finishedAt || first.startedAt) + 5 * 60_000 + 1_000
|
||||||
|
|
||||||
|
const firstTick = service.tick(new Date(dueAt))
|
||||||
|
const secondTick = service.tick(new Date(dueAt))
|
||||||
|
await waitForAutoResend(async () => sendAction.mock.calls.length === 2)
|
||||||
|
expect(sendAction).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
releaseRetry!(sentAction)
|
||||||
|
await Promise.all([firstTick, secondTick])
|
||||||
|
expect(sendAction).toHaveBeenCalledTimes(2)
|
||||||
|
expect(await latestExecution(service, created.data!.id)).toMatchObject({
|
||||||
|
status: 'success',
|
||||||
|
retryCount: 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shares the automatic resend with a concurrent manual retry', async () => {
|
||||||
|
const storageDir = await mkdtemp(
|
||||||
|
join(tmpdir(), 'tracememo-scheduled-report-resend-manual-race-')
|
||||||
|
)
|
||||||
|
const clock = { now: Date.parse('2026-08-27T01:00:00.000Z') }
|
||||||
|
let releaseRetry: ((value: typeof sentAction) => void) | undefined
|
||||||
|
const retryPending = new Promise<typeof sentAction>((resolve) => {
|
||||||
|
releaseRetry = resolve
|
||||||
|
})
|
||||||
|
const sendAction = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(unavailableSendAction()())
|
||||||
|
.mockReturnValueOnce(retryPending)
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
now: () => new Date(clock.now),
|
||||||
|
...makeDependencies({ sendAction })
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '手动竞争日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
const first = await runScheduled(service, created.data!.id)
|
||||||
|
const dueAt = Date.parse(first.finishedAt || first.startedAt) + 5 * 60_000 + 1_000
|
||||||
|
|
||||||
|
const automatic = service.tick(new Date(dueAt))
|
||||||
|
await waitForAutoResend(async () => sendAction.mock.calls.length === 2)
|
||||||
|
const manual = service.retryScheduledReportSend(first.id)
|
||||||
|
expect(sendAction).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
releaseRetry!(sentAction)
|
||||||
|
await automatic
|
||||||
|
const manualResult = await manual
|
||||||
|
expect(sendAction).toHaveBeenCalledTimes(2)
|
||||||
|
expect(manualResult).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
data: { status: 'success', retryCount: 1 }
|
||||||
|
})
|
||||||
|
expect(await latestExecution(service, created.data!.id)).toMatchObject(manualResult.data)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resumes an expired waiting execution after service recreation', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-resend-restart-'))
|
||||||
|
const clock = { now: Date.parse('2026-08-27T01:00:00.000Z') }
|
||||||
|
const firstSendAction = vi.fn(unavailableSendAction())
|
||||||
|
const firstService = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
now: () => new Date(clock.now),
|
||||||
|
...makeDependencies({ sendAction: firstSendAction })
|
||||||
|
})
|
||||||
|
const created = await firstService.createTask({
|
||||||
|
name: '重启恢复日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
const first = await runScheduled(firstService, created.data!.id)
|
||||||
|
expect(first.status).toBe('waiting_to_send')
|
||||||
|
|
||||||
|
const resumedSendAction = vi.fn().mockResolvedValue(sentAction)
|
||||||
|
const secondService = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
now: () => new Date(clock.now),
|
||||||
|
...makeDependencies({ sendAction: resumedSendAction })
|
||||||
|
})
|
||||||
|
const dueAt = Date.parse(first.finishedAt || first.startedAt) + 5 * 60_000 + 1_000
|
||||||
|
await secondService.tick(new Date(dueAt))
|
||||||
|
|
||||||
|
expect(resumedSendAction).toHaveBeenCalledTimes(1)
|
||||||
|
expect(await latestExecution(secondService, created.data!.id)).toMatchObject({
|
||||||
|
status: 'success',
|
||||||
|
retryCount: 1,
|
||||||
|
sendStatus: 'success'
|
||||||
|
})
|
||||||
|
const restored = new ScheduledReportService({ storageDir })
|
||||||
|
expect(await latestExecution(restored, created.data!.id)).toMatchObject({
|
||||||
|
status: 'success',
|
||||||
|
retryCount: 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('scheduled report filehelper target', () => {
|
||||||
|
it('builds contact recipients for filehelper and group recipients for chatrooms', () => {
|
||||||
|
expect(
|
||||||
|
buildScheduledReportActionRequest({
|
||||||
|
target: 'filehelper',
|
||||||
|
filePath: '/tmp/report.png',
|
||||||
|
executionId: 'exec-1',
|
||||||
|
triggerType: 'scheduled',
|
||||||
|
retryCount: 1,
|
||||||
|
taskId: 'task-1'
|
||||||
|
})
|
||||||
|
).toMatchObject({ recipient: { type: 'contact', id: 'filehelper' } })
|
||||||
|
expect(
|
||||||
|
buildScheduledReportActionRequest({
|
||||||
|
target: '12345@chatroom',
|
||||||
|
filePath: '/tmp/report.png',
|
||||||
|
executionId: 'exec-2',
|
||||||
|
triggerType: 'scheduled'
|
||||||
|
})
|
||||||
|
).toMatchObject({ recipient: { type: 'group', id: '12345@chatroom' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends a waiting report to filehelper when the target is the transfer assistant', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-filehelper-'))
|
||||||
|
const sendAction = vi.fn(async () => ({
|
||||||
|
actionId: 'send-filehelper',
|
||||||
|
status: 'failed' as const,
|
||||||
|
decision: 'allow' as const,
|
||||||
|
errorCode: 'SEND_CAPABILITY_UNAVAILABLE' as const,
|
||||||
|
reason: '当前微信发送能力不可用',
|
||||||
|
startedAt: '2026-08-27T01:00:00.000Z',
|
||||||
|
finishedAt: '2026-08-27T01:00:01.000Z'
|
||||||
|
}))
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
...makeDependencies({ sendAction })
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '文件传输助手日报',
|
||||||
|
group: '文件传输助手',
|
||||||
|
target: 'filehelper',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const execution = await runScheduled(service, created.data!.id)
|
||||||
|
|
||||||
|
expect(execution).toMatchObject({
|
||||||
|
status: 'waiting_to_send',
|
||||||
|
sendTarget: 'filehelper',
|
||||||
|
pngPath: '/tmp/saved.png'
|
||||||
|
})
|
||||||
|
expect(sendAction).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ target: 'filehelper', filePath: '/tmp/saved.png' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user