feat: 完善日报图片与公众号消息展示

修复日报图片读取格式与模型不支持时的友好降级
恢复话题关键词回退,确保词云在缺少顶层关键词时仍可展示
支持公众号多文章消息在聊天、日报、检索和 HTML 导出中完整呈现
迁移账号身份缓存并优化首次连接的账号占位信息
补充图片、词云、账号缓存与多文章消息回归测试
This commit is contained in:
电摇小子
2026-08-07 00:00:52 +08:00
parent 0ec2e6a0be
commit 3af62783dd
18 changed files with 432 additions and 36 deletions
+29
View File
@@ -579,6 +579,19 @@ body {
}
.structured-link { color: inherit; text-decoration: none; }
.structured-link:hover .structured-title { color: var(--accent); }
.structured-share-articles { display: flex; flex-direction: column; }
.structured-share-article {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 10px 0;
border-top: 1px solid var(--border);
color: inherit;
text-decoration: none;
}
.structured-share-article:first-child { border-top: 0; }
.structured-share-article img { width: 54px; height: 54px; border-radius: 4px; object-fit: cover; }
.location-content {
gap: 8px;
padding-top: 8px;
@@ -990,6 +1003,22 @@ const renderExportScript = (name: string): string => `
}
const renderShareContent = (data) => {
const label = shareLabel(data.typeVal)
const articles = Array.isArray(data.articles) ? data.articles : []
if (articles.length) {
const articleMarkup = articles.map((article) => {
const href = externalUrl(article.url)
const cover = imageUrl(article.coverUrl)
const body = '<span><div class="structured-title">' + displayText(article.title || '公众号文章') + '</div>' +
(article.description ? '<div class="structured-description">' + displayText(article.description) + '</div>' : '') +
'</span>' + (cover ? '<img src="' + cover + '" alt="">' : '')
return href
? '<a class="structured-share-article" href="' + href + '" target="_blank" rel="noreferrer noopener">' + body + '</a>'
: '<div class="structured-share-article">' + body + '</div>'
}).join('')
return '<div class="structured-content share-content" data-rich-kind="share">' +
'<div class="structured-kicker">' + displayText(data.appname || label) + '</div>' +
'<div class="structured-share-articles">' + articleMarkup + '</div></div>'
}
let title = decodeEntities(data.title || label)
let description = decodeEntities(data.des || '')
if (String(data.typeVal) === '51' && /当前微信版本不支持展示该内容/.test(title) && description) {
+14 -8
View File
@@ -321,7 +321,7 @@ function getLocalMediaMimeType(filePath: string): string {
}
}
function buildImageResponse(image: DecodedImage): {
function buildImageResponse(image: DecodedImage, includeData = false): {
success: true
data: string
isThumb: boolean
@@ -330,7 +330,7 @@ function buildImageResponse(image: DecodedImage): {
} {
const mediaService = image.cacheFilePath ? getImageMediaService() : null
const data =
mediaService && image.cacheFilePath && existsSync(image.cacheFilePath)
!includeData && mediaService && image.cacheFilePath && existsSync(image.cacheFilePath)
? mediaService.createLocalMediaUrl(image.cacheFilePath)
: image.data
return {
@@ -1126,7 +1126,12 @@ app.whenReady().then(async () => {
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
_sessionId?: string,
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
options?: {
force?: boolean
preferThumbnail?: boolean
priority?: number
includeData?: boolean
}
) => {
let service = imageDecryptService
if (!service) {
@@ -1146,6 +1151,7 @@ app.whenReady().then(async () => {
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
const force = options?.force === true
const preferThumbnail = options?.preferThumbnail === true
const includeData = options?.includeData === true
const priority = Number.isFinite(options?.priority) ? Number(options?.priority) : 0
const imageCacheKey = [
imageMd5 || '',
@@ -1154,10 +1160,10 @@ app.whenReady().then(async () => {
].join('|')
const mediaService = getImageMediaService()
const cachedImage = await service.getCachedDecodedImage(imageCacheKey, {
includeData: !mediaService
includeData: includeData || !mediaService
})
if (cachedImage && (!force || !cachedImage.isThumbnail)) {
return buildImageResponse(cachedImage)
return buildImageResponse(cachedImage, includeData)
}
return enqueueColdImageLoad(async () => {
@@ -1183,10 +1189,10 @@ app.whenReady().then(async () => {
// A previous queued request may have populated the cache while this one waited.
const queuedMediaService = getImageMediaService()
const queuedCacheHit = await coldService.getCachedDecodedImage(imageCacheKey, {
includeData: !queuedMediaService
includeData: includeData || !queuedMediaService
})
if (queuedCacheHit && (!force || !queuedCacheHit.isThumbnail)) {
return buildImageResponse(queuedCacheHit)
return buildImageResponse(queuedCacheHit, includeData)
}
let filePath = force
@@ -1220,7 +1226,7 @@ app.whenReady().then(async () => {
isThumbnail: coldService.isThumbnailFile(decrypted.filePath)
}
await coldService.cacheDecodedImage(imageCacheKey, decodedImage)
return buildImageResponse(decodedImage)
return buildImageResponse(decodedImage, includeData)
}, priority)
}
)
@@ -89,8 +89,11 @@ function sourceTextAndAttachment(message: chat.FormattedMessage): {
if (content.type === 'share') {
const title = content.title?.trim() || ''
const description = content.des?.trim() || ''
const articles = (content.articles || []).flatMap((article) =>
[article.title, article.description].map((value) => value?.trim()).filter(Boolean)
)
return {
text: [text, title, description].filter(Boolean).join('\n') || undefined,
text: [text, title, description, ...articles].filter(Boolean).join('\n') || undefined,
attachment:
title || content.url
? {
+66 -5
View File
@@ -8,6 +8,12 @@ type LocationContent = {
lng: number
}
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
type ShareArticle = {
title: string
description?: string
url: string
coverUrl?: string
}
type ShareContent = {
type: 'share'
title: string
@@ -15,6 +21,7 @@ type ShareContent = {
url: string
appname?: string
typeVal?: string
articles?: ShareArticle[]
}
type ForwardedMessageItem = {
messageType: number
@@ -482,17 +489,71 @@ function parseShareMessage(content: string): ParsedContent {
}
}
const title = decodeXmlEntities(extractXmlValue(content, 'title')) || ''
const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || ''
const url = extractXmlValue(content, 'url') || ''
const appname = extractXmlValue(content, 'appname') || extractXmlValue(content, 'appInfo') || ''
const articles = parseShareArticles(content)
const title =
articles[0]?.title || decodeXmlEntities(extractXmlValue(content, 'title')) || ''
const des =
articles[0]?.description ||
decodeXmlEntities(extractXmlValue(content, 'des') || extractXmlValue(content, 'desc')) ||
''
const url = articles[0]?.url || decodeXmlUrl(extractXmlValue(content, 'url')) || ''
const appname =
decodeXmlEntities(
extractXmlValue(content, 'appname') ||
extractXmlValue(content, 'publisher') ||
extractXmlValue(content, 'appInfo')
) || ''
const typeVal = extractXmlValue(content, 'type') || ''
if (!title && !url) {
return { type: 'unknown', raw: content }
}
return { type: 'share', title, des, url, appname, typeVal }
return {
type: 'share',
title,
des,
url,
appname,
typeVal,
articles: articles.length > 1 ? articles : undefined
}
}
function parseShareArticles(content: string): ShareArticle[] {
if (!/<mmreader\b/i.test(content)) return []
const articles = Array.from(
content.matchAll(/<item(?:\s[^>]*)?>([\s\S]*?)<\/item>/gi),
(match) => match[1] || ''
)
.map((item): ShareArticle | null => {
const title = decodeXmlEntities(extractXmlValue(item, 'title'))
const url = decodeXmlUrl(extractXmlValue(item, 'url'))
if (!title && !url) return null
const description = decodeXmlEntities(
extractXmlValue(item, 'digest') ||
extractXmlValue(item, 'summary') ||
extractXmlValue(item, 'des')
)
const coverUrl = decodeXmlUrl(
extractXmlValue(item, 'cover') || extractXmlValue(item, 'cover_1_1')
)
return {
title: title || '公众号文章',
url,
description: description || undefined,
coverUrl: coverUrl || undefined
}
})
.filter((article): article is ShareArticle => Boolean(article))
const seen = new Set<string>()
return articles.filter((article) => {
const key = `${article.url}|${article.title}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
function parseForwardBundle(content: string): ForwardBundleContent {
+34 -2
View File
@@ -155,14 +155,46 @@ function isCurrentAccountFile(
function readStartupCacheFile(accountRoot: string): StartupCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const file = getAccountCachePaths(normalizedRoot).startup
const paths = getAccountCachePaths(normalizedRoot)
const file = paths.startup
const scheduled = readScheduledValue<StartupCacheFile>(file)
if (scheduled) return scheduled
const memory = startupMemory.get(file)
if (memory) return memory
try {
if (!fs.existsSync(file)) return null
if (!fs.existsSync(file)) {
// Version 1 stored startup data in one JSON file. Migrate it lazily so
// account discovery can still show a cached nickname/avatar before the
// database key is entered.
if (!fs.existsSync(paths.legacy)) return null
const legacy = fs.readJsonSync(paths.legacy) as {
version?: number
platform?: NodeJS.Platform
accountRoot?: string
updatedAt?: number
self?: CachedSelfInfo
contacts?: Contact[]
}
if (
legacy.version !== 1 ||
legacy.platform !== process.platform ||
normalizeRoot(legacy.accountRoot) !== normalizedRoot
) {
return null
}
const migrated: StartupCacheFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Number(legacy.updatedAt) || 0,
self: legacy.self,
contacts: Array.isArray(legacy.contacts) ? legacy.contacts : []
}
startupMemory.set(file, migrated)
scheduleWrite(file, migrated, { cleanupFile: paths.legacy })
return migrated
}
const raw = fs.readJsonSync(file) as Partial<StartupCacheFile>
if (!isCurrentAccountFile(raw, normalizedRoot)) return null
const result: StartupCacheFile = {
+6 -1
View File
@@ -261,7 +261,12 @@ declare global {
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
sessionId?: string,
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
options?: {
force?: boolean
preferThumbnail?: boolean
priority?: number
includeData?: boolean
}
) => Promise<{
success: boolean
data?: string
+6 -1
View File
@@ -167,7 +167,12 @@ const api = {
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
sessionId?: string,
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
options?: {
force?: boolean
preferThumbnail?: boolean
priority?: number
includeData?: boolean
}
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
getVideo: (
hashes: string[],
@@ -333,12 +333,14 @@ export function DatabaseConnectionPage({
{account.avatar ? (
<img src={account.avatar} alt="" />
) : (
(account.nickname || '?').charAt(0)
(account.nickname || account.directoryName || '?').charAt(0)
)}
</span>
<span className="database-account-identity">
<strong>{account.nickname || '昵称未识别'}</strong>
<small>{account.wxid || 'wxid 未识别'}</small>
<strong>
{account.nickname || `账号目录 ${account.directoryName || '待识别'}`}
</strong>
<small>{account.wxid || '连接后读取微信号'}</small>
<code title={account.accountRoot}>{account.accountRoot}</code>
</span>
<span className="database-account-status">
@@ -158,7 +158,31 @@ function CardBubble({ data }: { data: Extract<ParsedContent, { type: 'card' }> }
}
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
const { title, des, url, appname } = data
const { title, des, url, appname, articles } = data
if (articles?.length) {
return (
<div className="share-message share-message-multi">
{appname && <div className="share-appname">{appname}</div>}
<div className="share-article-list">
{articles.map((article, index) => (
<button
type="button"
className="share-article"
key={`${article.url}-${index}`}
onClick={() => article.url && window.open(article.url, '_blank')}
>
<span className="share-article-copy">
<strong>{article.title || '公众号文章'}</strong>
{article.description ? <small>{article.description}</small> : null}
</span>
{article.coverUrl ? <img src={article.coverUrl} alt="" referrerPolicy="no-referrer" /> : null}
</button>
))}
</div>
</div>
)
}
const handleClick = (): void => {
if (url) {
@@ -30,8 +30,8 @@ const TEMPLATES: TemplateMeta[] = [
},
{
id: 'v2',
label: '模板2 · 支持图片板块',
tagline: '包含热点图片与上下文;需要模型服务商支持图片识别',
label: '模板2 · 丰富日报',
tagline: '包含更多群聊分析板块;勾选图片后会尝试生成图片精选',
preview: {
title: '支持图片板块',
sections: [
@@ -72,7 +72,7 @@ export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
<section className="report-section">
<h3></h3>
<p className="report-section-desc">
2 AI
</p>
<div className="report-template-list">
{TEMPLATES.map((tpl) => {
+61
View File
@@ -227,6 +227,67 @@
text-overflow: ellipsis;
}
.share-message-multi {
width: min(310px, 66vw);
max-width: 310px;
cursor: default;
}
.share-article-list {
display: flex;
flex-direction: column;
}
.share-article {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
width: 100%;
padding: 10px 0;
border: 0;
border-top: 1px solid var(--wxex-border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.share-article:first-child {
border-top: 0;
}
.share-article-copy {
min-width: 0;
}
.share-article-copy strong,
.share-article-copy small {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
}
.share-article-copy strong {
-webkit-line-clamp: 2;
font-size: 13px;
line-height: 18px;
}
.share-article-copy small {
margin-top: 4px;
color: var(--wxex-text-secondary);
-webkit-line-clamp: 1;
font-size: 11px;
}
.share-article img {
width: 54px;
height: 54px;
border-radius: 4px;
object-fit: cover;
}
.mini-program-message {
width: min(280px, 48vw);
overflow: hidden;
+31 -9
View File
@@ -34,7 +34,8 @@ declare const window: {
getImage: (
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
sessionId?: string
sessionId?: string,
options?: { includeData?: boolean }
) => Promise<ReportImageReadResult>
}
}
@@ -58,6 +59,20 @@ export interface GroupReportFactsSnapshot {
factsPrompt: string
}
function friendlyImageNotice(warnings: string[]): string {
const detail = warnings.join(' ')
if (/模型.*不支持|vision|multimodal|image.*support/i.test(detail)) {
return '当前 AI 模型暂未通过图片理解验证,已跳过图片精选;文字日报不受影响。'
}
if (/解密|密钥|未找到|读取失败/.test(detail)) {
return '部分图片在本机暂不可用,已跳过图片精选;文字日报不受影响。'
}
if (/格式.*不支持|图片格式/.test(detail)) {
return '部分图片暂不适合 AI 分析,已跳过图片精选;文字日报不受影响。'
}
return '图片精选暂未生成,文字消息、统计和关键词仍已正常处理。'
}
export const isInternalIdentifier = (value: string): boolean =>
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
@@ -90,7 +105,11 @@ export const summaryContent = (message: Message): string => {
case 'voice':
return `[语音${data.duration ? ` ${data.duration}` : ''}]`
case 'share':
return `[分享] ${data.title}${data.des ? `${data.des}` : ''}`
return data.articles?.length
? `[分享] ${data.articles
.map((article) => `${article.title}${article.description ? `${article.description}` : ''}`)
.join('')}`
: `[分享] ${data.title}${data.des ? `${data.des}` : ''}`
case 'quote': {
const reply = data.title || data.content || message.content || '[回复]'
const quotedSender =
@@ -282,7 +301,8 @@ const buildMediaSection = async (
const img = await rendererApi.getImage(
candidate.md5,
candidate.datName,
candidate.sessionId
candidate.sessionId,
{ includeData: true }
)
if (!img.success || !img.data) {
warnings.push(
@@ -336,7 +356,9 @@ const buildMediaSection = async (
const orig = rawImageCandidates.find((c) => c.sourceMessageIds[0] === item.messageId)
if (!orig) return item
try {
const img = await rendererApi.getImage(orig.md5, orig.datName, orig.sessionId)
const img = await rendererApi.getImage(orig.md5, orig.datName, orig.sessionId, {
includeData: true
})
if (img.success && img.data?.startsWith('data:image/')) {
return { ...item, imageUrl: img.data }
}
@@ -356,7 +378,9 @@ const buildMediaSection = async (
const imageCandidates = rendererApi
? await Promise.all(
rawImageCandidates.map(async (item) => {
const result = await rendererApi.getImage(item.md5, item.datName, item.sessionId)
const result = await rendererApi.getImage(item.md5, item.datName, item.sessionId, {
includeData: true
})
if (!result.success || !result.data?.startsWith('data:image/')) return null
return {
sender: item.sender,
@@ -637,7 +661,7 @@ export const buildGroupReportFacts = async (
)
if (warnings.length) metadata.warnings = [...(metadata.warnings || []), ...warnings]
if (imageCount > 0 && !media.visionGallery?.length) {
metadata.footerNote = `图片识别未成功:${warnings[0] || '当前模型未返回图片理解结果'}。其余内容基于已读取聊天记录生成。`
metadata.footerNote = friendlyImageNotice(warnings)
} else if (media.visionGallery?.length) {
metadata.footerNote = `基于已读取聊天记录生成;其中 ${media.visionGallery.length} 张图片已由当前视觉模型识别。`
}
@@ -647,9 +671,7 @@ export const buildGroupReportFacts = async (
transcriptRows.every((row) => row.content === '[图片]') &&
!media.visionGallery?.length
) {
throw new Error(
warnings[0] || '所选记录只有图片,但当前图片均未能识别,请检查图片解密密钥和模型视觉能力'
)
throw new Error('当前范围只有图片,但这些图片暂时无法分析。请改选文字消息,或在设置中验证图片理解能力。')
}
const factsPrompt = [
+10 -1
View File
@@ -850,6 +850,15 @@ export const parseGroupDailyReport = (
const root = asObject(extractJson(raw))
const topics = parseTopics(root)
if (!topics.length) throw new Error('AI 日报中没有有效话题')
const keywords = Array.from(
new Set([
...asStrings(root.keywords, 15),
...topics.flatMap((topic) => topic.keywords),
...topics.map((topic) => topic.title)
])
)
.filter(Boolean)
.slice(0, 15)
const heroRoot = asObject(root.hero)
const report: GroupDailyReport = {
@@ -888,7 +897,7 @@ export const parseGroupDailyReport = (
})),
voiceLeaderboard
},
keywords: asStrings(root.keywords, 15),
keywords,
media
}
+7
View File
@@ -53,6 +53,12 @@ type LocationContent = {
lng: number
}
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
export type ShareArticle = {
title: string
description?: string
url: string
coverUrl?: string
}
type ShareContent = {
type: 'share'
title: string
@@ -60,6 +66,7 @@ type ShareContent = {
url: string
appname?: string
typeVal?: string
articles?: ShareArticle[]
}
export type ForwardedMessageItem = {
messageType: number
+31
View File
@@ -72,3 +72,34 @@ describe('ImageBubble', () => {
expect(await screen.findByAltText('图片')).toBeVisible()
})
})
describe('RichMessageBubble', () => {
it('renders every article in a public-account bundle', async () => {
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
render(
<RichMessageBubble
contentData={{
type: 'share',
title: 'Article one',
url: 'https://example.test/one',
appname: 'Fixture Publisher',
articles: [
{ title: 'Article one', url: 'https://example.test/one' },
{ title: 'Article two', url: 'https://example.test/two' },
{ title: 'Article three', url: 'https://example.test/three' }
]
}}
/>
)
expect(screen.getByText('Fixture Publisher')).toBeVisible()
expect(screen.getByRole('button', { name: 'Article one' })).toBeVisible()
expect(screen.getByRole('button', { name: 'Article two' })).toBeVisible()
expect(screen.getByRole('button', { name: 'Article three' })).toBeVisible()
await userEvent.click(screen.getByRole('button', { name: 'Article three' }))
expect(open).toHaveBeenCalledWith('https://example.test/three', '_blank')
open.mockRestore()
})
})
+37 -1
View File
@@ -1,4 +1,5 @@
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync } from 'fs'
import { createHash } from 'crypto'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -105,4 +106,39 @@ describe('bootstrap cache', () => {
}).nickname
).toBe('濑岛田井卫')
})
it('migrates the previous startup cache so account discovery can show identity before unlock', () => {
const legacyRoot = '/fixture/legacy-account'
const digest = createHash('sha1')
.update(`${process.platform}:${legacyRoot}`)
.digest('hex')
.slice(0, 16)
const legacyFile = join(userData, 'cache', 'bootstrap', `${process.platform}-${digest}.json`)
mkdirSync(join(userData, 'cache', 'bootstrap'), { recursive: true })
writeFileSync(
legacyFile,
JSON.stringify({
version: 1,
platform: process.platform,
accountRoot: legacyRoot,
updatedAt: Date.now(),
self: {
wxid: 'wxid_legacy',
nickname: '缓存昵称',
avatar: 'data:image/png;base64,fixture',
accountRoot: legacyRoot
},
contacts: []
}),
'utf8'
)
clearBootstrapCache()
expect(getBootstrapCache(legacyRoot)?.self).toMatchObject({
wxid: 'wxid_legacy',
nickname: '缓存昵称',
avatar: 'data:image/png;base64,fixture'
})
flushBootstrapCacheWritesSync()
})
})
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { parseGroupDailyReport } from '../../src/renderer/src/utils/group-report'
import type { GroupReportMetadata } from '../../src/shared/group-report'
const metadata: GroupReportMetadata = {
groupName: '测试群',
reportDate: '2026-08-06',
dateRange: '今日',
messageCount: 3,
activeUsers: 2,
timeSpan: '1 h',
generatedAt: '2026/8/6 12:00:00',
recordNote: 'fixture',
footerNote: '',
heroParticipants: [],
reportMode: 'full'
}
const media = {
gallery: [],
voiceHighlights: [],
funBadges: []
}
describe('group report parsing', () => {
it('falls back to topic keywords when the model omits top-level keywords', () => {
const report = parseGroupDailyReport(
JSON.stringify({
topics: [
{
title: '健身安排',
summary: '讨论训练时间和肌酸。',
keywords: ['肌酸', '训练']
}
]
}),
[],
'',
[],
metadata,
media
)
expect(report.keywords).toEqual(['肌酸', '训练', '健身安排'])
})
})
+17
View File
@@ -69,6 +69,23 @@ describe('message parser', () => {
expect(parsed).toMatchObject({ type: 'share', title: '普通分享', typeVal: '5' })
})
it('preserves every article in a public-account multi-article message', () => {
const parsed = parseMessageContent(
`<appmsg><type>5</type><appname>长江日报</appname><mmreader><category count="3"><item><title><![CDATA[女子吃酒席时意外发现]]></title><url><![CDATA[https://mp.weixin.qq.com/a]]></url><cover><![CDATA[https://img.test/a.jpg]]></cover></item><item><title>霍尔木兹海峡开放临时协议</title><digest>国际油价短期走势</digest><url>https://mp.weixin.qq.com/b</url></item><item><title>东野圭吾新作</title><url>https://mp.weixin.qq.com/c</url></item></category></mmreader></appmsg>`,
49
)
expect(parsed).toMatchObject({ type: 'share', appname: '长江日报' })
if (parsed.type === 'share') {
expect(parsed.articles).toHaveLength(3)
expect(parsed.articles?.map((article) => article.title)).toEqual([
'女子吃酒席时意外发现',
'霍尔木兹海峡开放临时协议',
'东野圭吾新作'
])
}
})
it('uses the quoted group member id instead of the chatroom id', () => {
const parsed = parseMessageContent(
'<appmsg><type>57</type><title>回复内容</title><refermsg><type>1</type><fromusr>123456789@chatroom</fromusr><chatusr>wxid_fixture_member</chatusr><content>被引用内容</content></refermsg></appmsg>',