feat(biz): implement parse biz data

This commit is contained in:
H3CoF6
2026-03-28 04:16:24 +08:00
Unverified
parent a94ab83668
commit c29b425791
6 changed files with 643 additions and 26 deletions
+265
View File
@@ -0,0 +1,265 @@
<template>
<div class="biz-page h-screen flex overflow-hidden" style="background-color: var(--app-shell-bg)">
<div class="w-[300px] lg:w-[320px] bg-white border-r border-gray-200 flex flex-col flex-shrink-0 z-10">
<div class="p-3 border-b border-gray-200" style="background-color: var(--app-surface-muted)">
<div class="contact-search-wrapper flex-1">
<svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 14L11.1 11.1" />
</svg>
<input
v-model="searchQuery"
type="text"
class="contact-search-input"
placeholder="搜索服务号"
/>
</div>
</div>
<div class="flex-1 overflow-y-auto min-h-0">
<div v-if="loadingAccounts" class="flex justify-center py-4">
<span class="text-sm text-gray-400">加载中...</span>
</div>
<div v-else>
<div
v-for="item in filteredAccounts"
:key="item.username"
@click="selectAccount(item)"
class="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors"
:class="{ 'bg-[#F2F2F2]': selectedAccount?.username === item.username }"
>
<img
:src="item.avatar || defaultAvatar"
class="w-10 h-10 rounded-md object-cover bg-gray-200 flex-shrink-0"
@error="(e) => e.target.src = defaultAvatar"
alt=""/>
<div class="flex-1 min-w-0">
<h3 class="text-sm text-gray-900 truncate">{{ item.name || item.username }}</h3>
</div>
<div v-if="item.username === 'gh_3dfda90e39d6'" class="text-xs text-[#03C160] bg-[#03C160]/10 px-1.5 py-0.5 rounded">
官方
</div>
</div>
</div>
</div>
</div>
<div class="flex-1 flex flex-col min-h-0 min-w-0 bg-[#F5F5F5]">
<div v-if="selectedAccount" class="flex-1 flex flex-col min-h-0 relative">
<div class="h-14 border-b border-gray-200 bg-[#F5F5F5] flex items-center px-5 shrink-0 z-10">
<h2 class="text-base text-gray-900">{{ selectedAccount.name }}</h2>
</div>
<div
class="flex-1 overflow-y-auto px-4 py-6 flex flex-col-reverse"
@scroll="handleScroll"
ref="messageListRef"
>
<div v-if="!hasMore" class="text-center text-xs text-gray-400 py-4 w-full">没有更多消息了</div>
<div v-if="loadingMessages" class="text-center text-xs text-gray-400 py-4 w-full">正在加载...</div>
<div class="w-full max-w-[400px] mx-auto flex flex-col-reverse gap-6">
<div v-for="msg in messages" :key="msg.local_id" class="w-full">
<div v-if="selectedAccount.username === 'gh_3dfda90e39d6'" class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center text-gray-500 text-sm mb-5">
<img v-if="msg.merchant_icon" :src="msg.merchant_icon" class="w-6 h-6 rounded-full mr-2 object-cover" alt=""/>
<div v-else class="w-6 h-6 rounded-full mr-2 bg-green-100 flex items-center justify-center text-green-600">¥</div>
<span>{{ msg.merchant_name || '微信支付' }}</span>
</div>
<div class="text-center mb-6">
<h3 class="text-[22px] font-medium text-gray-900 mb-1">{{ msg.title }}</h3>
</div>
<div class="text-[13px] text-gray-500 whitespace-pre-wrap leading-relaxed">
{{ msg.description }}
</div>
<div class="mt-4 pt-3 border-t border-gray-100 text-[12px] text-gray-400 text-right">
{{ msg.formatted_time }}
</div>
</div>
<div v-else class="bg-white rounded-xl shadow-sm overflow-hidden border border-gray-100">
<a :href="msg.url" target="_blank" class="block relative group cursor-pointer">
<img :src="msg.cover || defaultImage" class="w-full h-[180px] object-cover bg-gray-100" alt=""/>
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3 pt-8">
<h3 class="text-white text-[15px] font-medium leading-snug line-clamp-2 group-hover:underline">
{{ msg.title }}
</h3>
</div>
</a>
<div v-if="msg.des" class="px-4 py-3 text-[13px] text-gray-500 border-b border-gray-50">
{{ msg.des }}
</div>
<div v-if="msg.content_list && msg.content_list.length > 0" class="flex flex-col">
<a
v-for="(item, idx) in msg.content_list"
:key="idx"
:href="item.url"
target="_blank"
class="flex items-center justify-between p-3 border-t border-gray-100 hover:bg-gray-50 cursor-pointer group"
>
<span class="text-[14px] text-gray-800 leading-snug line-clamp-2 pr-3 group-hover:underline">
{{ item.title }}
</span>
<img :src="item.cover" class="w-12 h-12 rounded object-cover flex-shrink-0 bg-gray-100 border border-gray-100" alt=""/>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="w-20 h-20 mx-auto mb-5 rounded-2xl bg-gray-200/50 flex items-center justify-center">
<svg class="w-10 h-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5L18.5 7H20" />
</svg>
</div>
<p class="text-sm text-gray-400">请选择一个服务号查看消息</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
// 状态
const accounts = ref([])
const loadingAccounts = ref(false)
const searchQuery = ref('')
const selectedAccount = ref(null)
const messages = ref([])
const loadingMessages = ref(false)
const offset = ref(0)
const limit = 20
const hasMore = ref(true)
const messageListRef = ref(null)
// 默认占位图
const defaultAvatar = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiNlNWU3ZWIiLz48L3N2Zz4='
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg=='
// 获取账号列表,并将微信支付置顶
const fetchAccounts = async () => {
loadingAccounts.value = true
try {
const res = await api.listBizAccounts()
if (res && res.data) {
// 提取微信支付
const payAccount = res.data.find(a => a.username === 'gh_3dfda90e39d6')
const otherAccounts = res.data.filter(a => a.username !== 'gh_3dfda90e39d6')
const sortedList = []
if (payAccount) {
// 如果后端没有返回名字,可以手动补齐
payAccount.name = '微信支付'
sortedList.push(payAccount)
} else {
// 如果后端列表里没有,但你想强行显示,也可以造一个假的
sortedList.push({ username: 'gh_3dfda90e39d6', name: '微信支付', avatar: '' })
}
accounts.value = [...sortedList, ...otherAccounts]
}
} catch (err) {
console.error('获取服务号失败:', err)
} finally {
loadingAccounts.value = false
}
}
// 搜索过滤
const filteredAccounts = computed(() => {
if (!searchQuery.value) return accounts.value
const q = searchQuery.value.toLowerCase()
return accounts.value.filter(a =>
(a.name && a.name.toLowerCase().includes(q)) ||
(a.username && a.username.toLowerCase().includes(q))
)
})
// 点击选择服务号
const selectAccount = (account) => {
if (selectedAccount.value?.username === account.username) return
selectedAccount.value = account
// 重置消息状态
messages.value = []
offset.value = 0
hasMore.value = true
loadMessages()
}
// 加载消息
const loadMessages = async () => {
if (loadingMessages.value || !hasMore.value || !selectedAccount.value) return
loadingMessages.value = true
try {
const username = selectedAccount.value.username
const params = { username, offset: offset.value, limit }
let res
if (username === 'gh_3dfda90e39d6') {
res = await api.listBizPayRecords(params)
} else {
res = await api.listBizMessages(params)
}
if (res && res.data) {
if (res.data.length < limit) {
hasMore.value = false
}
// 追加数据
messages.value.push(...res.data)
offset.value += limit
}
} catch (err) {
console.error('加载消息失败:', err)
} finally {
loadingMessages.value = false
}
}
// 向上滚动加载逻辑
// 因为容器设置了 flex-col-reverse,所以 scrollTop 越靠近负值(或0取决于浏览器)越是到了历史消息端
// 但比较通用兼容的做法是监听 scroll,距离顶部或底部小于阈值时触发
const handleScroll = (e) => {
const target = e.target
// 针对 flex-col-reverse: 滚动到底部实际上是视觉上的最上方(历史消息)
// 当 scrollHeight - Math.abs(scrollTop) - clientHeight < 50 时加载
if (target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight < 50) {
loadMessages()
}
}
onMounted(() => {
fetchAccounts()
})
</script>
<style scoped>
/* 隐藏滚动条但允许滚动(可选) */
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: rgba(0,0,0,0.1);
border-radius: 10px;
}
</style>
+24 -26
View File
@@ -101,6 +101,21 @@
</div>
</div>
<div
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="服务号"
@click="goBiz"
>
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isBizRoute }">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5L6 9H2v6h4l5 4V5z"></path>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
</div>
</div>
</div>
<!-- Wrapped -->
<div
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@@ -479,34 +494,17 @@ const isChatRoute = computed(() => route.path?.startsWith('/chat'))
const isEditsRoute = computed(() => route.path?.startsWith('/edits'))
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
const isBizRoute = computed(() => route.path?.startsWith('/biz')) // 新增
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
const goChat = async () => {
await navigateTo('/chat')
}
const goEdits = async () => {
await navigateTo('/edits')
}
const goSns = async () => {
await navigateTo('/sns')
}
const goContacts = async () => {
await navigateTo('/contacts')
}
const goWrapped = async () => {
await navigateTo('/wrapped')
}
const goGuide = async () => {
await navigateTo('/')
}
const goSettings = () => {
openSettingsDialog()
}
const goChat = async () => { await navigateTo('/chat') }
const goEdits = async () => { await navigateTo('/edits') }
const goSns = async () => { await navigateTo('/sns') }
const goContacts = async () => { await navigateTo('/contacts') }
const goBiz = async () => { await navigateTo('/biz') }
const goWrapped = async () => { await navigateTo('/wrapped') }
const goGuide = async () => { await navigateTo('/') }
const goSettings = () => { openSettingsDialog() }
const onWindowKeydown = (event) => {
if (event?.key !== 'Escape') return
+18
View File
@@ -561,6 +561,21 @@ export const useApi = () => {
return await request('/get_image_key')
}
// 枚举服务号信息
const listBizAccounts = async (params) => {
return await request('/biz/list', params)
}
// 获取普通服务号消息
const listBizMessages = async (params) => {
return await request('/biz/messages', params)
}
// 获取微信支付记录
const listBizPayRecords = async (params) => {
return await request('/biz/pay_records', params)
}
return {
detectWechat,
detectCurrentAccount,
@@ -616,5 +631,8 @@ export const useApi = () => {
getKeys,
getImageKey,
getWxStatus,
listBizAccounts,
listBizMessages,
listBizPayRecords,
}
}
+16
View File
@@ -0,0 +1,16 @@
<template>
<div class="h-screen flex overflow-hidden bg-white">
<div class="flex-1 min-w-0">
<BizMessages />
</div>
</div>
</template>
<script setup>
import BizMessages from "../components/BizMessages.vue";
useHead({
title: '服务号消息 - WeChatDataAnalysis'
})
</script>
+2
View File
@@ -36,6 +36,7 @@ from .routers.wrapped import router as _wrapped_router
from .request_logging import log_server_errors_middleware
from .sns_stage_timing import add_sns_stage_timing_headers
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
from .routers.biz import router as _biz_router
app = FastAPI(
title="微信数据库解密工具",
@@ -96,6 +97,7 @@ app.include_router(_chat_media_router)
app.include_router(_sns_router)
app.include_router(_sns_export_router)
app.include_router(_wrapped_router)
app.include_router(_biz_router)
class _SPAStaticFiles(StaticFiles):
+318
View File
@@ -0,0 +1,318 @@
import hashlib
import sqlite3
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional, Any, Dict, List
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..chat_helpers import _resolve_account_dir
from ..path_fix import PathFixRoute
from ..logging_config import get_logger
try:
import zstandard as zstd
except Exception:
zstd = None
logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
def decompress_zstd_content(data: bytes, source_id: str, local_id: int) -> Optional[bytes]:
"""Zstandard 解压逻辑"""
if not data or not data.startswith(b'\x28\xb5\x2f\xfd'):
return None
try:
if zstd:
dctx = zstd.ZstdDecompressor()
return dctx.decompress(data, max_output_size=10 * 1024 * 1024)
except Exception as e:
error_msg = f"❌ [解压失败] 服务号id: {source_id}, local_id: {local_id} -> {e}"
print(error_msg)
logger.error(error_msg)
return None
def extract_xml_from_db_content(content: Any, source_id: str, local_id: int) -> str:
"""提取并解压数据库内容"""
if not content:
return ""
if isinstance(content, memoryview):
content = content.tobytes()
elif isinstance(content, str):
content = content.encode('utf-8', errors='ignore')
if isinstance(content, bytes):
decompressed = decompress_zstd_content(content, source_id, local_id)
if decompressed:
return decompressed.decode('utf-8', errors='ignore')
# 若不是 zstd 压缩或解压失败,尝试直接 decode
try:
return content.decode('utf-8', errors='ignore')
except Exception:
return ""
return ""
def parse_wechat_xml_to_struct(xml_str: str, source_id: str, local_id: int) -> Optional[Dict[str, Any]]:
"""解析微信服务号 XML 到 Dict"""
if not xml_str.strip():
return None
try:
root = ET.fromstring(xml_str)
def get_tag_text(element, path, default=""):
node = element.find(path)
return node.text if node is not None and node.text else default
main_cover = get_tag_text(root, ".//appmsg/thumburl")
if not main_cover:
main_cover = get_tag_text(root, ".//topnew/cover")
result = {
"title": get_tag_text(root, ".//appmsg/title"),
"des": get_tag_text(root, ".//appmsg/des"),
"url": get_tag_text(root, ".//appmsg/url"),
"cover": main_cover,
"content_list": []
}
items = root.findall(".//mmreader/category/item")
for item in items:
item_struct = {
"title": get_tag_text(item, "title"),
"url": get_tag_text(item, "url"),
"cover": get_tag_text(item, "cover"),
"summary": get_tag_text(item, "summary")
}
if item_struct["title"]:
result["content_list"].append(item_struct)
return result
except Exception as e:
error_msg = f"❌ [解析XML失败] 服务号id: {source_id}, local_id: {local_id} -> {e}"
print(error_msg)
logger.error(error_msg)
return None
def parse_pay_xml(xml_str: str, local_id: int) -> Optional[Dict[str, Any]]:
"""解析微信支付 XML"""
if not xml_str.strip():
return None
try:
root = ET.fromstring(xml_str)
def get_text(path):
node = root.find(path)
return node.text if node is not None else ""
record = {
"title": get_text(".//appmsg/title"),
"description": get_text(".//appmsg/des"),
"merchant_name": get_text(".//template_header/display_name"),
"merchant_icon": get_text(".//template_header/icon_url"),
"timestamp": int(get_text(".//pub_time") or 0),
"formatted_time": ""
}
return record
except Exception as e:
error_msg = f"❌ [解析微信支付XML失败] 支付id: gh_3dfda90e39d6, local_id: {local_id} -> {e}"
print(error_msg)
logger.error(error_msg)
return None
# 接口 1:获取全部的服务号/公众号的信息
@router.get("/api/biz/list", summary="获取全部服务号/公众号列表")
def get_biz_account_list(account: Optional[str] = None):
account_dir = _resolve_account_dir(account)
# 1. 遍历 biz_message_*.db,从 Name2Id 拿到所有 id
biz_ids = set()
for db_file in account_dir.glob("biz_message*.db"):
try:
conn = sqlite3.connect(str(db_file))
cursor = conn.cursor()
# 不同微信版本的主键可能是 user_name 或 username
cursor.execute("PRAGMA table_info(Name2Id)")
cols = [row[1].lower() for row in cursor.fetchall()]
user_col = "username" if "username" in cols else "user_name" if "user_name" in cols else ""
if user_col:
rows = cursor.execute(f"SELECT {user_col} FROM Name2Id").fetchall()
for r in rows:
if r[0]:
biz_ids.add(r[0])
conn.close()
except Exception as e:
logger.warning(f"读取 Name2Id 失败 {db_file}: {e}")
# 2. 查询 contact.db 完善昵称和头像信息
contact_db_path = account_dir / "contact.db"
contact_info = {}
if contact_db_path.exists() and biz_ids:
try:
conn = sqlite3.connect(str(contact_db_path))
cursor = conn.cursor()
placeholders = ",".join(["?"] * len(biz_ids))
query = f"SELECT username, remark, nick_name, alias, big_head_url FROM contact WHERE username IN ({placeholders})"
rows = cursor.execute(query, list(biz_ids)).fetchall()
for r in rows:
uname = r[0]
remark = r[1]
nick_name = r[2]
alias = r[3]
head_url = r[4]
# 优先级: remark > nick_name > alias > id
name = remark or nick_name or alias or uname
contact_info[uname] = {
"username": uname,
"name": name,
"avatar": head_url
}
conn.close()
except Exception as e:
logger.warning(f"读取 contact.db 失败: {e}")
result = []
for uid in biz_ids:
if uid in contact_info:
result.append(contact_info[uid])
else:
result.append({"username": uid, "name": uid, "avatar": ""})
return {"status": "success", "total": len(result), "data": result}
# 接口 2:获取普通服务号/公众号的 json 消息
@router.get("/api/biz/messages", summary="获取指定服务号的消息")
def get_biz_messages(username: str, account: Optional[str] = None, limit: int = 50, offset: int = 0):
if username == "gh_3dfda90e39d6":
raise HTTPException(status_code=400, detail="微信支付记录请请求 /api/biz/pay_records 接口")
account_dir = _resolve_account_dir(account)
md5_id = hashlib.md5(username.encode('utf-8')).hexdigest().lower()
table_name = f"Msg_{md5_id}"
target_db = None
for db_file in account_dir.glob("biz_message*.db"):
conn = sqlite3.connect(str(db_file))
try:
res = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=?",
(table_name,)).fetchone()
if res:
target_db = db_file
break
except Exception:
pass
finally:
conn.close()
if not target_db:
return {"status": "success", "data": [], "message": f"未找到 {username} 的消息历史"}
messages = []
try:
conn = sqlite3.connect(str(target_db))
cursor = conn.cursor()
query = f"""
SELECT local_id, create_time, message_content
FROM [{table_name}]
WHERE local_type != 1
ORDER BY create_time DESC
LIMIT ? OFFSET ?
"""
rows = cursor.execute(query, (limit, offset)).fetchall()
for local_id, c_time, content in rows:
raw_xml = extract_xml_from_db_content(content, username, local_id)
if not raw_xml:
continue
struct_data = parse_wechat_xml_to_struct(raw_xml, username, local_id)
if struct_data:
struct_data["local_id"] = local_id
struct_data["create_time"] = c_time
messages.append(struct_data)
conn.close()
except Exception as e:
logger.error(f"[biz] 数据库查询出错: {e}")
return {"status": "error", "message": str(e)}
return {"status": "success", "data": messages}
# 接口 3:返回微信支付的 json 消息
@router.get("/api/biz/pay_records", summary="获取微信支付记录")
def get_wechat_pay_records(account: Optional[str] = None, limit: int = 50, offset: int = 0):
username = "gh_3dfda90e39d6"
account_dir = _resolve_account_dir(account)
md5_id = hashlib.md5(username.encode('utf-8')).hexdigest().lower()
table_name = f"Msg_{md5_id}"
target_db = None
for db_file in account_dir.glob("biz_message*.db"):
conn = sqlite3.connect(str(db_file))
try:
res = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=?",
(table_name,)).fetchone()
if res:
target_db = db_file
break
except Exception:
pass
finally:
conn.close()
if not target_db:
return {"status": "success", "data": [], "message": "未找到微信支付的消息历史"}
messages = []
try:
conn = sqlite3.connect(str(target_db))
cursor = conn.cursor()
query = f"""
SELECT local_id, create_time, message_content
FROM [{table_name}]
WHERE local_type = 21474836529 OR local_type != 1
ORDER BY create_time DESC
LIMIT ? OFFSET ?
"""
rows = cursor.execute(query, (limit, offset)).fetchall()
for local_id, c_time, content in rows:
raw_xml = extract_xml_from_db_content(content, username, local_id)
if not raw_xml:
continue
parsed_data = parse_pay_xml(raw_xml, local_id)
if parsed_data:
parsed_data["local_id"] = local_id
parsed_data["create_time"] = c_time
if not parsed_data["timestamp"]:
parsed_data["timestamp"] = c_time
parsed_data["formatted_time"] = time.strftime(
"%Y-%m-%d %H:%M:%S", time.localtime(parsed_data["timestamp"])
)
messages.append(parsed_data)
conn.close()
except Exception as e:
logger.error(f"[biz] 查询微信支付数据库出错: {e}")
return {"status": "error", "message": str(e)}
return {"status": "success", "data": messages}