feat(chat): 支持位置消息与小程序卡片解析展示

- 新增位置消息解析,补充经纬度、地点名和地址字段
- 修复小程序分享 type 识别,避免嵌套 type 干扰
- 聊天页新增位置卡片展示,并补充小程序卡片样式
- 导出、搜索和会话预览同步支持位置消息
- 补充位置导出与小程序解析测试
This commit is contained in:
2977094657
2026-03-06 21:19:15 +08:00
Unverified
parent d48ba3b9aa
commit 16a13af18a
9 changed files with 890 additions and 61 deletions
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1772793179663" class="icon" viewBox="0 0 1025 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2488" xmlns:xlink="http://www.w3.org/1999/xlink" width="200.1953125" height="200"><path d="M740.672 37.504c156.352 0 283.52 115.584 283.52 258.496 0 44.416-13.056 87.872-36.608 127.04-35.648 57.216-92.672 99.584-161.664 119.744a161.408 161.408 0 0 1-45.184 7.36 52.8 52.8 0 0 1-53.76-52.928c0-29.76 23.68-52.864 53.76-52.864 2.112 0 6.528 0 11.904-2.048 46.336-12.8 82.944-39.168 103.424-74.24 13.952-22.144 20.48-46.72 20.48-72.064 0-83.84-78.72-152.512-174.72-152.512a197.76 197.76 0 0 0-94.72 24.32c-50.816 28.544-80.896 76.16-80.896 128.192v443.904c0 89.984-50.752 172.672-134.848 219.328-45.184 25.408-96 38.272-147.712 38.272-156.288 0-283.52-115.648-283.52-258.56 0-44.352 13.12-87.872 36.608-127.04 35.648-57.216 92.736-99.584 161.664-119.68 19.328-5.312 32.384-7.36 45.184-7.36 30.272 0 53.824 23.36 53.824 52.864a52.8 52.8 0 0 1-53.76 52.928c-2.176 0-6.592 0-11.904 2.048-46.4 13.76-82.944 40.32-103.424 74.176-14.016 22.208-20.48 46.72-20.48 72.128 0 83.84 78.72 152.448 175.616 152.448a197.76 197.76 0 0 0 94.784-24.256c50.752-28.608 80.832-76.224 80.832-128.192V296.192c0-89.984 50.752-172.608 134.848-219.328a283.52 283.52 0 0 1 146.752-39.36z" fill="#6467f0" p-id="2489"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+261
View File
@@ -0,0 +1,261 @@
<template>
<div
class="wechat-location-card-wrap"
:class="isSent ? 'wechat-location-card-wrap--sent' : 'wechat-location-card-wrap--received'"
>
<div
class="wechat-location-card"
:class="{ 'wechat-location-card--sent': isSent }"
role="button"
tabindex="0"
@click="openLocation"
@keydown.enter.prevent="openLocation"
@keydown.space.prevent="openLocation"
>
<div class="wechat-location-card__text">
<div class="wechat-location-card__title">{{ primaryText }}</div>
<div v-if="secondaryText" class="wechat-location-card__subtitle">{{ secondaryText }}</div>
</div>
<div class="wechat-location-card__map" :class="{ 'wechat-location-card__map--placeholder': !mapTileUrl }">
<img
v-if="mapTileUrl"
:src="mapTileUrl"
alt="地图预览"
class="wechat-location-card__map-image"
loading="lazy"
referrerpolicy="no-referrer"
>
<div class="wechat-location-card__map-overlay"></div>
<div class="wechat-location-card__pin" :style="markerStyle" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M12 22s7-5.82 7-12a7 7 0 1 0-14 0c0 6.18 7 12 7 12Z" fill="#22c55e" />
<circle cx="12" cy="10" r="3.2" fill="#ffffff" />
</svg>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
message: { type: Object, default: () => ({}) },
})
const TILE_SIZE = 256
const MAP_ZOOM = 15
const cleanText = (value) => String(value || '').replace(/\s+/g, ' ').trim()
const toFiniteNumber = (value) => {
const num = Number.parseFloat(String(value ?? '').trim())
return Number.isFinite(num) ? num : null
}
const latitude = computed(() => {
const num = toFiniteNumber(props.message?.locationLat)
return num != null && Math.abs(num) <= 90 ? num : null
})
const longitude = computed(() => {
const num = toFiniteNumber(props.message?.locationLng)
return num != null && Math.abs(num) <= 180 ? num : null
})
const primaryText = computed(() => {
return cleanText(
props.message?.locationPoiname
|| props.message?.title
|| props.message?.content
|| '位置'
) || '位置'
})
const secondaryText = computed(() => {
const label = cleanText(props.message?.locationLabel)
return label && label !== primaryText.value ? label : ''
})
const isSent = computed(() => !!props.message?.isSent)
const mapTileMeta = computed(() => {
const lat = latitude.value
const lng = longitude.value
if (lat == null || lng == null) return null
const scale = Math.pow(2, MAP_ZOOM)
const worldX = ((lng + 180) / 360) * scale * TILE_SIZE
const latRad = (lat * Math.PI) / 180
const worldY = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * scale * TILE_SIZE
const tileX = Math.floor(worldX / TILE_SIZE)
const tileY = Math.floor(worldY / TILE_SIZE)
const offsetX = worldX - tileX * TILE_SIZE
const offsetY = worldY - tileY * TILE_SIZE
return {
tileX,
tileY,
left: `${(offsetX / TILE_SIZE) * 100}%`,
top: `${(offsetY / TILE_SIZE) * 100}%`,
}
})
const mapTileUrl = computed(() => {
const meta = mapTileMeta.value
if (!meta) return ''
return `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${meta.tileX}&y=${meta.tileY}&z=${MAP_ZOOM}`
})
const markerStyle = computed(() => {
const meta = mapTileMeta.value
return {
left: meta?.left || '50%',
top: meta?.top || '50%',
}
})
const mapLink = computed(() => {
const name = encodeURIComponent(primaryText.value || secondaryText.value || '位置')
const lat = latitude.value
const lng = longitude.value
if (lat != null && lng != null) {
return `https://uri.amap.com/marker?position=${lng},${lat}&name=${name}`
}
if (name) return `https://uri.amap.com/search?keyword=${name}`
return ''
})
const openLocation = () => {
if (!process.client) return
const href = mapLink.value
if (!href) return
window.open(href, '_blank', 'noopener,noreferrer')
}
</script>
<style scoped>
.wechat-location-card-wrap {
position: relative;
display: inline-block;
}
.wechat-location-card-wrap--received::before,
.wechat-location-card-wrap--sent::after {
content: '';
position: absolute;
top: 12px;
width: 12px;
height: 12px;
background: #fff;
transform: rotate(45deg);
border-radius: 2px;
}
.wechat-location-card-wrap--received::before {
left: -4px;
}
.wechat-location-card-wrap--sent::after {
right: -4px;
}
.wechat-location-card {
width: 208px;
overflow: hidden;
border-radius: var(--message-radius);
border: none;
background: #fff;
box-shadow: none;
cursor: pointer;
transition: opacity 0.15s ease;
}
.wechat-location-card--sent {
background: #fff;
}
.wechat-location-card__text {
padding: 10px 12px 8px;
background: #fff;
}
.wechat-location-card--sent .wechat-location-card__text {
background: #fff;
}
.wechat-location-card__title {
color: #111827;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.wechat-location-card__subtitle {
margin-top: 4px;
color: #9ca3af;
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wechat-location-card--sent .wechat-location-card__subtitle {
color: #9ca3af;
}
.wechat-location-card__map {
position: relative;
height: 98px;
overflow: hidden;
background:
linear-gradient(0deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.3)),
linear-gradient(135deg, #d7eef5 0%, #f6f8fb 45%, #ece7cf 100%);
}
.wechat-location-card__map--placeholder::before {
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(90deg, rgba(255,255,255,0.65) 0 8%, transparent 8% 34%, rgba(255,255,255,0.65) 34% 42%, transparent 42% 100%),
linear-gradient(0deg, rgba(255,255,255,0.7) 0 10%, transparent 10% 38%, rgba(255,255,255,0.7) 38% 46%, transparent 46% 100%);
opacity: 0.65;
}
.wechat-location-card__map-image,
.wechat-location-card__map-overlay {
position: absolute;
inset: 0;
}
.wechat-location-card__map-image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.wechat-location-card__map-overlay {
background: linear-gradient(180deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0) 38%, rgba(17,24,39,0.06) 100%);
}
.wechat-location-card__pin {
position: absolute;
width: 22px;
height: 22px;
transform: translate(-50%, -92%);
filter: drop-shadow(0 4px 8px rgba(34, 197, 94, 0.28));
}
.wechat-location-card__pin svg {
display: block;
width: 100%;
height: 100%;
}
</style>
@@ -83,6 +83,10 @@
[语音]
</div>
<div v-else-if="renderType === 'location'" class="max-w-sm">
<ChatLocationCard :message="message" />
</div>
<!-- 默认文本消息 -->
<div
v-else
+310 -55
View File
@@ -551,6 +551,7 @@
:preview="message.preview"
:fromAvatar="message.fromAvatar"
:from="message.from"
:linkType="message.linkType"
:isSent="message.isSent"
:variant="message.linkCardVariant || 'default'"
/>
@@ -814,6 +815,9 @@
<span>微信红包</span>
</div>
</div>
<div v-else-if="message.renderType === 'location'" class="max-w-sm">
<ChatLocationCard :message="message" />
</div>
<!-- 文本消息 -->
<div v-else-if="message.renderType === 'text'"
class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
@@ -1490,12 +1494,12 @@
@contextmenu="openMediaContextMenu($event, rec, 'message')"
>
<div class="wechat-link-content">
<div class="wechat-link-info">
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
<div v-if="rec.content || rec.preview" class="wechat-link-summary">
<div v-if="rec.content" class="wechat-link-desc">{{ rec.content }}</div>
</div>
<div v-if="rec.preview" class="wechat-link-thumb">
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
<div v-if="rec.preview" class="wechat-link-thumb">
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
</div>
</div>
</div>
<div class="wechat-link-from">
@@ -1602,12 +1606,12 @@
@contextmenu="openMediaContextMenu($event, win, 'message')"
>
<div class="wechat-link-content">
<div class="wechat-link-info">
<div class="wechat-link-title">{{ win.title || win.url || '链接' }}</div>
<div class="wechat-link-title">{{ win.title || win.url || '链接' }}</div>
<div v-if="win.content || win.preview" class="wechat-link-summary">
<div v-if="win.content" class="wechat-link-desc">{{ win.content }}</div>
</div>
<div v-if="win.preview" class="wechat-link-thumb">
<img :src="win.preview" :alt="win.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(win)" />
<div v-if="win.preview" class="wechat-link-thumb">
<img :src="win.preview" :alt="win.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(win)" />
</div>
</div>
</div>
<div class="wechat-link-from">
@@ -1768,12 +1772,12 @@
@contextmenu="openMediaContextMenu($event, rec, 'message')"
>
<div class="wechat-link-content">
<div class="wechat-link-info">
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
<div v-if="rec.content || rec.preview" class="wechat-link-summary">
<div v-if="rec.content" class="wechat-link-desc">{{ rec.content }}</div>
</div>
<div v-if="rec.preview" class="wechat-link-thumb">
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
<div v-if="rec.preview" class="wechat-link-thumb">
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
</div>
</div>
</div>
<div class="wechat-link-from">
@@ -2465,6 +2469,7 @@ import { useChatAccountsStore } from '~/stores/chatAccounts'
import { useChatRealtimeStore } from '~/stores/chatRealtime'
import { usePrivacyStore } from '~/stores/privacy'
import wechatPcLogoUrl from '~/assets/images/wechat/WeChat-Icon-Logo.wine.svg'
import miniProgramIconUrl from '~/assets/images/wechat/mini-program.svg'
import zipIconUrl from '~/assets/images/wechat/zip.png'
import pdfIconUrl from '~/assets/images/wechat/pdf.png'
import wordIconUrl from '~/assets/images/wechat/word.png'
@@ -5953,7 +5958,7 @@ const loadSessionsForSelectedAccount = async () => {
id: s.id,
name: s.name || s.username || s.id,
avatar: s.avatar || null,
lastMessage: s.lastMessage || '',
lastMessage: normalizeSessionPreview(s.lastMessage || ''),
lastMessageTime: s.lastMessageTime || '',
unreadCount: s.unreadCount || 0,
isGroup: !!s.isGroup,
@@ -6039,7 +6044,7 @@ const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => {
id: s.id,
name: s.name || s.username || s.id,
avatar: s.avatar || null,
lastMessage: s.lastMessage || '',
lastMessage: normalizeSessionPreview(s.lastMessage || ''),
lastMessageTime: s.lastMessageTime || '',
unreadCount: s.unreadCount || 0,
isGroup: !!s.isGroup,
@@ -6308,6 +6313,10 @@ const normalizeMessage = (msg) => {
transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款' || msg.transferStatus === '已被接收',
voiceUrl: normalizedVoiceUrl || '',
voiceDuration: msg.voiceLength || msg.voiceDuration || '',
locationLat: msg.locationLat ?? null,
locationLng: msg.locationLng ?? null,
locationPoiname: String(msg.locationPoiname || '').trim(),
locationLabel: String(msg.locationLabel || '').trim(),
preview: normalizedLinkPreviewUrl || '',
linkType: String(msg.linkType || '').trim(),
linkStyle: String(msg.linkStyle || '').trim(),
@@ -6409,6 +6418,14 @@ const closeTopFloatingWindow = () => {
if (top?.id) closeFloatingWindow(top.id)
}
const normalizeSessionPreview = (value) => {
const text = String(value || '').trim()
if (!text) return ''
if (/^\[location\]/i.test(text)) return text.replace(/^\[location\]/i, '[位置]')
if (/:\s*\[location\]$/i.test(text)) return text.replace(/\[location\]$/i, '[位置]')
return text
}
const openFloatingWindow = (payload) => {
if (!process.client) return null
const w0 = Number(payload?.width || 0) > 0 ? Number(payload.width) : 560
@@ -7843,13 +7860,15 @@ const onMessageScroll = async () => {
const LinkCard = defineComponent({
name: 'LinkCard',
props: {
href: { type: String, required: true },
href: { type: String, default: '' },
heading: { type: String, default: '' },
abstract: { type: String, default: '' },
preview: { type: String, default: '' },
fromAvatar: { type: String, default: '' },
from: { type: String, default: '' },
linkType: { type: String, default: '' },
isSent: { type: Boolean, default: false },
badge: { type: String, default: '' },
variant: { type: String, default: 'default' }
},
setup(props) {
@@ -7863,7 +7882,9 @@ const LinkCard = defineComponent({
// Fallback: when the appmsg XML doesn't provide sourcedisplayname/appname,
// show the host so the footer row still matches WeChat's fixed card layout.
try {
const host = new URL(String(props.href || '')).hostname
const href = String(props.href || '').trim()
if (!/^https?:\/\//i.test(href)) return ''
const host = new URL(href).hostname
return String(host || '').trim()
} catch {
return ''
@@ -7872,6 +7893,9 @@ const LinkCard = defineComponent({
return () => {
const fromText = getFromText()
const href = String(props.href || '').trim()
const canNavigate = /^https?:\/\//i.test(href)
const badgeText = String(props.badge || '').trim()
// WeChat link cards show a small avatar next to the source text. We don't
// always have a real image URL, so fall back to the first glyph.
const fromAvatarText = (() => {
@@ -7879,7 +7903,9 @@ const LinkCard = defineComponent({
return t ? (Array.from(t)[0] || '') : ''
})()
const fromAvatarUrl = String(props.fromAvatar || '').trim()
const isCoverVariant = String(props.variant || '').trim() === 'cover'
const isMiniProgram = String(props.linkType || '').trim() === 'mini_program'
const isCoverVariant = !isMiniProgram && String(props.variant || '').trim() === 'cover'
const Tag = canNavigate ? 'a' : 'div'
// Props may change when switching accounts/chats; reset load state per URL.
if (fromAvatarUrl !== lastFromAvatarUrl.value) {
@@ -7896,6 +7922,12 @@ const LinkCard = defineComponent({
color: 'transparent'
}
: null
const miniProgramAvatarStyle = fromAvatarImgOk.value
? {
background: '#fff',
color: 'transparent'
}
: null
const onFromAvatarLoad = () => {
fromAvatarImgOk.value = true
fromAvatarImgError.value = false
@@ -7918,17 +7950,17 @@ const LinkCard = defineComponent({
onError: onFromAvatarError
}) : null
].filter(Boolean)),
h('div', { class: 'wechat-link-cover-from-name' }, fromText || '\u200B')
])
h('div', { class: 'wechat-link-cover-from-name', style: { flex: '1 1 auto', minWidth: '0' } }, fromText || '\u200B'),
badgeText ? h('div', { class: 'wechat-link-cover-badge' }, badgeText) : null,
].filter(Boolean))
return h(
'a',
Tag,
{
href: props.href,
target: '_blank',
rel: 'noreferrer',
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
class: [
'wechat-link-card-cover',
!canNavigate ? 'wechat-link-card--disabled' : '',
'wechat-special-card',
'msg-radius',
props.isSent ? 'wechat-special-sent-side' : ''
@@ -7958,19 +7990,91 @@ const LinkCard = defineComponent({
}),
fromRow,
]) : fromRow,
h('div', { class: 'wechat-link-cover-title' }, props.heading || props.href)
h('div', { class: 'wechat-link-cover-title' }, props.heading || href)
].filter(Boolean)
)
}
const headingText = String(props.heading || href || '').trim()
let abstractText = String(props.abstract || '').trim()
if (abstractText && headingText && abstractText === headingText) abstractText = ''
if (isMiniProgram) {
return h(
Tag,
{
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
class: [
'wechat-link-card',
'wechat-link-card--mini-program',
!canNavigate ? 'wechat-link-card--disabled' : '',
'wechat-special-card',
'msg-radius',
props.isSent ? 'wechat-special-sent-side' : ''
].filter(Boolean).join(' '),
style: {
width: '210px',
minWidth: '210px',
maxWidth: '210px',
maxHeight: '270px',
height: '270px',
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
flex: '0 0 auto',
background: '#fff',
border: 'none',
boxShadow: 'none',
textDecoration: 'none',
outline: 'none'
}
},
[
h('div', { class: 'wechat-link-mini-body' }, [
h('div', { class: 'wechat-link-mini-header' }, [
h('div', { class: 'wechat-link-mini-header-avatar', style: miniProgramAvatarStyle, 'aria-hidden': 'true' }, [
showFromAvatarText ? (fromAvatarText || '\u200B') : null,
showFromAvatarImg ? h('img', {
src: fromAvatarUrl,
alt: '',
class: 'wechat-link-mini-header-avatar-img',
referrerpolicy: 'no-referrer',
onLoad: onFromAvatarLoad,
onError: onFromAvatarError
}) : null
].filter(Boolean)),
h('div', { class: 'wechat-link-mini-header-name' }, fromText || '\u200B')
]),
h('div', { class: 'wechat-link-mini-title' }, headingText || abstractText || href),
h('div', { class: ['wechat-link-mini-preview', !props.preview ? 'wechat-link-mini-preview--empty' : ''].filter(Boolean).join(' ') }, [
props.preview ? h('img', {
src: props.preview,
alt: props.heading || '小程序预览',
class: 'wechat-link-mini-preview-img',
referrerpolicy: 'no-referrer'
}) : null
].filter(Boolean))
]),
h('div', { class: 'wechat-link-mini-footer' }, [
h('img', {
src: miniProgramIconUrl,
alt: '',
class: 'wechat-link-mini-footer-icon',
'aria-hidden': 'true'
}),
h('span', { class: 'wechat-link-mini-footer-text' }, '小程序')
])
]
)
}
return h(
'a',
Tag,
{
href: props.href,
target: '_blank',
rel: 'noreferrer',
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
class: [
'wechat-link-card',
!canNavigate ? 'wechat-link-card--disabled' : '',
'wechat-special-card',
'msg-radius',
props.isSent ? 'wechat-special-sent-side' : ''
@@ -7995,13 +8099,15 @@ const LinkCard = defineComponent({
},
[
h('div', { class: 'wechat-link-content' }, [
h('div', { class: 'wechat-link-info' }, [
h('div', { class: 'wechat-link-title' }, props.heading || props.href),
props.abstract ? h('div', { class: 'wechat-link-desc' }, props.abstract) : null
].filter(Boolean)),
props.preview ? h('div', { class: 'wechat-link-thumb' }, [
h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'wechat-link-thumb-img', referrerpolicy: 'no-referrer' })
]) : null
h('div', { class: 'wechat-link-title' }, headingText || href),
(abstractText || props.preview)
? h('div', { class: 'wechat-link-summary' }, [
abstractText ? h('div', { class: 'wechat-link-desc' }, abstractText) : null,
props.preview ? h('div', { class: 'wechat-link-thumb' }, [
h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'wechat-link-thumb-img', referrerpolicy: 'no-referrer' })
]) : null
].filter(Boolean))
: null
].filter(Boolean)),
h('div', { class: 'wechat-link-from' }, [
h('div', { class: 'wechat-link-from-avatar', style: fromAvatarStyle, 'aria-hidden': 'true' }, [
@@ -8015,8 +8121,9 @@ const LinkCard = defineComponent({
onError: onFromAvatarError
}) : null
].filter(Boolean)),
h('div', { class: 'wechat-link-from-name' }, fromText || '\u200B')
])
h('div', { class: 'wechat-link-from-name', style: { flex: '1 1 auto', minWidth: '0' } }, fromText || '\u200B'),
badgeText ? h('div', { class: 'wechat-link-badge' }, badgeText) : null
].filter(Boolean))
].filter(Boolean)
)
}
@@ -8026,6 +8133,35 @@ const LinkCard = defineComponent({
</script>
<style scoped>
/* LinkCard:小程序标记与无 URL 降级 */
::deep(.wechat-link-badge) {
margin-left: auto;
padding-left: 8px;
font-size: 11px;
color: #b2b2b2;
white-space: nowrap;
flex-shrink: 0;
}
::deep(.wechat-link-cover-badge) {
margin-left: auto;
padding-left: 8px;
font-size: 11px;
color: rgba(243, 243, 243, 0.92);
white-space: nowrap;
flex-shrink: 0;
}
::deep(.wechat-link-card.wechat-link-card--disabled),
::deep(.wechat-link-card-cover.wechat-link-card--disabled) {
cursor: default;
}
::deep(.wechat-link-card.wechat-link-card--disabled:hover),
::deep(.wechat-link-card-cover.wechat-link-card--disabled:hover) {
background: #fff;
}
/* 滚动条样式 */
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
@@ -8775,21 +8911,18 @@ const LinkCard = defineComponent({
:deep(.wechat-link-content) {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
/* Keep a small breathing room above the footer divider. */
padding: 8px 10px 6px;
padding: 10px 10px 8px;
flex: 1 1 auto;
}
:deep(.wechat-link-info) {
:deep(.wechat-link-summary) {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1 1 auto;
min-width: 0;
align-items: flex-start;
gap: 10px;
min-height: 42px;
}
:deep(.wechat-link-title) {
@@ -8806,24 +8939,24 @@ const LinkCard = defineComponent({
:deep(.wechat-link-desc) {
font-size: 12px;
color: #8c8c8c;
margin-top: 4px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
word-break: break-word;
flex: 1 1 auto;
min-width: 0;
}
:deep(.wechat-link-thumb) {
width: 42px;
height: 42px;
flex-shrink: 0;
flex: 0 0 auto;
border-radius: 0;
overflow: hidden;
background: #f2f2f2;
/* Center the thumbnail in the content area (WeChat desktop style). */
align-self: center;
align-self: flex-start;
}
:deep(.wechat-link-thumb-img) {
@@ -8833,6 +8966,127 @@ const LinkCard = defineComponent({
display: block;
}
:deep(.wechat-link-card--mini-program) {
max-height: 270px;
height: 270px;
}
:deep(.wechat-link-mini-body) {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
box-sizing: border-box;
flex: 1 1 auto;
min-height: 0;
}
:deep(.wechat-link-mini-header) {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
:deep(.wechat-link-mini-header-avatar) {
width: 20px;
height: 20px;
border-radius: 50%;
background: #14c15f;
color: #fff;
font-size: 11px;
line-height: 20px;
text-align: center;
flex-shrink: 0;
position: relative;
overflow: hidden;
}
:deep(.wechat-link-mini-header-avatar-img) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
:deep(.wechat-link-mini-header-name) {
font-size: 13px;
color: #7d7d7d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1 1 auto;
}
:deep(.wechat-link-mini-title) {
font-size: 13px;
line-height: 1.45;
color: #1a1a1a;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
:deep(.wechat-link-mini-preview) {
width: 100%;
height: auto;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
background: #f2f2f2;
margin-top: auto;
}
:deep(.wechat-link-mini-preview--empty) {
background: #f7f7f7;
}
:deep(.wechat-link-mini-preview-img) {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
display: block;
}
:deep(.wechat-link-mini-footer) {
height: 23px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
box-sizing: border-box;
position: relative;
flex-shrink: 0;
}
:deep(.wechat-link-mini-footer)::before {
content: '';
position: absolute;
top: 0;
left: 12px;
right: 12px;
height: 1px;
background: #e8e8e8;
}
:deep(.wechat-link-mini-footer-icon) {
width: 12px;
height: 12px;
object-fit: contain;
flex-shrink: 0;
}
:deep(.wechat-link-mini-footer-text) {
font-size: 10px;
color: #8c8c8c;
}
:deep(.wechat-link-from) {
height: 30px;
display: flex;
@@ -9057,3 +9311,4 @@ const LinkCard = defineComponent({
}
}
</style>
@@ -40,6 +40,7 @@ from .chat_helpers import (
_load_latest_message_previews,
_lookup_resource_md5,
_parse_app_message,
_parse_location_message,
_parse_system_message_content,
_parse_pat_message,
_pick_display_name,
@@ -3378,6 +3379,10 @@ def _parse_message_for_export(
file_md5 = ""
transfer_id = ""
voip_type = ""
location_lat: Optional[float] = None
location_lng: Optional[float] = None
location_poiname = ""
location_label = ""
if local_type == 10000:
render_type = "system"
@@ -3437,6 +3442,14 @@ def _parse_message_for_export(
quote_voice_length = str(parsed.get("quoteVoiceLength") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
elif local_type == 48:
parsed = _parse_location_message(raw_text)
render_type = str(parsed.get("renderType") or "location")
content_text = str(parsed.get("content") or "[Location]")
location_lat = parsed.get("locationLat")
location_lng = parsed.get("locationLng")
location_poiname = str(parsed.get("locationPoiname") or "")
location_label = str(parsed.get("locationLabel") or "")
elif local_type == 3:
render_type = "image"
def add_md5(v: Any) -> None:
@@ -3708,6 +3721,10 @@ def _parse_message_for_export(
"transferStatus": transfer_status,
"transferId": transfer_id,
"voipType": voip_type,
"locationLat": location_lat,
"locationLng": location_lng,
"locationPoiname": location_poiname,
"locationLabel": location_label,
}
+164 -5
View File
@@ -712,6 +712,68 @@ def _extract_xml_tag_or_attr(xml_text: str, name: str) -> str:
return _extract_xml_attr(xml_text, name)
def _parse_location_message(text: str) -> dict[str, Any]:
raw = html.unescape(str(text or "").strip())
def _clean(value: Any) -> str:
candidate = _strip_cdata(str(value or "").strip())
if not candidate:
return ""
candidate = html.unescape(candidate)
candidate = re.sub(r"\s+", " ", candidate).strip()
return candidate
def _to_float(value: Any) -> Optional[float]:
s = str(value or "").strip()
if not s:
return None
try:
num = float(s)
except Exception:
return None
if not (-180.0 <= num <= 180.0):
return None
return num
poiname = _clean(
_extract_xml_tag_or_attr(raw, "poiname")
or _extract_xml_tag_or_attr(raw, "poiName")
or _extract_xml_tag_or_attr(raw, "name")
)
label = _clean(
_extract_xml_tag_or_attr(raw, "label")
or _extract_xml_tag_or_attr(raw, "labelname")
or _extract_xml_tag_or_attr(raw, "address")
)
lat = _to_float(
_extract_xml_tag_or_attr(raw, "x")
or _extract_xml_tag_or_attr(raw, "latitude")
or _extract_xml_tag_or_attr(raw, "lat")
)
lng = _to_float(
_extract_xml_tag_or_attr(raw, "y")
or _extract_xml_tag_or_attr(raw, "longitude")
or _extract_xml_tag_or_attr(raw, "lng")
or _extract_xml_tag_or_attr(raw, "lon")
)
if lat is not None and not (-90.0 <= lat <= 90.0):
lat = None
if lng is not None and not (-180.0 <= lng <= 180.0):
lng = None
title = poiname or label or "位置"
return {
"renderType": "location",
"content": title or "[Location]",
"locationLat": lat,
"locationLng": lng,
"locationPoiname": poiname,
"locationLabel": label,
}
def _parse_system_message_content(raw_text: str) -> str:
text = str(raw_text or "").strip()
if not text:
@@ -941,11 +1003,40 @@ def _parse_quote_message(text: str) -> str:
def _parse_app_message(text: str) -> dict[str, Any]:
app_type_raw = _extract_xml_tag_text(text, "type")
try:
app_type = int(str(app_type_raw or "0").strip() or "0")
except Exception:
app_type = 0
def _extract_appmsg_type(xml_text: str) -> int:
"""提取 <appmsg> 直系子节点的 <type>,避免被 refermsg/recorditem/weappinfo 等嵌套块里的 <type> 干扰。"""
probe = str(xml_text or "")
try:
m = re.search(r"<appmsg\b[^>]*>(.*?)</appmsg>", probe, flags=re.IGNORECASE | re.DOTALL)
except Exception:
m = None
if m:
inner = str(m.group(1) or "")
# 一些嵌套块内部也会出现 <type>,先剔除再提取。
try:
inner = re.sub(r"(<refermsg\b[^>]*>.*?</refermsg>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
inner = re.sub(r"(<patmsg\b[^>]*>.*?</patmsg>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
inner = re.sub(r"(<recorditem\b[^>]*>.*?</recorditem>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
inner = re.sub(r"(<weappinfo\b[^>]*>.*?</weappinfo>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
inner = re.sub(r"(<wxaappinfo\b[^>]*>.*?</wxaappinfo>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
except Exception:
pass
t = _extract_xml_tag_text(inner, "type")
try:
return int(str(t or "0").strip() or "0")
except Exception:
return 0
t = _extract_xml_tag_text(probe, "type")
try:
return int(str(t or "0").strip() or "0")
except Exception:
return 0
app_type = _extract_appmsg_type(text)
title = _extract_xml_tag_text(text, "title")
des = _extract_xml_tag_text(text, "des")
url = _normalize_xml_url(_extract_xml_tag_text(text, "url"))
@@ -1006,6 +1097,49 @@ def _parse_app_message(text: str) -> dict[str, Any]:
"linkStyle": link_style,
}
if app_type in (33, 36):
# 小程序分享(WeChat v4 常见:local_type = 49 + (33<<32) / 49 + (36<<32)
# 注:部分 payload 的 <url> 为空;前端会按需渲染为不可点击卡片。
weapp_block = _extract_xml_tag_text(text, "weappinfo") or _extract_xml_tag_text(text, "wxaappinfo")
weapp_username = _extract_xml_tag_text(weapp_block, "username") if weapp_block else ""
weapp_icon = _normalize_xml_url(
_extract_xml_tag_or_attr(weapp_block, "weappiconurl") if weapp_block else ""
) or _normalize_xml_url(_extract_xml_tag_or_attr(text, "weappiconurl"))
thumb_url = _normalize_xml_url(
_extract_xml_tag_or_attr(text, "thumburl")
or _extract_xml_tag_or_attr(text, "cdnthumburl")
or _extract_xml_tag_or_attr(text, "coverurl")
or _extract_xml_tag_or_attr(text, "cover")
or weapp_icon
)
from_display = str(source_display_name or "").strip()
if not from_display and weapp_block:
from_display = (
_extract_xml_tag_text(weapp_block, "nickname")
or _extract_xml_tag_text(weapp_block, "appname")
or ""
)
if not from_display:
from_display = str(_extract_xml_tag_text(text, "sourcename") or "").strip()
from_u = str(weapp_username or source_username or "").strip()
content_text = (des or title or "[Mini Program]").strip() or "[Mini Program]"
title_text = (title or des or "").strip()
return {
"renderType": "link",
"content": content_text,
"title": title_text or content_text,
"url": url or "",
"thumbUrl": thumb_url or "",
"from": from_display,
"fromUsername": from_u,
"linkType": "mini_program",
"linkStyle": "default",
}
if app_type in (6, 74):
file_name = title or ""
total_len = _extract_xml_tag_text(text, "totallen")
@@ -1303,6 +1437,14 @@ def _build_latest_message_preview(
content_text = "[视频]"
elif local_type == 47:
content_text = "[动画表情]"
elif local_type == 48:
parsed = _parse_location_message(raw_text)
location_name = (
str(parsed.get("locationPoiname") or "").strip()
or str(parsed.get("locationLabel") or "").strip()
or str(parsed.get("content") or "").strip()
)
content_text = f"[位置]{location_name}" if location_name else "[位置]"
else:
if raw_text and (not raw_text.startswith("<")) and (not raw_text.startswith('"<')):
content_text = raw_text
@@ -1347,6 +1489,7 @@ def _normalize_session_preview_text(
return ""
text = text.replace("[表情]", "[动画表情]")
text = re.sub(r"\[location\]", "[位置]", text, flags=re.IGNORECASE)
if (not is_group) or text.startswith("[草稿]"):
return text
@@ -2021,6 +2164,10 @@ def _row_to_search_hit(
pay_sub_type = ""
transfer_status = ""
voip_type = ""
location_lat: Optional[float] = None
location_lng: Optional[float] = None
location_poiname = ""
location_label = ""
if local_type == 10000:
render_type = "system"
@@ -2075,6 +2222,14 @@ def _row_to_search_hit(
elif local_type == 47:
render_type = "emoji"
content_text = "[表情]"
elif local_type == 48:
parsed = _parse_location_message(raw_text)
render_type = str(parsed.get("renderType") or "location")
content_text = str(parsed.get("content") or "[Location]")
location_lat = parsed.get("locationLat")
location_lng = parsed.get("locationLng")
location_poiname = str(parsed.get("locationPoiname") or "")
location_label = str(parsed.get("locationLabel") or "")
elif local_type == 50:
render_type = "voip"
try:
@@ -2162,4 +2317,8 @@ def _row_to_search_hit(
"paySubType": pay_sub_type,
"transferStatus": transfer_status,
"voipType": voip_type,
"locationLat": location_lat,
"locationLng": location_lng,
"locationPoiname": location_poiname,
"locationLabel": location_label,
}
+60 -1
View File
@@ -50,6 +50,7 @@ from ..chat_helpers import (
_lookup_resource_md5,
_normalize_xml_url,
_parse_app_message,
_parse_location_message,
_parse_system_message_content,
_parse_pat_message,
_pick_display_name,
@@ -2673,6 +2674,10 @@ def _append_full_messages_from_rows(
file_md5 = ""
transfer_id = ""
voip_type = ""
location_lat: Optional[float] = None
location_lng: Optional[float] = None
location_poiname = ""
location_label = ""
if local_type == 10000:
render_type = "system"
@@ -2883,6 +2888,14 @@ def _append_full_messages_from_rows(
create_time=create_time,
)
content_text = "[表情]"
elif local_type == 48:
parsed = _parse_location_message(raw_text)
render_type = str(parsed.get("renderType") or "location")
content_text = str(parsed.get("content") or "[Location]")
location_lat = parsed.get("locationLat")
location_lng = parsed.get("locationLng")
location_poiname = str(parsed.get("locationPoiname") or "")
location_label = str(parsed.get("locationLabel") or "")
elif local_type == 50:
render_type = "voip"
try:
@@ -2929,10 +2942,15 @@ def _append_full_messages_from_rows(
cover_url = str(parsed.get("coverUrl") or cover_url)
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
from_name = str(parsed.get("from") or from_name)
from_username = str(parsed.get("fromUsername") or from_username)
file_size = str(parsed.get("size") or file_size)
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
file_md5 = str(parsed.get("fileMd5") or file_md5)
transfer_id = str(parsed.get("transferId") or transfer_id)
quote_username = str(parsed.get("quoteUsername") or quote_username)
quote_server_id = str(parsed.get("quoteServerId") or quote_server_id)
quote_type = str(parsed.get("quoteType") or quote_type)
quote_voice_length = str(parsed.get("quoteVoiceLength") or quote_voice_length)
if render_type == "transfer":
# 如果 transferId 仍为空,尝试从原始 XML 提取
@@ -3009,6 +3027,10 @@ def _append_full_messages_from_rows(
"paySubType": pay_sub_type,
"transferStatus": transfer_status,
"transferId": transfer_id,
"locationLat": location_lat,
"locationLng": location_lng,
"locationPoiname": location_poiname,
"locationLabel": location_label,
"_rawText": raw_text if local_type == 266287972401 else "",
}
)
@@ -3734,8 +3756,19 @@ def list_chat_sessions(
except Exception:
last_previews = {}
def _is_generic_location_preview(value: Any) -> bool:
text = re.sub(r"\s+", " ", str(value or "").strip()).strip()
if not text:
return False
lowered = text.lower()
return lowered in {"[location]", "[位置]"} or lowered.endswith(": [location]") or lowered.endswith(": [位置]")
if preview_mode in {"latest", "db"}:
targets = usernames if preview_mode == "db" else [u for u in usernames if u and (u not in last_previews)]
targets = (
usernames
if preview_mode == "db"
else [u for u in usernames if u and ((u not in last_previews) or _is_generic_location_preview(last_previews.get(u)))]
)
if targets:
legacy = _load_latest_message_previews(account_dir, targets)
for u, v in legacy.items():
@@ -3830,6 +3863,11 @@ def list_chat_sessions(
last_msg_sub_type = 0
if last_msg_type == 81604378673 or (last_msg_type == 49 and last_msg_sub_type == 19):
last_message = "[聊天记录]"
elif last_msg_type == 48:
text = re.sub(r"\s+", " ", str(last_message or "").strip()).strip()
text = re.sub(r"^\[location\]", "", text, flags=re.IGNORECASE).strip()
text = re.sub(r"^\[位置\]", "", text).strip()
last_message = f"[位置]{text}" if text else "[位置]"
last_message = _normalize_session_preview_text(
last_message,
@@ -4065,6 +4103,10 @@ def _collect_chat_messages(
file_md5 = ""
transfer_id = ""
voip_type = ""
location_lat: Optional[float] = None
location_lng: Optional[float] = None
location_poiname = ""
location_label = ""
if local_type == 10000:
render_type = "system"
@@ -4251,6 +4293,14 @@ def _collect_chat_messages(
create_time=create_time,
)
content_text = "[表情]"
elif local_type == 48:
parsed = _parse_location_message(raw_text)
render_type = str(parsed.get("renderType") or "location")
content_text = str(parsed.get("content") or "[Location]")
location_lat = parsed.get("locationLat")
location_lng = parsed.get("locationLng")
location_poiname = str(parsed.get("locationPoiname") or "")
location_label = str(parsed.get("locationLabel") or "")
elif local_type == 50:
render_type = "voip"
try:
@@ -4289,6 +4339,7 @@ def _collect_chat_messages(
title = str(parsed.get("title") or title)
url = str(parsed.get("url") or url)
from_name = str(parsed.get("from") or from_name)
from_username = str(parsed.get("fromUsername") or from_username)
record_item = str(parsed.get("recordItem") or record_item)
quote_title = str(parsed.get("quoteTitle") or quote_title)
quote_content = str(parsed.get("quoteContent") or quote_content)
@@ -4302,6 +4353,10 @@ def _collect_chat_messages(
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
file_md5 = str(parsed.get("fileMd5") or file_md5)
transfer_id = str(parsed.get("transferId") or transfer_id)
quote_username = str(parsed.get("quoteUsername") or quote_username)
quote_server_id = str(parsed.get("quoteServerId") or quote_server_id)
quote_type = str(parsed.get("quoteType") or quote_type)
quote_voice_length = str(parsed.get("quoteVoiceLength") or quote_voice_length)
if render_type == "transfer":
# 如果 transferId 仍为空,尝试从原始 XML 提取
@@ -4385,6 +4440,10 @@ def _collect_chat_messages(
"paySubType": pay_sub_type,
"transferStatus": transfer_status,
"transferId": transfer_id,
"locationLat": location_lat,
"locationLng": location_lng,
"locationPoiname": location_poiname,
"locationLabel": location_label,
"_rawText": raw_text if local_type == 266287972401 else "",
}
)
@@ -132,6 +132,16 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA[“测试好友”撤回了一条消息]]></replacemsg></revokemsg></sysmsg>',
None,
),
(
7,
1007,
48,
7,
2,
1735689607,
'<msg><location x="39.9042" y="116.4074" scale="15" label="北京市东城区东华门街道" poiname="天安门" /></msg>',
None,
),
]
conn.executemany(
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
@@ -357,6 +367,41 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
def test_checked_location_exports_location_fields(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
self._prepare_account(root, account=account, username=username)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
svc = self._reload_export_modules()
job = self._create_job(
svc.CHAT_EXPORT_MANAGER,
account=account,
username=username,
message_types=["location"],
include_media=False,
)
self.assertEqual(job.status, "done", msg=job.error)
payload, manifest, _ = self._load_export_payload(job.zip_path)
location_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 48), None)
self.assertIsNotNone(location_msg)
self.assertEqual(str(location_msg.get("renderType") or ""), "location")
self.assertEqual(str(location_msg.get("locationPoiname") or ""), "天安门")
self.assertEqual(str(location_msg.get("locationLabel") or ""), "北京市东城区东华门街道")
self.assertAlmostEqual(float(location_msg.get("locationLat") or 0), 39.9042, places=4)
self.assertAlmostEqual(float(location_msg.get("locationLng") or 0), 116.4074, places=4)
self.assertEqual(manifest.get("filters", {}).get("messageTypes"), ["location"])
finally:
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
def test_privacy_mode_never_exports_media(self):
with TemporaryDirectory() as td:
root = Path(td)
+28
View File
@@ -10,6 +10,34 @@ from wechat_decrypt_tool.chat_helpers import _parse_app_message
class TestParseAppMessage(unittest.TestCase):
def test_mini_program_type_33_parses_as_link(self):
# 小程序分享是 appmsg type=33/36。部分 payload 会在 <weappinfo> 内嵌一个 <type>0</type>
# 并且出现在外层 <type>33</type> 之前,因此解析必须避免被嵌套 <type> 误导。
raw_text = (
"<msg><appmsg appid='' sdkver='0'>"
"<title>锦城苑房源详情分享给你,点击查看哦~</title>"
"<des></des>"
"<weappinfo>"
"<type>0</type>"
"<username><![CDATA[gh_xxx@app]]></username>"
"<weappiconurl><![CDATA[https://example.com/icon.png]]></weappiconurl>"
"</weappinfo>"
"<type>33</type>"
"<url></url>"
"<thumburl>https://example.com/thumb.jpg</thumburl>"
"<sourcedisplayname><![CDATA[成都购房通]]></sourcedisplayname>"
"</appmsg></msg>"
)
parsed = _parse_app_message(raw_text)
self.assertEqual(parsed.get("renderType"), "link")
self.assertEqual(parsed.get("linkType"), "mini_program")
self.assertEqual(parsed.get("title"), "锦城苑房源详情分享给你,点击查看哦~")
self.assertEqual(parsed.get("from"), "成都购房通")
self.assertEqual(parsed.get("fromUsername"), "gh_xxx@app")
self.assertEqual(parsed.get("thumbUrl"), "https://example.com/thumb.jpg")
def test_quote_type_57_nested_refermsg_uses_inner_title(self):
raw_text = (
'<msg><appmsg appid="" sdkver="0">'