Compare commits

..

5 Commits

  • improvement(chat): 会话列表可拖拽调宽并优化 realtime 关闭同步
    - 中间栏新增拖拽调宽/双击重置;宽度按物理 px 持久化(兼容旧 key,并按 dpr 换算)
    
    - 关闭 realtime 前触发 syncChatRealtimeMessages(max_scan=5000),避免回退到过期解密快照
    
    - 按 dpr 调整联系人/消息头像与 skeleton 尺寸
  • improvement(chat): WCDB 回退补全昵称/头像
    - contact.db 缺失(企业/开放平台/openim/群等)时,回退 WCDB realtime 查询 displayName/avatarUrl
    
    - 覆盖消息/会话:senderDisplayName/senderAvatar、link card from、quoteTitle、会话列表 name/avatar
    
    - realtime 场景尽量复用已建立的 WCDB 连接;best-effort,失败不影响主流程
  • feat(sns): 前端新增朋友圈页面并接入候选匹配
    - 新增 /sns 页面:时间线列表、账号切换、隐私模式、复制动态 JSON
    
    - 图片预览支持候选匹配切换并保存(localStorage + /api/sns/media_picks)
    
    - 聊天页侧边栏增加头像/朋友圈入口,隐私模式开关持久化(chat/sns 共用)
    
    - app.vue 增加 --dpr 与 sidebar rail CSS 变量,并在 resize 时刷新
    
    - useApi 补充 sns 相关接口封装
  • feat(sns): 增加朋友圈时间线与图片本地缓存接口
    - 新增 /api/sns/timeline:优先走 WCDB realtime 读取 sns.db,支持分页/用户过滤/关键字
    
    - 新增 /api/sns/media:本地缓存(cache/.../Sns/Img)解密优先,支持手动 pick/避开重复
    
    - 新增 /api/sns/media_candidates 与 /api/sns/media_picks:候选 key 列表与本机持久化匹配表
    
    - wcdb_realtime 增加 exec_query/get_sns_timeline 封装,并在连接时 set_my_wxid 上下文
    
    - 更新 wcdb_api.dll 并补齐 MSVC runtime 依赖
  • fix(chat): proxy_image 兼容 tc.qq.com 并增强防盗链 Referer
    - proxy_image 放开 .tc.qq.com 白名单,兼容朋友圈/CDN 图片
    
    - 下载时按多组 Referer/Origin 轮询,提高成功率
    
    - 保持 host 校验与 10MB 限制
