Compare commits

...
13 Commits
25 changed files with 2379 additions and 48 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 153 KiB

+28 -4
View File
@@ -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 ? '视频通话' : '语音通话'
+23
View File
@@ -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)
+71
View File
@@ -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 '[收藏]'
}
+7
View File
@@ -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
View File
@@ -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'
} }
} }
+17 -2
View File
@@ -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
} }
} }
+30 -2
View File
@@ -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,
+73 -27
View File
@@ -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
} }
+50 -1
View File
@@ -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 })
}
}
} }
+93
View File
@@ -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
}
+102
View File
@@ -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
} }
+39 -1
View File
@@ -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>
+37
View File
@@ -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;
+2
View File
@@ -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
+241
View File
@@ -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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/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'
}
}
+107
View File
@@ -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
+44
View File
@@ -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 = {
+68
View File
@@ -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(
+96
View File
@@ -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' })
})
})
+280
View File
@@ -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: '已领取'
}
})
})
}) })
+31
View File
@@ -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')
})
})
+31
View File
@@ -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
})
})
})
+408
View File
@@ -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' })
)
})
})