14 changed files with 2955 additions and 69 deletions
+20 -1
View File
@@ -14,12 +14,23 @@
// So we detect desktop onMounted and update reactively.
const isDesktop = ref(false)
const updateDprVar = () => {
const dpr = window.devicePixelRatio || 1
document.documentElement.style.setProperty('--dpr', String(dpr))
}
onMounted(() => {
isDesktop.value = !!window?.wechatDesktop
updateDprVar()
window.addEventListener('resize', updateDprVar)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateDprVar)
})
const route = useRoute()
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
const isChatRoute = computed(() => route.path?.startsWith('/chat') || route.path?.startsWith('/sns'))
const rootClass = computed(() => {
const base = 'bg-gradient-to-br from-green-50 via-emerald-50 to-green-100'
@@ -34,6 +45,14 @@ const contentClass = computed(() =>
</script>
<style>
:root {
--dpr: 1;
/* Left sidebar rail (chat/sns): icon size + spacing */
--sidebar-rail-step: 48px;
--sidebar-rail-btn: 32px;
--sidebar-rail-icon: 24px;
}
/* Electron 桌面端使用自绘标题栏(frame: false)。
* 页面里如果继续用 Tailwind 的 h-screen/min-h-screen100vh),会把标题栏高度叠加进去,从而出现外层滚动条。
* 这里把 “screen” 在桌面端视为内容区高度(100%),让标题栏高度自然内嵌在布局里。 */
+43
View File
@@ -179,6 +179,46 @@ export const useApi = () => {
return await request(url)
}
// 朋友圈时间线
const listSnsTimeline = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.limit != null) query.set('limit', String(params.limit))
if (params && params.offset != null) query.set('offset', String(params.offset))
if (params && params.usernames && Array.isArray(params.usernames) && params.usernames.length > 0) {
query.set('usernames', params.usernames.join(','))
} else if (params && params.usernames && typeof params.usernames === 'string') {
query.set('usernames', params.usernames)
}
if (params && params.keyword) query.set('keyword', params.keyword)
const url = '/sns/timeline' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 朋友圈图片本地缓存候选(用于错图时手动选择)
const listSnsMediaCandidates = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.create_time != null) query.set('create_time', String(params.create_time))
if (params && params.width != null) query.set('width', String(params.width))
if (params && params.height != null) query.set('height', String(params.height))
if (params && params.limit != null) query.set('limit', String(params.limit))
if (params && params.offset != null) query.set('offset', String(params.offset))
const url = '/sns/media_candidates' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 保存朋友圈图片手动匹配结果(本机)
const saveSnsMediaPicks = async (data = {}) => {
return await request('/sns/media_picks', {
method: 'POST',
body: {
account: data.account || null,
picks: (data && data.picks && typeof data.picks === 'object' && !Array.isArray(data.picks)) ? data.picks : {}
}
})
}
const openChatMediaFolder = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
@@ -288,6 +328,9 @@ export const useApi = () => {
buildChatSearchIndex,
listChatSearchSenders,
getChatMessagesAround,
listSnsTimeline,
listSnsMediaCandidates,
saveSnsMediaPicks,
openChatMediaFolder,
downloadChatEmoji,
saveMediaKeys,
+295 -30
View File
@@ -1,52 +1,116 @@
<template>
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
<!-- 左侧边栏 -->
<div class="w-16 border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7">
<div class="flex-1 flex flex-col justify-start pt-0">
<div class="border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7; width: 60px; min-width: 60px; max-width: 60px">
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
<!-- 头像类似微信侧边栏 -->
<div class="w-full h-[60px] flex items-center justify-center">
<div class="w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
<div
v-else
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
style="background-color: #4B5563"
>
</div>
</div>
</div>
<!-- 聊天图标 ( oh-my-wechat 一致) -->
<div class="w-16 h-16 flex items-center justify-center chat-tab selected text-[#07b75b]">
<div class="w-7 h-7">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z"/>
</svg>
<div class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group">
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md bg-transparent group-hover:bg-[#E1E1E1] flex items-center justify-center transition-colors">
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#07b75b]">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z" />
</svg>
</div>
</div>
</div>
<!-- 朋友圈图标Aperture 风格 -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="朋友圈"
@click="goSns"
>
<div
class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]"
>
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isSnsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
<svg
class="w-full h-full"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="14.31" y1="8" x2="20.05" y2="17.94" />
<line x1="9.69" y1="8" x2="21.17" y2="8" />
<line x1="7.38" y1="12" x2="13.12" y2="2.06" />
<line x1="9.69" y1="16" x2="3.95" y2="6.06" />
<line x1="14.31" y1="16" x2="2.83" y2="16" />
<line x1="16.62" y1="12" x2="10.88" y2="21.94" />
</svg>
</div>
</div>
</div>
<!-- 隐私模式按钮 -->
<div
class="w-16 h-12 flex items-center justify-center cursor-pointer transition-colors"
:class="privacyMode ? 'text-[#03C160]' : 'text-gray-500 hover:text-gray-700'"
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@click="privacyMode = !privacyMode"
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
>
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path v-if="privacyMode" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
<path v-else stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
</svg>
<div
class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]"
>
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="privacyMode ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path v-if="privacyMode" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
<path v-else stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
</svg>
</div>
</div>
<!-- 设置按钮仅桌面端 -->
<div
v-if="isDesktopEnv"
class="w-16 h-12 flex items-center justify-center cursor-pointer transition-colors text-gray-500 hover:text-gray-700"
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@click="openDesktopSettings"
title="设置"
>
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#5d5d5d]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
</div>
</div>
</div>
<!-- 中间列表区域 -->
<div class="w-80 border-r border-gray-200 flex flex-col min-h-0" style="background-color: #F7F7F7">
<div
class="session-list-panel border-r border-gray-200 flex flex-col min-h-0 shrink-0 relative"
:style="{ backgroundColor: '#F7F7F7', '--session-list-width': sessionListWidth + 'px' }"
>
<!-- 拖动调整会话列表宽度 -->
<div
class="session-list-resizer"
:class="{ 'session-list-resizer-active': sessionListResizing }"
title="拖动调整会话列表宽度"
@pointerdown="onSessionListResizerPointerDown"
@dblclick="resetSessionListWidth"
/>
<!-- 聊天列表 -->
<div class="h-full flex flex-col min-h-0">
<!-- 搜索栏 -->
@@ -90,8 +154,8 @@
<!-- 联系人列表 -->
<div class="flex-1 overflow-y-auto min-h-0">
<div v-if="isLoadingContacts" class="px-3 py-4 h-full overflow-hidden">
<div v-for="i in 15" :key="i" class="flex items-center space-x-3 py-2">
<div class="w-10 h-10 rounded-md bg-gray-200 skeleton-pulse"></div>
<div v-for="i in 15" :key="i" class="flex items-center space-x-3 h-[calc(85px/var(--dpr))]">
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md bg-gray-200 skeleton-pulse"></div>
<div class="flex-1 space-y-2">
<div class="h-3.5 bg-gray-200 rounded skeleton-pulse" :style="{ width: (60 + (i % 4) * 15) + 'px' }"></div>
<div class="h-3 bg-gray-200 rounded skeleton-pulse" :style="{ width: (80 + (i % 3) * 20) + 'px' }"></div>
@@ -106,12 +170,12 @@
</div>
<template v-else>
<div v-for="contact in filteredContacts" :key="contact.id"
class="px-3 py-2 cursor-pointer transition-colors duration-150 border-b border-gray-100"
class="px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 h-[calc(85px/var(--dpr))] flex items-center"
:class="selectedContact?.id === contact.id ? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]' : 'hover:bg-[#eaeaea]'"
@click="selectContact(contact)">
<div class="flex items-center space-x-3">
<div class="flex items-center space-x-3 w-full">
<!-- 联系人头像 -->
<div class="w-10 h-10 rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<div v-if="contact.avatar" class="w-full h-full">
<img :src="contact.avatar" :alt="contact.name" class="w-full h-full object-cover">
</div>
@@ -287,7 +351,7 @@
<div v-else class="flex items-center" :class="message.isSent ? 'justify-end' : 'justify-start'">
<div class="flex items-start max-w-md" :class="message.isSent ? 'flex-row-reverse' : ''">
<!-- 消息发送者头像 -->
<div class="w-[36px] h-[36px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]">
<div class="w-[calc(42px/var(--dpr))] h-[calc(42px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]">
<div v-if="message.avatar" class="w-full h-full">
<img
:src="message.avatar"
@@ -1714,6 +1778,7 @@ useHead({
})
const route = useRoute()
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
const routeUsername = computed(() => {
const raw = route.params.username
@@ -1729,6 +1794,147 @@ const selectedContact = ref(null)
// 隐私模式
const privacyMode = ref(false)
const PRIVACY_MODE_KEY = 'ui.privacy_mode'
onMounted(() => {
if (!process.client) return
try {
privacyMode.value = localStorage.getItem(PRIVACY_MODE_KEY) === '1'
} catch {}
})
watch(
() => privacyMode.value,
(v) => {
if (!process.client) return
try {
localStorage.setItem(PRIVACY_MODE_KEY, v ? '1' : '0')
} catch {}
}
)
// 会话列表(中间栏)宽度(按物理像素 px 配置):默认 295px,支持拖动调整并持久化
const SESSION_LIST_WIDTH_KEY = 'ui.chat.session_list_width_physical'
const SESSION_LIST_WIDTH_KEY_LEGACY = 'ui.chat.session_list_width'
const SESSION_LIST_WIDTH_DEFAULT = 295
const SESSION_LIST_WIDTH_MIN = 220
const SESSION_LIST_WIDTH_MAX = 520
const sessionListWidth = ref(SESSION_LIST_WIDTH_DEFAULT)
const sessionListResizing = ref(false)
let sessionListResizeStartX = 0
let sessionListResizeStartWidth = SESSION_LIST_WIDTH_DEFAULT
let sessionListResizeStartDpr = 1
let sessionListResizePrevCursor = ''
let sessionListResizePrevUserSelect = ''
const clampSessionListWidth = (n) => {
const v = Number.isFinite(n) ? n : SESSION_LIST_WIDTH_DEFAULT
return Math.min(SESSION_LIST_WIDTH_MAX, Math.max(SESSION_LIST_WIDTH_MIN, Math.round(v)))
}
const loadSessionListWidth = () => {
if (!process.client) return
try {
const raw = localStorage.getItem(SESSION_LIST_WIDTH_KEY)
const v = parseInt(String(raw || ''), 10)
if (!Number.isNaN(v)) {
sessionListWidth.value = clampSessionListWidth(v)
return
}
// Legacy: value was stored as CSS px. Convert to physical px using current dpr.
const legacy = localStorage.getItem(SESSION_LIST_WIDTH_KEY_LEGACY)
const legacyV = parseInt(String(legacy || ''), 10)
if (!Number.isNaN(legacyV)) {
const dpr = window.devicePixelRatio || 1
const converted = clampSessionListWidth(legacyV * dpr)
sessionListWidth.value = converted
try {
localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(converted))
localStorage.removeItem(SESSION_LIST_WIDTH_KEY_LEGACY)
} catch {}
}
} catch {}
}
const saveSessionListWidth = () => {
if (!process.client) return
try {
localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(clampSessionListWidth(sessionListWidth.value)))
} catch {}
}
const setSessionListResizingActive = (active) => {
if (!process.client) return
try {
const body = document.body
if (!body) return
if (active) {
sessionListResizePrevCursor = body.style.cursor || ''
sessionListResizePrevUserSelect = body.style.userSelect || ''
body.style.cursor = 'col-resize'
body.style.userSelect = 'none'
} else {
body.style.cursor = sessionListResizePrevCursor
body.style.userSelect = sessionListResizePrevUserSelect
sessionListResizePrevCursor = ''
sessionListResizePrevUserSelect = ''
}
} catch {}
}
const onSessionListResizerPointerMove = (ev) => {
if (!sessionListResizing.value) return
const clientX = Number(ev?.clientX || 0)
// `clientX` delta is in CSS px. We store width as physical px, so multiply by dpr.
sessionListWidth.value = clampSessionListWidth(
sessionListResizeStartWidth + (clientX - sessionListResizeStartX) * (sessionListResizeStartDpr || 1)
)
}
const stopSessionListResize = () => {
if (!process.client) return
if (!sessionListResizing.value) return
sessionListResizing.value = false
setSessionListResizingActive(false)
try {
window.removeEventListener('pointermove', onSessionListResizerPointerMove)
} catch {}
saveSessionListWidth()
}
const onSessionListResizerPointerUp = () => {
stopSessionListResize()
}
const onSessionListResizerPointerDown = (ev) => {
if (!process.client) return
try {
ev?.preventDefault?.()
} catch {}
sessionListResizing.value = true
sessionListResizeStartX = Number(ev?.clientX || 0)
sessionListResizeStartWidth = Number(sessionListWidth.value || SESSION_LIST_WIDTH_DEFAULT)
sessionListResizeStartDpr = window.devicePixelRatio || 1
setSessionListResizingActive(true)
try {
window.addEventListener('pointermove', onSessionListResizerPointerMove)
window.addEventListener('pointerup', onSessionListResizerPointerUp, { once: true })
} catch {}
}
const resetSessionListWidth = () => {
sessionListWidth.value = SESSION_LIST_WIDTH_DEFAULT
saveSessionListWidth()
}
onMounted(() => {
loadSessionListWidth()
})
// 桌面端设置(仅 Electron 环境可见)
const isDesktopEnv = ref(false)
@@ -1880,6 +2086,18 @@ const selectedAccount = ref(null)
const availableAccounts = ref([])
const sidebarMediaBase = process.client ? 'http://localhost:8000' : ''
const selfAvatarUrl = computed(() => {
const acc = String(selectedAccount.value || '').trim()
if (!acc) return ''
return `${sidebarMediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
})
const goSns = async () => {
await navigateTo('/sns')
}
// 实时更新(WCDB DLL + db_storage watcher
const realtimeEnabled = ref(false)
const realtimeAvailable = ref(false)
@@ -4593,6 +4811,7 @@ onUnmounted(() => {
if (!process.client) return
document.removeEventListener('click', onGlobalClick)
document.removeEventListener('keydown', onGlobalKeyDown)
stopSessionListResize()
if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer)
messageSearchDebounceTimer = null
if (highlightMessageTimer) clearTimeout(highlightMessageTimer)
@@ -4892,8 +5111,22 @@ const toggleRealtime = async (opts = {}) => {
return true
}
// Turning off realtime: sync the latest WCDB rows into the decrypted sqlite DB first,
// otherwise the UI will fall back to an outdated decrypted snapshot.
realtimeEnabled.value = false
stopRealtimeStream()
try {
const api = useApi()
const u = String(selectedContact.value?.username || '').trim()
if (u) {
// Use a larger scan window on shutdown to reduce the chance of missing a backlog.
await api.syncChatRealtimeMessages({
account: selectedAccount.value,
username: u,
max_scan: 5000
})
}
} catch {}
await refreshSessionsForSelectedAccount({ sourceOverride: '' })
if (selectedContact.value?.username) {
await refreshSelectedMessages()
@@ -5162,6 +5395,38 @@ const LinkCard = defineComponent({
background: #a1a1a1;
}
/* 会话列表宽度:按物理像素(px)配置,按 dpr 换算为 CSS px */
.session-list-panel {
width: calc(var(--session-list-width, 295px) / var(--dpr));
}
/* 会话列表拖动条(中间栏右侧) */
.session-list-resizer {
position: absolute;
top: 0;
right: -3px; /* 覆盖在 border 上,便于拖动 */
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 50;
}
.session-list-resizer::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 2px;
width: 2px;
background: transparent;
transition: background-color 0.15s ease;
}
.session-list-resizer:hover::after,
.session-list-resizer-active::after {
background: rgba(0, 0, 0, 0.12);
}
/* 消息气泡样式 */
.message-bubble {
border-radius: var(--message-radius);
File diff suppressed because it is too large Load Diff
+2
View File
@@ -18,6 +18,7 @@ from .routers.decrypt import router as _decrypt_router
from .routers.health import router as _health_router
from .routers.keys import router as _keys_router
from .routers.media import router as _media_router
from .routers.sns import router as _sns_router
from .routers.wechat_detection import router as _wechat_detection_router
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
@@ -51,6 +52,7 @@ app.include_router(_media_router)
app.include_router(_chat_router)
app.include_router(_chat_export_router)
app.include_router(_chat_media_router)
app.include_router(_sns_router)
class _SPAStaticFiles(StaticFiles):
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+238 -14
View File
@@ -68,6 +68,8 @@ from ..session_last_message import (
from ..wcdb_realtime import (
WCDBRealtimeError,
WCDB_REALTIME,
get_avatar_urls as _wcdb_get_avatar_urls,
get_display_names as _wcdb_get_display_names,
get_messages as _wcdb_get_messages,
get_sessions as _wcdb_get_sessions,
)
@@ -2231,6 +2233,35 @@ def _postprocess_full_messages(
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
# contact.db may not include enterprise/openim contacts (or group chatroom records). WCDB has a more complete
# view of display names + avatar URLs, so we use it as a best-effort fallback.
wcdb_display_names: dict[str, str] = {}
wcdb_avatar_urls: dict[str, str] = {}
try:
need_display: list[str] = []
need_avatar: list[str] = []
for u in uniq_senders:
if not u:
continue
row = sender_contact_rows.get(u)
if _pick_display_name(row, u) == u:
need_display.append(u)
if (not _pick_avatar_url(row)) and (u not in local_sender_avatars):
need_avatar.append(u)
need_display = list(dict.fromkeys(need_display))
need_avatar = list(dict.fromkeys(need_avatar))
if need_display or need_avatar:
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
with wcdb_conn.lock:
if need_display:
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
if need_avatar:
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
except Exception:
wcdb_display_names = {}
wcdb_avatar_urls = {}
for m in merged:
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
@@ -2238,15 +2269,28 @@ def _postprocess_full_messages(
frow = sender_contact_rows.get(fu)
if frow is not None:
m["from"] = _pick_display_name(frow, fu)
else:
wd = str(wcdb_display_names.get(fu) or "").strip()
if wd:
m["from"] = wd
su = str(m.get("senderUsername") or "")
if not su:
continue
row = sender_contact_rows.get(su)
m["senderDisplayName"] = _pick_display_name(row, su)
display_name = _pick_display_name(row, su)
if display_name == su:
wd = str(wcdb_display_names.get(su) or "").strip()
if wd and wd != su:
display_name = wd
m["senderDisplayName"] = display_name
avatar_url = _pick_avatar_url(row)
if not avatar_url and su in local_sender_avatars:
avatar_url = base_url + _build_avatar_url(account_dir.name, su)
if not avatar_url:
wa = str(wcdb_avatar_urls.get(su) or "").strip()
if wa.lower().startswith(("http://", "https://")):
avatar_url = wa
m["senderAvatar"] = avatar_url
qu = str(m.get("quoteUsername") or "").strip()
@@ -2262,9 +2306,15 @@ def _postprocess_full_messages(
if remark:
m["quoteTitle"] = remark
elif not qt:
m["quoteTitle"] = _pick_display_name(qrow, qu)
title = _pick_display_name(qrow, qu)
if title == qu:
wd = str(wcdb_display_names.get(qu) or "").strip()
if wd and wd != qu:
title = wd
m["quoteTitle"] = title
elif not qt:
m["quoteTitle"] = qu
wd = str(wcdb_display_names.get(qu) or "").strip()
m["quoteTitle"] = wd or qu
# Media URL fallback: if CDN URLs missing, use local media endpoints.
try:
@@ -2401,6 +2451,7 @@ def list_chat_sessions(
head_image_db_path = account_dir / "head_image.db"
base_url = str(request.base_url).rstrip("/")
rt_conn = None
rows: list[Any]
if source_norm == "realtime":
trace_id = f"rt-sessions-{int(time.time() * 1000)}-{threading.get_ident()}"
@@ -2416,6 +2467,7 @@ def list_chat_sessions(
try:
logger.info("[%s] ensure wcdb connected account=%s", trace_id, account_dir.name)
conn = WCDB_REALTIME.ensure_connected(account_dir)
rt_conn = conn
logger.info("[%s] wcdb connected account=%s handle=%s", trace_id, account_dir.name, int(conn.handle))
logger.info("[%s] wcdb_get_sessions account=%s", trace_id, account_dir.name)
wcdb_t0 = time.perf_counter()
@@ -2529,6 +2581,36 @@ def list_chat_sessions(
contact_rows = _load_contact_rows(contact_db_path, usernames)
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, usernames)
# Some sessions (notably enterprise groups / openim-related IDs) may be missing from decrypted contact.db
# (or lack nickname/avatar columns). In that case, fall back to WCDB APIs (same as WeFlow) to resolve
# display names + avatar URLs.
wcdb_display_names: dict[str, str] = {}
wcdb_avatar_urls: dict[str, str] = {}
try:
need_display: list[str] = []
need_avatar: list[str] = []
for u in usernames:
if not u:
continue
row = contact_rows.get(u)
if _pick_display_name(row, u) == u:
need_display.append(u)
if (not _pick_avatar_url(row)) and (u not in local_avatar_usernames):
need_avatar.append(u)
need_display = list(dict.fromkeys(need_display))
need_avatar = list(dict.fromkeys(need_avatar))
if need_display or need_avatar:
wcdb_conn = rt_conn or WCDB_REALTIME.ensure_connected(account_dir)
with wcdb_conn.lock:
if need_display:
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
if need_avatar:
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
except Exception:
wcdb_display_names = {}
wcdb_avatar_urls = {}
preview_mode = str(preview or "").strip().lower()
if preview_mode not in {"latest", "index", "session", "db", "none"}:
preview_mode = "latest"
@@ -2568,9 +2650,18 @@ def list_chat_sessions(
c_row = contact_rows.get(username)
display_name = _pick_display_name(c_row, username)
if display_name == username:
wd = str(wcdb_display_names.get(username) or "").strip()
if wd and wd != username:
display_name = wd
avatar_url = _pick_avatar_url(c_row)
if not avatar_url and username in local_avatar_usernames:
avatar_url = base_url + _build_avatar_url(account_dir.name, username)
if not avatar_url:
wa = str(wcdb_avatar_urls.get(username) or "").strip()
if wa.lower().startswith(("http://", "https://")):
avatar_url = wa
last_message = ""
if preview_mode == "session":
@@ -3896,6 +3987,35 @@ def list_chat_messages(
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
# contact.db may not include enterprise/openim contacts (or group chatroom records). WCDB has a more complete
# view of display names + avatar URLs, so we use it as a best-effort fallback.
wcdb_display_names: dict[str, str] = {}
wcdb_avatar_urls: dict[str, str] = {}
try:
need_display: list[str] = []
need_avatar: list[str] = []
for u in uniq_senders:
if not u:
continue
row = sender_contact_rows.get(u)
if _pick_display_name(row, u) == u:
need_display.append(u)
if (not _pick_avatar_url(row)) and (u not in local_sender_avatars):
need_avatar.append(u)
need_display = list(dict.fromkeys(need_display))
need_avatar = list(dict.fromkeys(need_avatar))
if need_display or need_avatar:
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
with wcdb_conn.lock:
if need_display:
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
if need_avatar:
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
except Exception:
wcdb_display_names = {}
wcdb_avatar_urls = {}
for m in merged:
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
@@ -3903,15 +4023,28 @@ def list_chat_messages(
frow = sender_contact_rows.get(fu)
if frow is not None:
m["from"] = _pick_display_name(frow, fu)
else:
wd = str(wcdb_display_names.get(fu) or "").strip()
if wd:
m["from"] = wd
su = str(m.get("senderUsername") or "")
if not su:
continue
row = sender_contact_rows.get(su)
m["senderDisplayName"] = _pick_display_name(row, su)
display_name = _pick_display_name(row, su)
if display_name == su:
wd = str(wcdb_display_names.get(su) or "").strip()
if wd and wd != su:
display_name = wd
m["senderDisplayName"] = display_name
avatar_url = _pick_avatar_url(row)
if not avatar_url and su in local_sender_avatars:
avatar_url = base_url + _build_avatar_url(account_dir.name, su)
if not avatar_url:
wa = str(wcdb_avatar_urls.get(su) or "").strip()
if wa.lower().startswith(("http://", "https://")):
avatar_url = wa
m["senderAvatar"] = avatar_url
qu = str(m.get("quoteUsername") or "").strip()
@@ -3927,9 +4060,15 @@ def list_chat_messages(
if remark:
m["quoteTitle"] = remark
elif not qt:
m["quoteTitle"] = _pick_display_name(qrow, qu)
title = _pick_display_name(qrow, qu)
if title == qu:
wd = str(wcdb_display_names.get(qu) or "").strip()
if wd and wd != qu:
title = wd
m["quoteTitle"] = title
elif not qt:
m["quoteTitle"] = qu
wd = str(wcdb_display_names.get(qu) or "").strip()
m["quoteTitle"] = wd or qu
# Media URL fallback: if CDN URLs missing, use local media endpoints.
try:
@@ -4357,11 +4496,48 @@ async def _search_chat_messages_via_fts(
uniq_usernames = list(dict.fromkeys([username] + [str(x.get("senderUsername") or "") for x in hits]))
contact_rows = _load_contact_rows(contact_db_path, uniq_usernames)
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, uniq_usernames)
wcdb_display_names: dict[str, str] = {}
wcdb_avatar_urls: dict[str, str] = {}
try:
need_display: list[str] = []
need_avatar: list[str] = []
for u in uniq_usernames:
uu = str(u or "").strip()
if not uu:
continue
row = contact_rows.get(uu)
if _pick_display_name(row, uu) == uu:
need_display.append(uu)
if (not _pick_avatar_url(row)) and (uu not in local_avatar_usernames):
need_avatar.append(uu)
need_display = list(dict.fromkeys(need_display))
need_avatar = list(dict.fromkeys(need_avatar))
if need_display or need_avatar:
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
with wcdb_conn.lock:
if need_display:
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
if need_avatar:
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
except Exception:
wcdb_display_names = {}
wcdb_avatar_urls = {}
conv_row = contact_rows.get(username)
conv_name = _pick_display_name(conv_row, username)
if conv_name == username:
wd = str(wcdb_display_names.get(username) or "").strip()
if wd and wd != username:
conv_name = wd
conv_avatar = _pick_avatar_url(conv_row)
if (not conv_avatar) and (username in local_avatar_usernames):
conv_avatar = base_url + _build_avatar_url(account_dir.name, username)
if not conv_avatar:
wa = str(wcdb_avatar_urls.get(username) or "").strip()
if wa.lower().startswith(("http://", "https://")):
conv_avatar = wa
for h in hits:
su = str(h.get("senderUsername") or "").strip()
@@ -4369,14 +4545,19 @@ async def _search_chat_messages_via_fts(
h["conversationAvatar"] = conv_avatar
if su:
row = contact_rows.get(su)
h["senderDisplayName"] = (
_pick_display_name(row, su)
if row is not None
else (conv_name if su == username else su)
)
display_name = _pick_display_name(row, su) if row is not None else (conv_name if su == username else su)
if display_name == su:
wd = str(wcdb_display_names.get(su) or "").strip()
if wd and wd != su:
display_name = wd
h["senderDisplayName"] = display_name
avatar_url = _pick_avatar_url(row)
if (not avatar_url) and (su in local_avatar_usernames):
avatar_url = base_url + _build_avatar_url(account_dir.name, su)
if not avatar_url:
wa = str(wcdb_avatar_urls.get(su) or "").strip()
if wa.lower().startswith(("http://", "https://")):
avatar_url = wa
h["senderAvatar"] = avatar_url
else:
uniq_contacts = list(
@@ -4387,24 +4568,67 @@ async def _search_chat_messages_via_fts(
contact_rows = _load_contact_rows(contact_db_path, uniq_contacts)
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, uniq_contacts)
wcdb_display_names: dict[str, str] = {}
wcdb_avatar_urls: dict[str, str] = {}
try:
need_display: list[str] = []
need_avatar: list[str] = []
for u in uniq_contacts:
uu = str(u or "").strip()
if not uu:
continue
row = contact_rows.get(uu)
if _pick_display_name(row, uu) == uu:
need_display.append(uu)
if (not _pick_avatar_url(row)) and (uu not in local_avatar_usernames):
need_avatar.append(uu)
need_display = list(dict.fromkeys(need_display))
need_avatar = list(dict.fromkeys(need_avatar))
if need_display or need_avatar:
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
with wcdb_conn.lock:
if need_display:
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
if need_avatar:
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
except Exception:
wcdb_display_names = {}
wcdb_avatar_urls = {}
for h in hits:
cu = str(h.get("username") or "").strip()
su = str(h.get("senderUsername") or "").strip()
crow = contact_rows.get(cu)
conv_name = _pick_display_name(crow, cu) if cu else ""
if cu and (conv_name == cu):
wd = str(wcdb_display_names.get(cu) or "").strip()
if wd and wd != cu:
conv_name = wd
h["conversationName"] = conv_name or cu
conv_avatar = _pick_avatar_url(crow)
if (not conv_avatar) and cu and (cu in local_avatar_usernames):
conv_avatar = base_url + _build_avatar_url(account_dir.name, cu)
if not conv_avatar and cu:
wa = str(wcdb_avatar_urls.get(cu) or "").strip()
if wa.lower().startswith(("http://", "https://")):
conv_avatar = wa
h["conversationAvatar"] = conv_avatar
if su:
row = contact_rows.get(su)
h["senderDisplayName"] = (
_pick_display_name(row, su) if row is not None else (conv_name if su == cu else su)
)
display_name = _pick_display_name(row, su) if row is not None else (conv_name if su == cu else su)
if display_name == su:
wd = str(wcdb_display_names.get(su) or "").strip()
if wd and wd != su:
display_name = wd
h["senderDisplayName"] = display_name
avatar_url = _pick_avatar_url(row)
if (not avatar_url) and (su in local_avatar_usernames):
avatar_url = base_url + _build_avatar_url(account_dir.name, su)
if not avatar_url:
wa = str(wcdb_avatar_urls.get(su) or "").strip()
if wa.lower().startswith(("http://", "https://")):
avatar_url = wa
h["senderAvatar"] = avatar_url
return {
+43 -24
View File
@@ -414,7 +414,7 @@ def _is_allowed_proxy_image_host(host: str) -> bool:
if not h:
return False
# WeChat public account/article thumbnails and avatars commonly live on these CDNs.
return h.endswith(".qpic.cn") or h.endswith(".qlogo.cn")
return h.endswith(".qpic.cn") or h.endswith(".qlogo.cn") or h.endswith(".tc.qq.com")
@router.get("/api/chat/media/proxy_image", summary="代理获取远程图片(解决微信公众号图片防盗链)")
@@ -435,33 +435,52 @@ async def proxy_image(url: str):
raise HTTPException(status_code=400, detail="Unsupported url host for proxy_image.")
def _download_bytes() -> tuple[bytes, str]:
headers = {
base_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
# qpic/qlogo often require a mp.weixin.qq.com referer (anti-hotlink)
"Referer": "https://mp.weixin.qq.com/",
"Origin": "https://mp.weixin.qq.com",
}
r = requests.get(u, headers=headers, timeout=20, stream=True)
try:
r.raise_for_status()
content_type = str(r.headers.get("Content-Type") or "").strip()
max_bytes = 10 * 1024 * 1024
chunks: list[bytes] = []
total = 0
for ch in r.iter_content(chunk_size=64 * 1024):
if not ch:
continue
chunks.append(ch)
total += len(ch)
if total > max_bytes:
raise HTTPException(status_code=400, detail="Proxy image too large (>10MB).")
return b"".join(chunks), content_type
finally:
# Different Tencent CDNs enforce different anti-hotlink rules.
# Try a couple of safe referers so Moments(qpic) and MP(qpic) both work.
header_variants = [
{"Referer": "https://wx.qq.com/", "Origin": "https://wx.qq.com"},
{"Referer": "https://mp.weixin.qq.com/", "Origin": "https://mp.weixin.qq.com"},
{"Referer": "https://www.baidu.com/", "Origin": "https://www.baidu.com"},
{},
]
last_err: Exception | None = None
for extra in header_variants:
headers = dict(base_headers)
headers.update(extra)
r = requests.get(u, headers=headers, timeout=20, stream=True)
try:
r.close()
except Exception:
pass
r.raise_for_status()
content_type = str(r.headers.get("Content-Type") or "").strip()
max_bytes = 10 * 1024 * 1024
chunks: list[bytes] = []
total = 0
for ch in r.iter_content(chunk_size=64 * 1024):
if not ch:
continue
chunks.append(ch)
total += len(ch)
if total > max_bytes:
raise HTTPException(status_code=400, detail="Proxy image too large (>10MB).")
return b"".join(chunks), content_type
except HTTPException:
# Hard failure, don't retry with another referer.
raise
except Exception as e:
last_err = e
finally:
try:
r.close()
except Exception:
pass
# All variants failed.
raise last_err or RuntimeError("proxy_image download failed")
try:
data, ct = await asyncio.to_thread(_download_bytes)
File diff suppressed because it is too large Load Diff
+154
View File
@@ -68,6 +68,13 @@ def _load_wcdb_lib() -> ctypes.CDLL:
lib.wcdb_close_account.argtypes = [ctypes.c_int64]
lib.wcdb_close_account.restype = ctypes.c_int
# Optional: wcdb_set_my_wxid(handle, wxid)
try:
lib.wcdb_set_my_wxid.argtypes = [ctypes.c_int64, ctypes.c_char_p]
lib.wcdb_set_my_wxid.restype = ctypes.c_int
except Exception:
pass
lib.wcdb_get_sessions.argtypes = [ctypes.c_int64, ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_sessions.restype = ctypes.c_int
@@ -95,6 +102,37 @@ def _load_wcdb_lib() -> ctypes.CDLL:
lib.wcdb_get_group_members.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_group_members.restype = ctypes.c_int
# Optional: execute arbitrary SQL on a selected database kind/path.
# Signature: wcdb_exec_query(handle, kind, path, sql, out_json)
try:
lib.wcdb_exec_query.argtypes = [
ctypes.c_int64,
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.POINTER(ctypes.c_char_p),
]
lib.wcdb_exec_query.restype = ctypes.c_int
except Exception:
pass
# Optional (newer DLLs): wcdb_get_sns_timeline(handle, limit, offset, usernames_json, keyword, start_time, end_time, out_json)
try:
lib.wcdb_get_sns_timeline.argtypes = [
ctypes.c_int64,
ctypes.c_int32,
ctypes.c_int32,
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.c_int32,
ctypes.c_int32,
ctypes.POINTER(ctypes.c_char_p),
]
lib.wcdb_get_sns_timeline.restype = ctypes.c_int
except Exception:
# Older wcdb_api.dll may not expose this export.
pass
lib.wcdb_get_logs.argtypes = [ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_logs.restype = ctypes.c_int
@@ -195,6 +233,30 @@ def open_account(session_db_path: Path, key_hex: str) -> int:
return int(out_handle.value)
def set_my_wxid(handle: int, wxid: str) -> bool:
"""Best-effort set the "my wxid" context for some WCDB APIs."""
try:
_ensure_initialized()
except Exception:
return False
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_set_my_wxid", None)
if not fn:
return False
w = str(wxid or "").strip()
if not w:
return False
try:
rc = int(fn(ctypes.c_int64(int(handle)), w.encode("utf-8")))
except Exception:
return False
return rc == 0
def close_account(handle: int) -> None:
try:
h = int(handle)
@@ -293,6 +355,93 @@ def get_avatar_urls(handle: int, usernames: list[str]) -> dict[str, str]:
return {}
def exec_query(handle: int, *, kind: str, path: Optional[str], sql: str) -> list[dict[str, Any]]:
"""Execute raw SQL on a specific db kind/path via WCDB.
This is primarily used for SNS/other dbs that are not directly exposed by dedicated APIs.
"""
_ensure_initialized()
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_exec_query", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support exec_query.")
k = str(kind or "").strip()
if not k:
raise WCDBRealtimeError("Missing kind for exec_query.")
s = str(sql or "").strip()
if not s:
return []
p = None if path is None else str(path or "").strip()
out_json = _call_out_json(
fn,
ctypes.c_int64(int(handle)),
k.encode("utf-8"),
None if p is None else p.encode("utf-8"),
s.encode("utf-8"),
)
decoded = _safe_load_json(out_json)
if isinstance(decoded, list):
out: list[dict[str, Any]] = []
for x in decoded:
if isinstance(x, dict):
out.append(x)
return out
return []
def get_sns_timeline(
handle: int,
*,
limit: int = 20,
offset: int = 0,
usernames: Optional[list[str]] = None,
keyword: str | None = None,
start_time: int = 0,
end_time: int = 0,
) -> list[dict[str, Any]]:
"""Read Moments (SnsTimeLine) from the live encrypted db_storage via WCDB.
Requires a newer wcdb_api.dll export: wcdb_get_sns_timeline.
"""
_ensure_initialized()
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_get_sns_timeline", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support sns timeline.")
lim = max(0, int(limit or 0))
off = max(0, int(offset or 0))
users = [str(u or "").strip() for u in (usernames or []) if str(u or "").strip()]
users = list(dict.fromkeys(users))
users_json = json.dumps(users, ensure_ascii=False) if users else ""
kw = str(keyword or "").strip()
payload = _call_out_json(
fn,
ctypes.c_int64(int(handle)),
ctypes.c_int32(lim),
ctypes.c_int32(off),
users_json.encode("utf-8"),
kw.encode("utf-8"),
ctypes.c_int32(int(start_time or 0)),
ctypes.c_int32(int(end_time or 0)),
)
decoded = _safe_load_json(payload)
if isinstance(decoded, list):
out: list[dict[str, Any]] = []
for x in decoded:
if isinstance(x, dict):
out.append(x)
return out
return []
def shutdown() -> None:
global _initialized
lib = _load_wcdb_lib()
@@ -427,6 +576,11 @@ class WCDBRealtimeManager:
session_db_path = _resolve_session_db_path(db_storage_dir)
handle = open_account(session_db_path, key)
# Some WCDB APIs (e.g. exec_query on non-session DBs) may require this context.
try:
set_my_wxid(handle, account)
except Exception:
pass
conn = WCDBRealtimeConnection(
account=account,