Compare commits
22 Commits
@@ -17,6 +17,7 @@ wheels/
|
||||
# Local config templates
|
||||
/wechat_db_config_template.json
|
||||
.ace-tool/
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Local dev repos and data
|
||||
/WxDatDecrypt/
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div align="center">
|
||||
<h1>WeChatDataAnalysis - 微信数据库解密与分析工具</h1>
|
||||
<p>一个专门用于微信4.x版本数据库解密的工具(支持聊天记录实时更新)</p>
|
||||
<p><b>特别致谢</b>:<a href="https://github.com/ycccccccy/echotrace">echotrace</a>(本项目大量功能参考其实现,提供了重要技术支持)</p>
|
||||
<p><b>特别致谢</b>:<a href="https://github.com/ycccccccy/echotrace">echotrace</a>、<a href="https://github.com/hicccc77/WeFlow">WeFlow</a>(本项目大量功能参考其实现,提供了重要技术支持)</p>
|
||||
<img src="https://img.shields.io/github/v/tag/LifeArchiveProject/WeChatDataAnalysis" alt="Version" />
|
||||
<img src="https://img.shields.io/github/stars/LifeArchiveProject/WeChatDataAnalysis" alt="Stars" />
|
||||
<img src="https://gh-down-badges.linkof.link/LifeArchiveProject/WeChatDataAnalysis" alt="Downloads" />
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
## 年度总结
|
||||
|
||||
年度总结现在支持 4 种不同风格(style1-4)。如果你对某个风格有更好的修改建议,或有新风格的点子,欢迎到 Issue 区反馈:https://github.com/LifeArchiveProject/WeChatDataAnalysis/issues
|
||||
年度总结现在支持 3 种不同风格(style1、style2、style3)。如果你对某个风格有更好的修改建议,或有新风格的点子,欢迎到 Issue 区反馈:https://github.com/LifeArchiveProject/WeChatDataAnalysis/issues
|
||||
|
||||
> ⚠️ **提醒**:年度总结目前还不是最终版本,后续还会增加新总结或新风格。
|
||||
|
||||
@@ -82,12 +82,10 @@
|
||||
<td><img src="frontend/public/style2.png" alt="年度总结 Style 2" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Style 3</b></td>
|
||||
<td align="center"><b>Style 4</b></td>
|
||||
<td align="center" colspan="2"><b>Style 3</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="frontend/public/style3.png" alt="年度总结 Style 3" width="400"/></td>
|
||||
<td><img src="frontend/public/style4.png" alt="年度总结 Style 4" width="400"/></td>
|
||||
<td align="center" colspan="2"><img src="frontend/public/style3.png" alt="年度总结 Style 3" width="400"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -196,21 +194,33 @@ npm run dist
|
||||
1. **[echotrace](https://github.com/ycccccccy/echotrace)** - 微信数据解析/取证工具
|
||||
- 本项目大量功能参考并复用其实现思路,提供了重要技术支持
|
||||
|
||||
2. **[wx_key](https://github.com/ycccccccy/wx_key)** - 微信数据库与图片密钥提取工具
|
||||
2. **[WeFlow](https://github.com/hicccc77/WeFlow)** - 微信数据分析工具
|
||||
- 提供了重要的功能参考和技术支持
|
||||
|
||||
3. **[wx_key](https://github.com/ycccccccy/wx_key)** - 微信数据库与图片密钥提取工具
|
||||
- 支持获取微信 4.x 数据库密钥与缓存图片密钥
|
||||
- 本项目推荐使用此工具获取密钥
|
||||
|
||||
3. **[wechat-dump-rs](https://github.com/0xlane/wechat-dump-rs)** - Rust实现的微信数据库解密工具
|
||||
4. **[wechat-dump-rs](https://github.com/0xlane/wechat-dump-rs)** - Rust实现的微信数据库解密工具
|
||||
- 提供了SQLCipher 4.0解密的正确实现参考
|
||||
- 本项目的HMAC验证和页面处理逻辑基于此项目的实现
|
||||
|
||||
4. **[oh-my-wechat](https://github.com/chclt/oh-my-wechat)** - 微信聊天记录查看工具
|
||||
5. **[oh-my-wechat](https://github.com/chclt/oh-my-wechat)** - 微信聊天记录查看工具
|
||||
- 提供了优秀的聊天记录界面设计参考
|
||||
- 本项目的聊天界面风格参考了此项目的实现
|
||||
|
||||
5. **[vue3-wechat-tool](https://github.com/Ele-Cat/vue3-wechat-tool)** - 微信聊天记录工具(Vue3)
|
||||
6. **[vue3-wechat-tool](https://github.com/Ele-Cat/vue3-wechat-tool)** - 微信聊天记录工具(Vue3)
|
||||
- 提供了聊天记录展示与交互的实现参考
|
||||
|
||||
7. **[wx-dat](https://github.com/waaaaashi/wx-dat)** - 微信图片密钥获取工具
|
||||
- 实现真正的无头获取图片密钥,不再依赖扫描微信内存与点击朋友圈大图
|
||||
|
||||
8. **PR #24 贡献者 [H3CoF6](https://github.com/H3CoF6)** - 微信密钥获取能力增强
|
||||
- 无第三方工具依赖实现微信密钥获取能力
|
||||
- 实现数据库密钥获取:实现形式参考 [wx_key](https://github.com/ycccccccy/wx_key) 项目,完成 Python 预编译 wheel 封装,详情见 [py_wx_key](https://github.com/H3CoF6/py_wx_key)
|
||||
- 特征码不在 C++ 内硬编码,而由 Python 模块传入,减少 wheel 更新次数
|
||||
- 实现真正的无头获取图片密钥,不再依赖扫描微信内存(以及点击朋友圈大图),感谢项目 [wx-dat](https://github.com/waaaaashi/wx-dat)
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#LifeArchiveProject/WeChatDataAnalysis&Date)
|
||||
@@ -222,3 +232,4 @@ npm run dist
|
||||
---
|
||||
|
||||
**免责声明**: 本工具仅供学习研究使用,使用者需自行承担使用风险。开发者不对因使用本工具造成的任何损失负责。
|
||||
|
||||
|
||||
@@ -611,6 +611,25 @@ function registerWindowIpc() {
|
||||
return getCloseBehavior();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:chooseDirectory", async (_event, options) => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: String(options?.title || "选择文件夹"),
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
return {
|
||||
canceled: !!result?.canceled,
|
||||
filePaths: Array.isArray(result?.filePaths) ? result.filePaths : [],
|
||||
};
|
||||
} catch (err) {
|
||||
logMain(`[main] dialog:chooseDirectory failed: ${err?.message || err}`);
|
||||
return {
|
||||
canceled: true,
|
||||
filePaths: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -11,4 +11,6 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
|
||||
getCloseBehavior: () => ipcRenderer.invoke("app:getCloseBehavior"),
|
||||
setCloseBehavior: (behavior) => ipcRenderer.invoke("app:setCloseBehavior", String(behavior || "")),
|
||||
|
||||
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
<template>
|
||||
<div :class="rootClass">
|
||||
<DesktopTitleBar v-if="isDesktop && !isChatRoute" />
|
||||
<div :class="contentClass">
|
||||
<NuxtPage />
|
||||
<SidebarRail v-if="showSidebar" />
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Desktop titlebar lives above the page content (right column) -->
|
||||
<DesktopTitleBar />
|
||||
<div :class="contentClass">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// In Electron the server/pre-render doesn't know about `window.wechatDesktop`.
|
||||
// If we render different DOM on server vs client, Vue hydration will keep the
|
||||
// server HTML (no patch) and the layout/CSS fixes won't apply reliably.
|
||||
@@ -23,25 +32,32 @@ onMounted(() => {
|
||||
isDesktop.value = !!window?.wechatDesktop
|
||||
updateDprVar()
|
||||
window.addEventListener('resize', updateDprVar)
|
||||
|
||||
// Init global UI state.
|
||||
const chatAccounts = useChatAccountsStore()
|
||||
const privacy = usePrivacyStore()
|
||||
void chatAccounts.ensureLoaded()
|
||||
privacy.init()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateDprVar)
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
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'
|
||||
return isDesktop.value
|
||||
? `wechat-desktop h-screen flex flex-col overflow-hidden ${base}`
|
||||
: `min-h-screen ${base}`
|
||||
? `wechat-desktop h-screen flex overflow-hidden ${base}`
|
||||
: `h-screen flex overflow-hidden ${base}`
|
||||
})
|
||||
|
||||
const contentClass = computed(() =>
|
||||
isDesktop.value ? 'wechat-desktop-content flex-1 overflow-auto min-h-0' : ''
|
||||
isDesktop.value
|
||||
? 'wechat-desktop-content flex-1 overflow-auto min-h-0'
|
||||
: 'flex-1 overflow-auto min-h-0'
|
||||
)
|
||||
|
||||
const showSidebar = computed(() => !String(route.path || '').startsWith('/wrapped'))
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -70,15 +86,4 @@ const contentClass = computed(() =>
|
||||
.wechat-desktop .wechat-desktop-content > .min-h-screen {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* 页面过渡动画 - 渐显渐隐效果 */
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -730,35 +730,39 @@
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
@apply flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-white border border-gray-200 text-gray-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
@apply flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md bg-white border border-gray-200 text-gray-700 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm;
|
||||
}
|
||||
|
||||
.header-btn:hover:not(:disabled) {
|
||||
@apply bg-gray-50 border-gray-300;
|
||||
@apply bg-gray-50 border-gray-300 shadow;
|
||||
}
|
||||
|
||||
.header-btn:active:not(:disabled) {
|
||||
@apply bg-gray-100;
|
||||
@apply bg-gray-100 scale-95;
|
||||
}
|
||||
|
||||
.header-btn svg {
|
||||
@apply w-3.5 h-3.5;
|
||||
}
|
||||
|
||||
.header-btn-icon {
|
||||
@apply w-8 h-8 flex items-center justify-center rounded-lg bg-white border border-gray-200 text-gray-600 transition-all duration-200;
|
||||
@apply w-8 h-8 flex items-center justify-center rounded-lg bg-transparent border border-transparent text-gray-600 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.header-btn-icon:hover {
|
||||
@apply bg-gray-50 border-gray-300 text-gray-800;
|
||||
@apply bg-transparent border-transparent text-gray-800;
|
||||
}
|
||||
|
||||
.header-btn-icon-active {
|
||||
@apply bg-[#03C160]/10 border-[#03C160] text-[#03C160];
|
||||
@apply bg-transparent border-transparent text-[#03C160];
|
||||
}
|
||||
|
||||
.header-btn-icon-active:hover {
|
||||
@apply bg-[#03C160]/15;
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
.message-filter-select {
|
||||
@apply text-xs px-2 py-1.5 rounded-lg bg-white border border-gray-200 text-gray-700 focus:outline-none focus:ring-2 focus:ring-[#03C160]/20 focus:border-[#03C160] transition-all disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
@apply text-xs px-2 py-1.5 rounded-lg bg-transparent border-0 text-gray-700 focus:outline-none focus:ring-0 transition-all disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
/* 搜索侧边栏样式 */
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div
|
||||
class="border-r border-gray-200 flex flex-col"
|
||||
:style="{ backgroundColor: '#e8e7e7', width: '60px', minWidth: '60px', maxWidth: '60px' }"
|
||||
>
|
||||
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
|
||||
<!-- Avatar -->
|
||||
<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="{ backgroundColor: '#4B5563' }"
|
||||
>
|
||||
我
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="聊天"
|
||||
@click="goChat"
|
||||
>
|
||||
<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="isChatRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<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>
|
||||
|
||||
<!-- Moments -->
|
||||
<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>
|
||||
|
||||
<!-- Contacts -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="联系人"
|
||||
@click="goContacts"
|
||||
>
|
||||
<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="isContactsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<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="M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="10" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wrapped -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="年度总结"
|
||||
@click="goWrapped"
|
||||
>
|
||||
<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="isWrappedRoute ? '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"
|
||||
>
|
||||
<rect x="4" y="5" width="16" height="15" rx="2" />
|
||||
<path d="M8 3v4" />
|
||||
<path d="M16 3v4" />
|
||||
<path d="M4 9h16" />
|
||||
<path d="M8.5 15l2-2 1.5 1.5 3-3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Realtime -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group"
|
||||
:class="realtimeBusy ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'"
|
||||
:title="realtimeTitle"
|
||||
@click="toggleRealtime"
|
||||
>
|
||||
<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="realtimeEnabled ? 'text-[#07b75b]' : 'text-[#5d5d5d]'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M13 2L4 14h7l-1 8 9-12h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="privacyStore.toggle"
|
||||
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Settings -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="goSettings"
|
||||
title="设置"
|
||||
>
|
||||
<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="isSettingsRoute ? 'text-[#07b75b]' : '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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { useChatRealtimeStore } from '~/stores/chatRealtime'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const chatAccounts = useChatAccountsStore()
|
||||
const { selectedAccount } = storeToRefs(chatAccounts)
|
||||
|
||||
const privacyStore = usePrivacyStore()
|
||||
const { privacyMode } = storeToRefs(privacyStore)
|
||||
|
||||
const realtimeStore = useChatRealtimeStore()
|
||||
const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realtimeChecking, statusError: realtimeStatusError, toggling: realtimeToggling } = storeToRefs(realtimeStore)
|
||||
|
||||
onMounted(async () => {
|
||||
await chatAccounts.ensureLoaded()
|
||||
})
|
||||
|
||||
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 isChatRoute = computed(() => route.path?.startsWith('/chat'))
|
||||
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
|
||||
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
|
||||
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
|
||||
const isSettingsRoute = computed(() => route.path?.startsWith('/settings'))
|
||||
|
||||
const goChat = async () => {
|
||||
await navigateTo('/chat')
|
||||
}
|
||||
|
||||
const goSns = async () => {
|
||||
await navigateTo('/sns')
|
||||
}
|
||||
|
||||
const goContacts = async () => {
|
||||
await navigateTo('/contacts')
|
||||
}
|
||||
|
||||
const goWrapped = async () => {
|
||||
await navigateTo('/wrapped')
|
||||
}
|
||||
|
||||
const goSettings = async () => {
|
||||
await navigateTo('/settings')
|
||||
}
|
||||
|
||||
const realtimeBusy = computed(() => !!realtimeChecking.value || !!realtimeToggling.value)
|
||||
|
||||
const realtimeTitle = computed(() => {
|
||||
if (realtimeEnabled.value) return '关闭实时更新(全局)'
|
||||
if (realtimeAvailable.value) return '开启实时更新(全局)'
|
||||
return realtimeStatusError.value || '实时模式不可用'
|
||||
})
|
||||
|
||||
const toggleRealtime = async () => {
|
||||
if (realtimeBusy.value) return
|
||||
await realtimeStore.toggle({ silent: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -292,6 +292,7 @@ export const useApi = () => {
|
||||
message_types: Array.isArray(data.message_types) ? data.message_types : [],
|
||||
include_media: data.include_media == null ? true : !!data.include_media,
|
||||
media_kinds: Array.isArray(data.media_kinds) ? data.media_kinds : ['image', 'emoji', 'video', 'video_thumb', 'voice', 'file'],
|
||||
output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(),
|
||||
allow_process_key_extract: !!data.allow_process_key_extract,
|
||||
privacy_mode: !!data.privacy_mode,
|
||||
file_name: data.file_name || null
|
||||
@@ -313,6 +314,36 @@ export const useApi = () => {
|
||||
return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// 联系人
|
||||
const listChatContacts = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.keyword) query.set('keyword', params.keyword)
|
||||
if (params && params.include_friends != null) query.set('include_friends', String(!!params.include_friends))
|
||||
if (params && params.include_groups != null) query.set('include_groups', String(!!params.include_groups))
|
||||
if (params && params.include_officials != null) query.set('include_officials', String(!!params.include_officials))
|
||||
const url = '/chat/contacts' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
const exportChatContacts = async (payload = {}) => {
|
||||
return await request('/chat/contacts/export', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
account: payload.account || null,
|
||||
output_dir: payload.output_dir || '',
|
||||
format: payload.format || 'json',
|
||||
include_avatar_link: payload.include_avatar_link == null ? true : !!payload.include_avatar_link,
|
||||
keyword: payload.keyword || null,
|
||||
contact_types: {
|
||||
friends: payload?.contact_types?.friends == null ? true : !!payload.contact_types.friends,
|
||||
groups: payload?.contact_types?.groups == null ? true : !!payload.contact_types.groups,
|
||||
officials: payload?.contact_types?.officials == null ? true : !!payload.contact_types.officials,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// WeChat Wrapped(年度总结)
|
||||
const getWrappedAnnual = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
@@ -344,7 +375,22 @@ export const useApi = () => {
|
||||
const url = `/wrapped/annual/cards/${safeId}` + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
|
||||
// 获取微信进程状态
|
||||
const getWxStatus = async () => {
|
||||
return await request('/wechat/status')
|
||||
}
|
||||
|
||||
// 获取数据库密钥
|
||||
const getDbKey = async () => {
|
||||
return await request('/get_db_key')
|
||||
}
|
||||
|
||||
// 获取图片密钥
|
||||
const getImageKey = async () => {
|
||||
return await request('/get_image_key')
|
||||
}
|
||||
|
||||
return {
|
||||
detectWechat,
|
||||
detectCurrentAccount,
|
||||
@@ -373,8 +419,13 @@ export const useApi = () => {
|
||||
getChatExport,
|
||||
listChatExports,
|
||||
cancelChatExport,
|
||||
listChatContacts,
|
||||
exportChatContacts,
|
||||
getWrappedAnnual,
|
||||
getWrappedAnnualMeta,
|
||||
getWrappedAnnualCard
|
||||
getWrappedAnnualCard,
|
||||
getDbKey,
|
||||
getImageKey,
|
||||
getWxStatus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,7 @@ export default defineNuxtConfig({
|
||||
{ rel: 'icon', type: 'image/png', href: '/logo.png' },
|
||||
{ rel: 'stylesheet', href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css' }
|
||||
]
|
||||
},
|
||||
pageTransition: { name: 'page', mode: 'out-in' }
|
||||
}
|
||||
},
|
||||
|
||||
// 模块配置
|
||||
|
||||
@@ -19,5 +19,8 @@
|
||||
"ogl": "^1.0.11",
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "3.4.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||||
<div class="flex-1 min-h-0 overflow-hidden p-4">
|
||||
<div class="h-full grid grid-cols-1 lg:grid-cols-[400px_minmax(0,1fr)] gap-4">
|
||||
<div class="bg-white border border-gray-200 rounded-lg flex flex-col min-h-0 overflow-hidden">
|
||||
<div class="p-3 border-b border-gray-200" style="background-color: #F7F7F7">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="contact-search-wrapper flex-1" :class="{ 'privacy-blur': privacyMode }">
|
||||
<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="searchKeyword" class="contact-search-input" type="text" placeholder="搜索联系人" />
|
||||
<button v-if="searchKeyword" type="button" class="contact-search-clear" @click="searchKeyword = ''">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select v-if="availableAccounts.length > 1" v-model="selectedAccount" class="account-select">
|
||||
<option v-for="acc in availableAccounts" :key="acc" :value="acc">{{ acc }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-3 py-2 border-b border-gray-200 bg-white flex items-center gap-4 text-sm">
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="contactTypes.friends" type="checkbox" />
|
||||
<span>好友 {{ counts.friends }}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="contactTypes.groups" type="checkbox" />
|
||||
<span>群聊 {{ counts.groups }}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="contactTypes.officials" type="checkbox" />
|
||||
<span>公众号 {{ counts.officials }}</span>
|
||||
</label>
|
||||
<span class="ml-auto text-gray-500">总计 {{ counts.total }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<div v-if="loading" class="p-4 text-sm text-gray-500">加载中…</div>
|
||||
<div v-else-if="error" class="p-4 text-sm text-red-500 whitespace-pre-wrap">{{ error }}</div>
|
||||
<div v-else-if="contacts.length === 0" class="p-4 text-sm text-gray-500">暂无联系人</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="contact in contacts"
|
||||
:key="contact.username"
|
||||
class="px-3 py-2 border-b border-gray-100 flex items-center gap-3"
|
||||
>
|
||||
<div class="w-10 h-10 rounded-md overflow-hidden bg-gray-300 shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<img v-if="contact.avatar" :src="contact.avatar" :alt="contact.displayName" class="w-full h-full object-cover" referrerpolicy="no-referrer" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-white text-xs font-bold" style="background-color:#4B5563">{{ contact.displayName?.charAt(0) || '?' }}</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1" :class="{ 'privacy-blur': privacyMode }">
|
||||
<div class="text-sm text-gray-900 truncate">{{ contact.displayName }}</div>
|
||||
<div class="text-xs text-gray-500 truncate">{{ contact.username }}</div>
|
||||
<div class="text-[11px] text-gray-500 truncate" v-if="contact.type !== 'group' && (contact.region || contact.source)">
|
||||
<span v-if="contact.region">地区:{{ contact.region }}</span>
|
||||
<span v-if="contact.region && contact.source"> · </span>
|
||||
<span
|
||||
v-if="contact.source"
|
||||
:title="contact.sourceScene != null ? `来源场景码:${contact.sourceScene}` : ''"
|
||||
>来源:{{ contact.source }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs px-2 py-0.5 rounded" :class="typeBadgeClass(contact.type)">
|
||||
{{ typeLabel(contact.type) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<div class="text-base font-medium text-gray-900">导出联系人</div>
|
||||
<div class="text-xs text-gray-500 mt-1">支持 JSON / CSV,默认包含头像链接</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="font-medium text-gray-800">导出格式</div>
|
||||
<label class="flex items-center gap-2"><input v-model="exportFormat" type="radio" value="json" /> JSON</label>
|
||||
<label class="flex items-center gap-2"><input v-model="exportFormat" type="radio" value="csv" /> CSV (Excel)</label>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="font-medium text-gray-800">导出类型(多选)</div>
|
||||
<label class="flex items-center gap-2"><input v-model="exportTypes.friends" type="checkbox" /> 好友</label>
|
||||
<label class="flex items-center gap-2"><input v-model="exportTypes.groups" type="checkbox" /> 群聊</label>
|
||||
<label class="flex items-center gap-2"><input v-model="exportTypes.officials" type="checkbox" /> 公众号</label>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="includeAvatarLink" type="checkbox" />
|
||||
导出头像链接
|
||||
</label>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="font-medium text-gray-800">导出目录</div>
|
||||
<div class="px-2 py-2 rounded border border-gray-200 bg-gray-50 text-xs break-all min-h-[38px]">{{ exportFolder || '未选择' }}</div>
|
||||
<button type="button" class="w-full px-3 py-2 rounded border border-gray-200 hover:bg-gray-50" @click="chooseExportFolder">选择文件夹</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 w-full px-3 py-2 rounded text-white"
|
||||
:class="canExport && !exporting ? 'bg-[#03C160] hover:bg-[#02ad56]' : 'bg-gray-300 cursor-not-allowed'"
|
||||
:disabled="!canExport || exporting"
|
||||
@click="startExport"
|
||||
>
|
||||
{{ exporting ? '导出中…' : '开始导出' }}
|
||||
</button>
|
||||
|
||||
<div v-if="exportMsg" class="text-xs whitespace-pre-wrap" :class="exportOk ? 'text-green-600' : 'text-red-500'">{{ exportMsg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
useHead({ title: '联系人 - 微信数据分析助手' })
|
||||
|
||||
const api = useApi()
|
||||
|
||||
const chatAccounts = useChatAccountsStore()
|
||||
const { accounts: availableAccounts, selectedAccount } = storeToRefs(chatAccounts)
|
||||
|
||||
const privacyStore = usePrivacyStore()
|
||||
const { privacyMode } = storeToRefs(privacyStore)
|
||||
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const contactTypes = reactive({
|
||||
friends: true,
|
||||
groups: true,
|
||||
officials: true,
|
||||
})
|
||||
|
||||
const contacts = ref([])
|
||||
const counts = reactive({
|
||||
friends: 0,
|
||||
groups: 0,
|
||||
officials: 0,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const exportFormat = ref('json')
|
||||
const includeAvatarLink = ref(true)
|
||||
const exportTypes = reactive({
|
||||
friends: true,
|
||||
groups: true,
|
||||
officials: true,
|
||||
})
|
||||
const exportFolder = ref('')
|
||||
const exportFolderHandle = ref(null)
|
||||
const exporting = ref(false)
|
||||
const exportMsg = ref('')
|
||||
const exportOk = ref(false)
|
||||
|
||||
const typeLabel = (type) => {
|
||||
if (type === 'friend') return '好友'
|
||||
if (type === 'group') return '群聊'
|
||||
if (type === 'official') return '公众号'
|
||||
return '其他'
|
||||
}
|
||||
|
||||
const typeBadgeClass = (type) => {
|
||||
if (type === 'friend') return 'bg-blue-100 text-blue-700'
|
||||
if (type === 'group') return 'bg-green-100 text-green-700'
|
||||
if (type === 'official') return 'bg-orange-100 text-orange-700'
|
||||
return 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
const isDesktopExportRuntime = () => {
|
||||
return !!(process.client && window?.wechatDesktop?.chooseDirectory)
|
||||
}
|
||||
|
||||
const isWebDirectoryPickerSupported = () => {
|
||||
return !!(process.client && typeof window.showDirectoryPicker === 'function')
|
||||
}
|
||||
|
||||
const canExport = computed(() => {
|
||||
const hasExportTarget = isDesktopExportRuntime()
|
||||
? !!exportFolder.value
|
||||
: !!exportFolderHandle.value
|
||||
return !!selectedAccount.value && hasExportTarget && (exportTypes.friends || exportTypes.groups || exportTypes.officials)
|
||||
})
|
||||
|
||||
const safeExportPart = (value) => {
|
||||
const cleaned = String(value || '').trim().replace(/[^0-9A-Za-z._-]+/g, '_').replace(/^[._-]+|[._-]+$/g, '')
|
||||
return cleaned || 'account'
|
||||
}
|
||||
|
||||
const buildExportTimestamp = () => {
|
||||
const now = new Date()
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||
}
|
||||
|
||||
const escapeCsvCell = (value) => {
|
||||
const text = String(value == null ? '' : value)
|
||||
if (/[",\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`
|
||||
return text
|
||||
}
|
||||
|
||||
const buildExportContactsPayload = async () => {
|
||||
const resp = await api.listChatContacts({
|
||||
account: selectedAccount.value,
|
||||
keyword: searchKeyword.value || '',
|
||||
include_friends: exportTypes.friends,
|
||||
include_groups: exportTypes.groups,
|
||||
include_officials: exportTypes.officials,
|
||||
})
|
||||
const contactsList = Array.isArray(resp?.contacts) ? resp.contacts : []
|
||||
const exportContacts = contactsList.map((item) => {
|
||||
const row = {
|
||||
username: String(item?.username || ''),
|
||||
displayName: String(item?.displayName || ''),
|
||||
remark: String(item?.remark || ''),
|
||||
nickname: String(item?.nickname || ''),
|
||||
alias: String(item?.alias || ''),
|
||||
type: String(item?.type || ''),
|
||||
region: String(item?.region || ''),
|
||||
country: String(item?.country || ''),
|
||||
province: String(item?.province || ''),
|
||||
city: String(item?.city || ''),
|
||||
source: String(item?.source || ''),
|
||||
sourceScene: item?.sourceScene == null ? '' : String(item?.sourceScene),
|
||||
}
|
||||
if (includeAvatarLink.value) {
|
||||
row.avatarLink = String(item?.avatarLink || '')
|
||||
}
|
||||
return row
|
||||
})
|
||||
|
||||
return {
|
||||
account: String(selectedAccount.value || ''),
|
||||
count: exportContacts.length,
|
||||
contacts: exportContacts,
|
||||
}
|
||||
}
|
||||
|
||||
const writeWebExportFile = async ({ fileName, content }) => {
|
||||
if (!exportFolderHandle.value || typeof exportFolderHandle.value.getFileHandle !== 'function') {
|
||||
throw new Error('未选择浏览器导出目录')
|
||||
}
|
||||
const fileHandle = await exportFolderHandle.value.getFileHandle(fileName, { create: true })
|
||||
const writable = await fileHandle.createWritable()
|
||||
await writable.write(content)
|
||||
await writable.close()
|
||||
}
|
||||
|
||||
const exportContactsInWeb = async () => {
|
||||
const fmt = String(exportFormat.value || 'json').toLowerCase()
|
||||
if (fmt !== 'json' && fmt !== 'csv') {
|
||||
throw new Error('网页端仅支持 JSON/CSV 导出')
|
||||
}
|
||||
if (!exportFolderHandle.value) {
|
||||
throw new Error('请先选择导出目录')
|
||||
}
|
||||
|
||||
const payload = await buildExportContactsPayload()
|
||||
const fileName = `contacts_${safeExportPart(payload.account)}_${buildExportTimestamp()}.${fmt}`
|
||||
|
||||
if (fmt === 'json') {
|
||||
const jsonPayload = {
|
||||
exportedAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||
account: payload.account,
|
||||
count: payload.count,
|
||||
filters: {
|
||||
keyword: String(searchKeyword.value || ''),
|
||||
contactTypes: {
|
||||
friends: !!exportTypes.friends,
|
||||
groups: !!exportTypes.groups,
|
||||
officials: !!exportTypes.officials,
|
||||
},
|
||||
includeAvatarLink: !!includeAvatarLink.value,
|
||||
},
|
||||
contacts: payload.contacts,
|
||||
}
|
||||
await writeWebExportFile({ fileName, content: JSON.stringify(jsonPayload, null, 2) })
|
||||
} else {
|
||||
const columns = [
|
||||
['username', '用户名'],
|
||||
['displayName', '显示名称'],
|
||||
['remark', '备注'],
|
||||
['nickname', '昵称'],
|
||||
['alias', '微信号'],
|
||||
['type', '类型'],
|
||||
['region', '地区'],
|
||||
['country', '国家/地区码'],
|
||||
['province', '省份'],
|
||||
['city', '城市'],
|
||||
['source', '来源'],
|
||||
['sourceScene', '来源场景码'],
|
||||
]
|
||||
if (includeAvatarLink.value) {
|
||||
columns.push(['avatarLink', '头像链接'])
|
||||
}
|
||||
const lines = [columns.map(([, label]) => escapeCsvCell(label)).join(',')]
|
||||
for (const row of payload.contacts) {
|
||||
lines.push(columns.map(([key]) => escapeCsvCell(row[key])).join(','))
|
||||
}
|
||||
await writeWebExportFile({ fileName, content: `\uFEFF${lines.join('\n')}` })
|
||||
}
|
||||
|
||||
return {
|
||||
count: payload.count,
|
||||
outputPath: `${exportFolder.value}/${fileName}`,
|
||||
}
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
await chatAccounts.ensureLoaded({ force: true })
|
||||
}
|
||||
|
||||
const loadContacts = async () => {
|
||||
if (!selectedAccount.value) {
|
||||
contacts.value = []
|
||||
counts.friends = 0
|
||||
counts.groups = 0
|
||||
counts.officials = 0
|
||||
counts.total = 0
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const resp = await api.listChatContacts({
|
||||
account: selectedAccount.value,
|
||||
keyword: searchKeyword.value || '',
|
||||
include_friends: contactTypes.friends,
|
||||
include_groups: contactTypes.groups,
|
||||
include_officials: contactTypes.officials,
|
||||
})
|
||||
contacts.value = Array.isArray(resp?.contacts) ? resp.contacts : []
|
||||
counts.friends = Number(resp?.counts?.friends || 0)
|
||||
counts.groups = Number(resp?.counts?.groups || 0)
|
||||
counts.officials = Number(resp?.counts?.officials || 0)
|
||||
counts.total = Number(resp?.counts?.total || contacts.value.length)
|
||||
} catch (e) {
|
||||
contacts.value = []
|
||||
error.value = e?.message || '加载联系人失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let keywordTimer = null
|
||||
watch(() => searchKeyword.value, () => {
|
||||
if (keywordTimer) clearTimeout(keywordTimer)
|
||||
keywordTimer = setTimeout(() => {
|
||||
void loadContacts()
|
||||
}, 250)
|
||||
})
|
||||
|
||||
watch(() => [selectedAccount.value, contactTypes.friends, contactTypes.groups, contactTypes.officials], () => {
|
||||
void loadContacts()
|
||||
})
|
||||
|
||||
const chooseExportFolder = async () => {
|
||||
exportMsg.value = ''
|
||||
exportOk.value = false
|
||||
try {
|
||||
if (!process.client) {
|
||||
exportMsg.value = '当前环境不支持选择导出目录'
|
||||
return
|
||||
}
|
||||
|
||||
if (isDesktopExportRuntime()) {
|
||||
const result = await window.wechatDesktop.chooseDirectory({ title: '选择导出目录' })
|
||||
if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
|
||||
exportFolder.value = String(result.filePaths[0] || '')
|
||||
exportFolderHandle.value = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isWebDirectoryPickerSupported()) {
|
||||
const handle = await window.showDirectoryPicker()
|
||||
if (handle) {
|
||||
exportFolderHandle.value = handle
|
||||
exportFolder.value = `浏览器目录:${String(handle.name || '已选择')}`
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
exportMsg.value = '当前浏览器不支持目录选择,请使用桌面端或 Chromium 新版浏览器'
|
||||
} catch (e) {
|
||||
exportMsg.value = e?.message || '选择文件夹失败'
|
||||
exportOk.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startExport = async () => {
|
||||
exportMsg.value = ''
|
||||
exportOk.value = false
|
||||
|
||||
if (!canExport.value) {
|
||||
exportMsg.value = '请先选择账号、导出目录,并至少勾选一种联系人类型'
|
||||
return
|
||||
}
|
||||
|
||||
exporting.value = true
|
||||
try {
|
||||
const resp = isDesktopExportRuntime()
|
||||
? await api.exportChatContacts({
|
||||
account: selectedAccount.value,
|
||||
output_dir: exportFolder.value,
|
||||
format: exportFormat.value,
|
||||
include_avatar_link: includeAvatarLink.value,
|
||||
keyword: searchKeyword.value || '',
|
||||
contact_types: {
|
||||
friends: exportTypes.friends,
|
||||
groups: exportTypes.groups,
|
||||
officials: exportTypes.officials,
|
||||
}
|
||||
})
|
||||
: await exportContactsInWeb()
|
||||
exportOk.value = true
|
||||
exportMsg.value = `导出成功:${resp?.outputPath || ''}\n共 ${Number(resp?.count || 0)} 个联系人`
|
||||
} catch (e) {
|
||||
exportOk.value = false
|
||||
exportMsg.value = e?.message || '导出失败'
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
privacyStore.init()
|
||||
await loadAccounts()
|
||||
await loadContacts()
|
||||
})
|
||||
</script>
|
||||
@@ -26,24 +26,40 @@
|
||||
<!-- 密钥输入 -->
|
||||
<div>
|
||||
<label for="key" class="block text-sm font-medium text-[#000000e6] mb-2">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||
</svg>
|
||||
解密密钥 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="key"
|
||||
v-model="formData.key"
|
||||
type="text"
|
||||
placeholder="请输入64位十六进制密钥"
|
||||
class="w-full px-4 py-3 bg-white border border-[#EDEDED] rounded-lg font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160] focus:border-transparent transition-all duration-200"
|
||||
:class="{ 'border-red-500': formErrors.key }"
|
||||
required
|
||||
/>
|
||||
<div v-if="formData.key" class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<span class="text-xs text-[#7F7F7F]">{{ formData.key.length }}/64</span>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
id="key"
|
||||
v-model="formData.key"
|
||||
type="text"
|
||||
placeholder="请输入64位十六进制密钥"
|
||||
class="w-full px-4 py-3 bg-white border border-[#EDEDED] rounded-lg font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160] focus:border-transparent transition-all duration-200"
|
||||
:class="{ 'border-red-500': formErrors.key }"
|
||||
required
|
||||
/>
|
||||
<div v-if="formData.key" class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<span class="text-xs text-[#7F7F7F]">{{ formData.key.length }}/64</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="handleGetDbKey"
|
||||
:disabled="isGettingDbKey"
|
||||
class="flex-none inline-flex items-center px-4 py-3 bg-[#07C160] text-white rounded-lg text-sm font-medium hover:bg-[#06AD56] transition-all duration-200 disabled:opacity-50 disabled:cursor-wait whitespace-nowrap"
|
||||
>
|
||||
<svg v-if="isGettingDbKey" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ isGettingDbKey ? '获取中...' : '自动获取' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="formErrors.key" class="mt-1 text-sm text-red-600 flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -55,7 +71,7 @@
|
||||
<svg class="w-4 h-4 mr-1 text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
使用 <a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a> 等工具获取的64位十六进制字符串
|
||||
尝试自动获取,或者使用 <a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a> 等工具获取的64位十六进制字符串
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -131,6 +147,26 @@
|
||||
<!-- 填写密钥 -->
|
||||
<div class="mb-6">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
|
||||
<div class="flex justify-between items-center mb-4 pb-3 border-b border-gray-200">
|
||||
<span class="text-sm font-medium text-gray-500">手动输入或通过微信获取</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleGetImageKey"
|
||||
:disabled="isGettingImageKey"
|
||||
class="flex-none inline-flex items-center px-4 py-3 bg-[#07C160] text-white rounded-lg text-sm font-medium hover:bg-[#06AD56] transition-all duration-200 disabled:opacity-50 disabled:cursor-wait whitespace-nowrap"
|
||||
>
|
||||
<svg v-if="isGettingImageKey" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ isGettingImageKey ? '正在获取...' : '自动获取' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#000000e6] mb-2">XOR(必填)</label>
|
||||
@@ -158,7 +194,7 @@
|
||||
<svg class="w-4 h-4 mr-1 text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
使用 <a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a> 获取图片密钥;AES 可选(V4-V2 需要)
|
||||
尝试自动获取,或使用 <a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a> 获取图片密钥;AES 可选(V4-V2 需要)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -325,6 +361,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 警告渲染 -->
|
||||
<transition name="fade">
|
||||
<div v-if="warning" class="bg-amber-50 border border-amber-200 rounded-lg p-4 mt-6 flex items-start">
|
||||
<svg class="h-5 w-5 mr-2 flex-shrink-0 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold text-amber-800">温馨提示</p>
|
||||
<p class="text-sm mt-1 text-amber-700">{{ warning }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<transition name="fade">
|
||||
@@ -367,12 +416,15 @@
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
const { decryptDatabase, saveMediaKeys, getSavedKeys } = useApi()
|
||||
const { decryptDatabase, saveMediaKeys, getSavedKeys, getDbKey, getImageKey, getWxStatus } = useApi()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const warning = ref('') // 警告,用于密钥提示
|
||||
const currentStep = ref(0)
|
||||
const mediaAccount = ref('')
|
||||
const isGettingDbKey = ref(false)
|
||||
const isGettingImageKey = ref(false)
|
||||
|
||||
// 步骤定义
|
||||
const steps = [
|
||||
@@ -453,10 +505,89 @@ const prefillKeysForAccount = async (account) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleGetDbKey = async () => {
|
||||
if (isGettingDbKey.value) return
|
||||
isGettingDbKey.value = true
|
||||
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
formErrors.key = ''
|
||||
|
||||
try {
|
||||
const statusRes = await getWxStatus() // pid不是主进程,但是没关系
|
||||
const wxStatus = statusRes?.wx_status
|
||||
|
||||
if (wxStatus?.is_running) {
|
||||
warning.value = '检测到微信正在运行,5秒后将终止进程并重启以获取密钥!!'
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
} else {
|
||||
// 没有逻辑
|
||||
}
|
||||
|
||||
warning.value = '正在启动微信以获取密钥,请确保微信未开启“自动登录”,并在启动后 1 分钟内完成登录操作。'
|
||||
|
||||
const res = await getDbKey()
|
||||
|
||||
if (res && res.status === 0) {
|
||||
if (res.data?.db_key) {
|
||||
formData.key = res.data.db_key
|
||||
warning.value = ''
|
||||
}
|
||||
|
||||
if (res.errmsg && res.errmsg !== 'ok') {
|
||||
warning.value = res.errmsg
|
||||
}
|
||||
} else {
|
||||
error.value = '获取失败: ' + (res?.errmsg || '未知错误')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
error.value = '系统错误: ' + e.message
|
||||
} finally {
|
||||
isGettingDbKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleGetImageKey = async () => {
|
||||
if (isGettingImageKey.value) return
|
||||
isGettingImageKey.value = true
|
||||
manualKeyErrors.xor_key = ''
|
||||
manualKeyErrors.aes_key = ''
|
||||
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
|
||||
try {
|
||||
const res = await getImageKey()
|
||||
|
||||
if (res && res.status === 0) {
|
||||
if (res.data?.aes_key) {
|
||||
manualKeys.aes_key = res.data.aes_key
|
||||
}
|
||||
if (res.data?.xor_key) {
|
||||
// 后端记得处理为16进制再返回!!!
|
||||
manualKeys.xor_key = res.data.xor_key
|
||||
}
|
||||
|
||||
if (res.errmsg && res.errmsg !== 'ok') {
|
||||
error.value = res.errmsg
|
||||
}
|
||||
} else {
|
||||
error.value = '获取失败: ' + (res?.errmsg || '未知错误')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
error.value = '系统错误: ' + e.message
|
||||
} finally {
|
||||
isGettingImageKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyManualKeys = () => {
|
||||
manualKeyErrors.xor_key = ''
|
||||
manualKeyErrors.aes_key = ''
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
|
||||
const aes = normalizeAesKey(manualKeys.aes_key)
|
||||
if (!aes.ok) {
|
||||
@@ -550,6 +681,7 @@ const handleDecrypt = async () => {
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
|
||||
try {
|
||||
const result = await decryptDatabase({
|
||||
@@ -596,6 +728,7 @@ const decryptAllImages = async () => {
|
||||
mediaDecrypting.value = true
|
||||
mediaDecryptResult.value = null
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
|
||||
// 重置进度
|
||||
decryptProgress.current = 0
|
||||
@@ -671,6 +804,7 @@ const decryptAllImages = async () => {
|
||||
// 从密钥步骤进入图片解密步骤
|
||||
const goToMediaDecryptStep = async () => {
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
// 校验并应用(未填写则允许直接进入,后端会使用已保存密钥或报错提示)
|
||||
const ok = applyManualKeys()
|
||||
if (!ok || manualKeyErrors.xor_key || manualKeyErrors.aes_key) return
|
||||
|
||||
@@ -68,17 +68,12 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
const DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY = 'desktop.settings.defaultToChatWhenData'
|
||||
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
|
||||
onMounted(async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop) return
|
||||
|
||||
let enabled = false
|
||||
try {
|
||||
enabled = localStorage.getItem(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY) === 'true'
|
||||
} catch {}
|
||||
const enabled = readLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, false)
|
||||
if (!enabled) return
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||||
<div class="flex-1 min-h-0 overflow-auto p-6">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 bg-[#F7F7F7]">
|
||||
<div class="text-lg font-semibold text-gray-900">设置</div>
|
||||
<div class="text-sm text-gray-500 mt-1">桌面端相关行为与启动偏好</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-5">
|
||||
<div v-if="!isDesktopEnv" class="rounded-md border border-amber-200 bg-amber-50 text-amber-900 px-3 py-2 text-xs leading-5">
|
||||
当前为浏览器环境:“桌面行为”分组仅桌面端可用;“启动偏好”分组可正常使用。
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200">
|
||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<div class="text-sm font-medium text-gray-900">桌面行为</div>
|
||||
</div>
|
||||
<div class="px-4 py-3 space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">开机自启动</div>
|
||||
<div class="text-xs text-gray-500">系统登录后自动启动桌面端</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="desktopAutoLaunch"
|
||||
:disabled="!isDesktopEnv || desktopAutoLaunchLoading"
|
||||
@change="onDesktopAutoLaunchToggle"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="desktopAutoLaunchError" class="text-xs text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopAutoLaunchError }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">关闭窗口行为</div>
|
||||
<div class="text-xs text-gray-500">点击关闭按钮时:默认最小化到托盘</div>
|
||||
</div>
|
||||
<select
|
||||
class="text-sm px-2 py-1 rounded-md border border-gray-200 bg-white"
|
||||
:disabled="!isDesktopEnv || desktopCloseBehaviorLoading"
|
||||
:value="desktopCloseBehavior"
|
||||
@change="onDesktopCloseBehaviorChange"
|
||||
>
|
||||
<option value="tray">最小化到托盘</option>
|
||||
<option value="exit">直接退出</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="desktopCloseBehaviorError" class="text-xs text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopCloseBehaviorError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200">
|
||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<div class="text-sm font-medium text-gray-900">启动偏好</div>
|
||||
</div>
|
||||
<div class="px-4 py-3 space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">启动后自动开启实时获取</div>
|
||||
<div class="text-xs text-gray-500">进入聊天页后自动打开“实时开关”(默认关闭)</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="desktopAutoRealtime"
|
||||
@change="onDesktopAutoRealtimeToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">有数据时默认进入聊天页</div>
|
||||
<div class="text-xs text-gray-500">有已解密账号时,打开应用默认跳转到 /chat(默认关闭)</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="desktopDefaultToChatWhenData"
|
||||
@change="onDesktopDefaultToChatToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
|
||||
useHead({ title: '设置 - 微信数据分析助手' })
|
||||
|
||||
const isDesktopEnv = ref(false)
|
||||
|
||||
const desktopAutoRealtime = ref(false)
|
||||
const desktopDefaultToChatWhenData = ref(false)
|
||||
|
||||
const desktopAutoLaunch = ref(false)
|
||||
const desktopAutoLaunchLoading = ref(false)
|
||||
const desktopAutoLaunchError = ref('')
|
||||
|
||||
const desktopCloseBehavior = ref('tray')
|
||||
const desktopCloseBehaviorLoading = ref(false)
|
||||
const desktopCloseBehaviorError = ref('')
|
||||
|
||||
const refreshDesktopAutoLaunch = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getAutoLaunch) return
|
||||
desktopAutoLaunchLoading.value = true
|
||||
desktopAutoLaunchError.value = ''
|
||||
try {
|
||||
desktopAutoLaunch.value = !!(await window.wechatDesktop.getAutoLaunch())
|
||||
} catch (e) {
|
||||
desktopAutoLaunchError.value = e?.message || '读取开机自启动状态失败'
|
||||
} finally {
|
||||
desktopAutoLaunchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDesktopAutoLaunch = async (enabled) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.setAutoLaunch) return
|
||||
desktopAutoLaunchLoading.value = true
|
||||
desktopAutoLaunchError.value = ''
|
||||
try {
|
||||
desktopAutoLaunch.value = !!(await window.wechatDesktop.setAutoLaunch(!!enabled))
|
||||
} catch (e) {
|
||||
desktopAutoLaunchError.value = e?.message || '设置开机自启动失败'
|
||||
await refreshDesktopAutoLaunch()
|
||||
} finally {
|
||||
desktopAutoLaunchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDesktopCloseBehavior = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getCloseBehavior) return
|
||||
desktopCloseBehaviorLoading.value = true
|
||||
desktopCloseBehaviorError.value = ''
|
||||
try {
|
||||
const v = await window.wechatDesktop.getCloseBehavior()
|
||||
desktopCloseBehavior.value = String(v || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
|
||||
} catch (e) {
|
||||
desktopCloseBehaviorError.value = e?.message || '读取关闭窗口行为失败'
|
||||
} finally {
|
||||
desktopCloseBehaviorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDesktopCloseBehavior = async (behavior) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.setCloseBehavior) return
|
||||
const desired = String(behavior || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
|
||||
desktopCloseBehaviorLoading.value = true
|
||||
desktopCloseBehaviorError.value = ''
|
||||
try {
|
||||
const v = await window.wechatDesktop.setCloseBehavior(desired)
|
||||
desktopCloseBehavior.value = String(v || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
|
||||
} catch (e) {
|
||||
desktopCloseBehaviorError.value = e?.message || '设置关闭窗口行为失败'
|
||||
await refreshDesktopCloseBehavior()
|
||||
} finally {
|
||||
desktopCloseBehaviorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onDesktopAutoLaunchToggle = async (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
await setDesktopAutoLaunch(checked)
|
||||
}
|
||||
|
||||
const onDesktopCloseBehaviorChange = async (ev) => {
|
||||
const v = String(ev?.target?.value || '').trim()
|
||||
await setDesktopCloseBehavior(v)
|
||||
}
|
||||
|
||||
const onDesktopAutoRealtimeToggle = (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
desktopAutoRealtime.value = checked
|
||||
writeLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, checked)
|
||||
}
|
||||
|
||||
const onDesktopDefaultToChatToggle = (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
desktopDefaultToChatWhenData.value = checked
|
||||
writeLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, checked)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
isDesktopEnv.value = !!window.wechatDesktop
|
||||
}
|
||||
|
||||
desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false)
|
||||
desktopDefaultToChatWhenData.value = readLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, false)
|
||||
|
||||
if (isDesktopEnv.value) {
|
||||
await refreshDesktopAutoLaunch()
|
||||
await refreshDesktopCloseBehavior()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,126 +1,7 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<!-- 左侧边栏 -->
|
||||
<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>
|
||||
|
||||
<!-- 聊天图标 -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="聊天"
|
||||
@click="goChat"
|
||||
>
|
||||
<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="isChatRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<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-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="年度总结"
|
||||
@click="goWrapped"
|
||||
>
|
||||
<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="isWrappedRoute ? '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"
|
||||
>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<path d="M8 16v-5" />
|
||||
<path d="M12 16v-8" />
|
||||
<path d="M16 16v-3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐私模式按钮 -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="privacyMode = !privacyMode"
|
||||
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 右侧朋友圈区域 -->
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||||
<!-- 桌面端标题栏放在内容区(与聊天页一致) -->
|
||||
<DesktopTitleBar />
|
||||
|
||||
<div class="flex-1 overflow-auto min-h-0">
|
||||
<div class="max-w-2xl mx-auto px-4 py-4">
|
||||
<div v-if="error" class="text-sm text-red-500 whitespace-pre-wrap py-2">{{ error }}</div>
|
||||
@@ -405,38 +286,19 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
useHead({ title: '朋友圈 - 微信数据分析助手' })
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
|
||||
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
|
||||
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
|
||||
|
||||
// 隐私模式(聊天/朋友圈共用本地开关)
|
||||
const PRIVACY_MODE_KEY = 'ui.privacy_mode'
|
||||
const privacyMode = ref(false)
|
||||
|
||||
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 {}
|
||||
}
|
||||
)
|
||||
|
||||
const api = useApi()
|
||||
const selectedAccount = ref(null)
|
||||
const availableAccounts = ref([])
|
||||
|
||||
const chatAccounts = useChatAccountsStore()
|
||||
const { selectedAccount, accounts: availableAccounts } = storeToRefs(chatAccounts)
|
||||
|
||||
const privacyStore = usePrivacyStore()
|
||||
const { privacyMode } = storeToRefs(privacyStore)
|
||||
|
||||
const posts = ref([])
|
||||
const hasMore = ref(true)
|
||||
@@ -644,12 +506,6 @@ const onCopyPostJsonClick = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const selfAvatarUrl = computed(() => {
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc) return ''
|
||||
return `${mediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
|
||||
})
|
||||
|
||||
const postAvatarUrl = (username) => {
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
const u = String(username || '').trim()
|
||||
@@ -1005,15 +861,9 @@ const formatRelativeTime = (tsSeconds) => {
|
||||
|
||||
const loadAccounts = async () => {
|
||||
error.value = ''
|
||||
try {
|
||||
const resp = await api.listChatAccounts()
|
||||
const accounts = resp?.accounts || []
|
||||
availableAccounts.value = accounts
|
||||
selectedAccount.value = selectedAccount.value || resp?.default_account || accounts[0] || null
|
||||
} catch (e) {
|
||||
error.value = e?.message || '加载账号失败'
|
||||
availableAccounts.value = []
|
||||
selectedAccount.value = null
|
||||
await chatAccounts.ensureLoaded({ force: true })
|
||||
if (!selectedAccount.value) {
|
||||
error.value = chatAccounts.error || '未检测到已解密账号,请先解密数据库。'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,18 +893,6 @@ const loadPosts = async ({ reset }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const goChat = async () => {
|
||||
await navigateTo('/chat')
|
||||
}
|
||||
|
||||
const goSns = async () => {
|
||||
await navigateTo('/sns')
|
||||
}
|
||||
|
||||
const goWrapped = async () => {
|
||||
await navigateTo('/wrapped')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedAccount.value,
|
||||
async (v, oldV) => {
|
||||
@@ -1079,6 +917,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
privacyStore.init()
|
||||
await loadAccounts()
|
||||
loadSnsMediaOverrides()
|
||||
loadSnsSettings()
|
||||
@@ -1109,15 +948,3 @@ onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onGlobalKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 隐私模式模糊效果 */
|
||||
.privacy-blur {
|
||||
filter: blur(9px);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.privacy-blur:hover {
|
||||
filter: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 97 KiB |
@@ -0,0 +1,107 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
const SELECTED_ACCOUNT_KEY = 'ui.selected_account'
|
||||
|
||||
export const useChatAccountsStore = defineStore('chatAccounts', () => {
|
||||
const accounts = ref([])
|
||||
const selectedAccount = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const loaded = ref(false)
|
||||
|
||||
let loadPromise = null
|
||||
|
||||
const readSelectedAccount = () => {
|
||||
if (!process.client) return null
|
||||
try {
|
||||
const raw = localStorage.getItem(SELECTED_ACCOUNT_KEY)
|
||||
const v = String(raw || '').trim()
|
||||
return v || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const writeSelectedAccount = (value) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
const v = String(value || '').trim()
|
||||
if (!v) {
|
||||
localStorage.removeItem(SELECTED_ACCOUNT_KEY)
|
||||
return
|
||||
}
|
||||
localStorage.setItem(SELECTED_ACCOUNT_KEY, v)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const setSelectedAccount = (next) => {
|
||||
selectedAccount.value = next ? String(next) : null
|
||||
writeSelectedAccount(selectedAccount.value)
|
||||
}
|
||||
|
||||
if (process.client) {
|
||||
watch(selectedAccount, (next) => {
|
||||
writeSelectedAccount(next)
|
||||
})
|
||||
}
|
||||
|
||||
const ensureLoaded = async ({ force = false } = {}) => {
|
||||
if (!process.client) return
|
||||
if (loaded.value && !force) return
|
||||
|
||||
if (loadPromise && !force) {
|
||||
await loadPromise
|
||||
return
|
||||
}
|
||||
|
||||
loadPromise = (async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
if (!selectedAccount.value) {
|
||||
const cached = readSelectedAccount()
|
||||
if (cached) selectedAccount.value = cached
|
||||
}
|
||||
|
||||
try {
|
||||
const api = useApi()
|
||||
const resp = await api.listChatAccounts()
|
||||
const nextAccounts = Array.isArray(resp?.accounts) ? resp.accounts : []
|
||||
accounts.value = nextAccounts
|
||||
|
||||
const preferred = String(selectedAccount.value || '').trim()
|
||||
const defaultAccount = String(resp?.default_account || '').trim()
|
||||
const fallback = defaultAccount || nextAccounts[0] || ''
|
||||
const nextSelected = preferred && nextAccounts.includes(preferred) ? preferred : (fallback || null)
|
||||
|
||||
selectedAccount.value = nextSelected
|
||||
writeSelectedAccount(nextSelected)
|
||||
loaded.value = true
|
||||
} catch (e) {
|
||||
accounts.value = []
|
||||
selectedAccount.value = null
|
||||
writeSelectedAccount(null)
|
||||
loaded.value = true
|
||||
error.value = e?.message || '加载账号失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
await loadPromise
|
||||
} finally {
|
||||
loadPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accounts,
|
||||
selectedAccount,
|
||||
loading,
|
||||
error,
|
||||
loaded,
|
||||
ensureLoaded,
|
||||
setSelectedAccount,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
|
||||
export const useChatRealtimeStore = defineStore('chatRealtime', () => {
|
||||
const chatAccounts = useChatAccountsStore()
|
||||
|
||||
const enabled = ref(false)
|
||||
const available = ref(false)
|
||||
const checking = ref(false)
|
||||
const statusInfo = ref(null)
|
||||
const statusError = ref('')
|
||||
const toggling = ref(false)
|
||||
const toggleSeq = ref(0)
|
||||
const lastToggleAction = ref('')
|
||||
const changeSeq = ref(0)
|
||||
const priorityUsername = ref('')
|
||||
|
||||
let eventSource = null
|
||||
let changeDebounceTimer = null
|
||||
|
||||
const getAccount = () => String(chatAccounts.selectedAccount || '').trim()
|
||||
|
||||
const setPriorityUsername = (username) => {
|
||||
priorityUsername.value = String(username || '').trim()
|
||||
}
|
||||
|
||||
const ensureReadyAccount = async () => {
|
||||
if (!process.client) return false
|
||||
await chatAccounts.ensureLoaded()
|
||||
return !!getAccount()
|
||||
}
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!process.client) return
|
||||
const account = getAccount()
|
||||
if (!account) {
|
||||
available.value = false
|
||||
statusInfo.value = null
|
||||
statusError.value = '未检测到已解密账号,请先解密数据库。'
|
||||
return
|
||||
}
|
||||
|
||||
const api = useApi()
|
||||
checking.value = true
|
||||
statusError.value = ''
|
||||
try {
|
||||
const resp = await api.getChatRealtimeStatus({ account })
|
||||
available.value = !!resp?.available
|
||||
statusInfo.value = resp?.realtime || null
|
||||
statusError.value = ''
|
||||
} catch (e) {
|
||||
available.value = false
|
||||
statusInfo.value = null
|
||||
statusError.value = e?.message || '实时状态获取失败'
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stopStream = () => {
|
||||
if (eventSource) {
|
||||
try {
|
||||
eventSource.close()
|
||||
} catch {}
|
||||
eventSource = null
|
||||
}
|
||||
if (changeDebounceTimer) {
|
||||
try {
|
||||
clearTimeout(changeDebounceTimer)
|
||||
} catch {}
|
||||
changeDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const bumpChangeSeqDebounced = () => {
|
||||
if (changeDebounceTimer) return
|
||||
changeDebounceTimer = setTimeout(() => {
|
||||
changeDebounceTimer = null
|
||||
changeSeq.value += 1
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const startStream = () => {
|
||||
stopStream()
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!enabled.value) return
|
||||
const account = getAccount()
|
||||
if (!account) return
|
||||
if (typeof EventSource === 'undefined') return
|
||||
|
||||
const base = 'http://localhost:8000'
|
||||
const url = `${base}/api/chat/realtime/stream?account=${encodeURIComponent(account)}`
|
||||
|
||||
try {
|
||||
eventSource = new EventSource(url)
|
||||
} catch {
|
||||
eventSource = null
|
||||
return
|
||||
}
|
||||
|
||||
eventSource.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(String(ev.data || '{}'))
|
||||
if (String(data?.type || '') === 'change') {
|
||||
bumpChangeSeqDebounced()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
// Keep `enabled` as-is; same behavior as the old in-page implementation.
|
||||
stopStream()
|
||||
}
|
||||
}
|
||||
|
||||
const enable = async ({ silent = false } = {}) => {
|
||||
if (toggling.value) return false
|
||||
toggling.value = true
|
||||
try {
|
||||
const ok = await ensureReadyAccount()
|
||||
if (!ok) {
|
||||
if (!silent && process.client && typeof window !== 'undefined') {
|
||||
window.alert('未检测到已解密账号,请先解密数据库。')
|
||||
}
|
||||
statusError.value = '未检测到已解密账号,请先解密数据库。'
|
||||
return false
|
||||
}
|
||||
|
||||
await fetchStatus()
|
||||
if (!available.value) {
|
||||
if (!silent && process.client && typeof window !== 'undefined') {
|
||||
window.alert(statusError.value || '实时模式不可用:缺少密钥或 db_storage 路径。')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
enabled.value = true
|
||||
startStream()
|
||||
lastToggleAction.value = 'enabled'
|
||||
toggleSeq.value += 1
|
||||
return true
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const disable = async ({ silent = false } = {}) => {
|
||||
if (toggling.value) return false
|
||||
toggling.value = true
|
||||
try {
|
||||
const account = getAccount()
|
||||
enabled.value = false
|
||||
stopStream()
|
||||
|
||||
if (!account) {
|
||||
lastToggleAction.value = 'disabled'
|
||||
toggleSeq.value += 1
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const api = useApi()
|
||||
await api.syncChatRealtimeAll({
|
||||
account,
|
||||
max_scan: 200,
|
||||
priority_username: priorityUsername.value || '',
|
||||
priority_max_scan: 5000,
|
||||
include_hidden: true,
|
||||
include_official: true,
|
||||
})
|
||||
} catch (e) {
|
||||
if (!silent && process.client && typeof window !== 'undefined') {
|
||||
window.alert(e?.message || '关闭实时模式时同步失败')
|
||||
}
|
||||
}
|
||||
|
||||
lastToggleAction.value = 'disabled'
|
||||
toggleSeq.value += 1
|
||||
return true
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (opts = {}) => {
|
||||
return enabled.value ? await disable(opts) : await enable(opts)
|
||||
}
|
||||
|
||||
if (process.client) {
|
||||
watch(
|
||||
() => chatAccounts.selectedAccount,
|
||||
async () => {
|
||||
setPriorityUsername('')
|
||||
await fetchStatus()
|
||||
if (enabled.value) {
|
||||
startStream()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
available,
|
||||
checking,
|
||||
statusInfo,
|
||||
statusError,
|
||||
toggling,
|
||||
toggleSeq,
|
||||
lastToggleAction,
|
||||
changeSeq,
|
||||
priorityUsername,
|
||||
|
||||
setPriorityUsername,
|
||||
ensureReadyAccount,
|
||||
fetchStatus,
|
||||
startStream,
|
||||
stopStream,
|
||||
enable,
|
||||
disable,
|
||||
toggle,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { readPrivacyMode, writePrivacyMode } from '~/utils/privacy-mode'
|
||||
|
||||
export const usePrivacyStore = defineStore('privacy', () => {
|
||||
const privacyMode = ref(false)
|
||||
const initialized = ref(false)
|
||||
|
||||
const init = () => {
|
||||
if (initialized.value) return
|
||||
initialized.value = true
|
||||
privacyMode.value = readPrivacyMode(false)
|
||||
}
|
||||
|
||||
const set = (enabled) => {
|
||||
privacyMode.value = !!enabled
|
||||
writePrivacyMode(privacyMode.value)
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
set(!privacyMode.value)
|
||||
}
|
||||
|
||||
return {
|
||||
privacyMode,
|
||||
init,
|
||||
set,
|
||||
toggle,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export const DESKTOP_SETTING_AUTO_REALTIME_KEY = 'desktop.settings.autoRealtime'
|
||||
export const DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY = 'desktop.settings.defaultToChatWhenData'
|
||||
|
||||
export const readLocalBoolSetting = (key, fallback = false) => {
|
||||
if (!process.client) return !!fallback
|
||||
try {
|
||||
const raw = localStorage.getItem(String(key || ''))
|
||||
if (raw == null) return !!fallback
|
||||
return String(raw).toLowerCase() === 'true'
|
||||
} catch {
|
||||
return !!fallback
|
||||
}
|
||||
}
|
||||
|
||||
export const writeLocalBoolSetting = (key, value) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(String(key || ''), value ? 'true' : 'false')
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const PRIVACY_MODE_KEY = 'ui.privacy_mode'
|
||||
|
||||
export const readPrivacyMode = (fallback = false) => {
|
||||
if (!process.client) return !!fallback
|
||||
try {
|
||||
const raw = localStorage.getItem(PRIVACY_MODE_KEY)
|
||||
if (raw == null) return !!fallback
|
||||
const normalized = String(raw).trim().toLowerCase()
|
||||
return normalized === '1' || normalized === 'true'
|
||||
} catch {
|
||||
return !!fallback
|
||||
}
|
||||
}
|
||||
|
||||
export const writePrivacyMode = (enabled) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(PRIVACY_MODE_KEY, enabled ? '1' : '0')
|
||||
} catch {}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ dependencies = [
|
||||
"zstandard>=0.23.0",
|
||||
"pilk>=0.2.4",
|
||||
"pypinyin>=0.53.0",
|
||||
"wx_key",
|
||||
"packaging",
|
||||
"httpx",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -40,3 +43,6 @@ include = [
|
||||
"src/wechat_decrypt_tool/native/wcdb_api.dll",
|
||||
"src/wechat_decrypt_tool/native/WCDB.dll",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
find-links = ["./tools/key_wheels/"]
|
||||
|
||||
@@ -13,6 +13,7 @@ from .logging_config import setup_logging, get_logger
|
||||
from .path_fix import PathFixRoute
|
||||
from .chat_realtime_autosync import CHAT_REALTIME_AUTOSYNC
|
||||
from .routers.chat import router as _chat_router
|
||||
from .routers.chat_contacts import router as _chat_contacts_router
|
||||
from .routers.chat_export import router as _chat_export_router
|
||||
from .routers.chat_media import router as _chat_media_router
|
||||
from .routers.decrypt import router as _decrypt_router
|
||||
@@ -52,6 +53,7 @@ app.include_router(_decrypt_router)
|
||||
app.include_router(_keys_router)
|
||||
app.include_router(_media_router)
|
||||
app.include_router(_chat_router)
|
||||
app.include_router(_chat_contacts_router)
|
||||
app.include_router(_chat_export_router)
|
||||
app.include_router(_chat_media_router)
|
||||
app.include_router(_sns_router)
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from email.utils import formatdate
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from .app_paths import get_output_dir
|
||||
from .logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
AVATAR_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
|
||||
def is_avatar_cache_enabled() -> bool:
|
||||
v = str(os.environ.get("WECHAT_TOOL_AVATAR_CACHE_ENABLED", "1") or "").strip().lower()
|
||||
return v not in {"", "0", "false", "off", "no"}
|
||||
|
||||
|
||||
def get_avatar_cache_root_dir() -> Path:
|
||||
return get_output_dir() / "avatar_cache"
|
||||
|
||||
|
||||
def _safe_segment(value: str) -> str:
|
||||
cleaned = re.sub(r"[^0-9A-Za-z._-]+", "_", str(value or "").strip())
|
||||
cleaned = cleaned.strip("._-")
|
||||
return cleaned or "default"
|
||||
|
||||
|
||||
def _account_layout(account: str) -> tuple[Path, Path, Path, Path]:
|
||||
account_dir = get_avatar_cache_root_dir() / _safe_segment(account)
|
||||
files_dir = account_dir / "files"
|
||||
tmp_dir = account_dir / "tmp"
|
||||
db_path = account_dir / "avatar_cache.db"
|
||||
return account_dir, files_dir, tmp_dir, db_path
|
||||
|
||||
|
||||
def _ensure_account_layout(account: str) -> tuple[Path, Path, Path, Path]:
|
||||
account_dir, files_dir, tmp_dir, db_path = _account_layout(account)
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
files_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
return account_dir, files_dir, tmp_dir, db_path
|
||||
|
||||
|
||||
def _connect(account: str) -> sqlite3.Connection:
|
||||
_, _, _, db_path = _ensure_account_layout(account)
|
||||
conn = sqlite3.connect(str(db_path), timeout=5)
|
||||
conn.row_factory = sqlite3.Row
|
||||
_ensure_schema(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS avatar_cache_entries (
|
||||
account TEXT NOT NULL,
|
||||
cache_key TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
source_md5 TEXT NOT NULL DEFAULT '',
|
||||
source_update_time INTEGER NOT NULL DEFAULT 0,
|
||||
rel_path TEXT NOT NULL DEFAULT '',
|
||||
media_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
etag TEXT NOT NULL DEFAULT '',
|
||||
last_modified TEXT NOT NULL DEFAULT '',
|
||||
fetched_at INTEGER NOT NULL DEFAULT 0,
|
||||
checked_at INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (account, cache_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_avatar_cache_entries_account_username ON avatar_cache_entries(account, username)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_avatar_cache_entries_account_source ON avatar_cache_entries(account, source_kind, source_url)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _row_to_dict(row: Optional[sqlite3.Row]) -> Optional[dict[str, Any]]:
|
||||
if row is None:
|
||||
return None
|
||||
out: dict[str, Any] = {}
|
||||
for k in row.keys():
|
||||
out[str(k)] = row[k]
|
||||
return out
|
||||
|
||||
|
||||
def normalize_avatar_source_url(url: str) -> str:
|
||||
raw = str(url or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
try:
|
||||
p = urlsplit(raw)
|
||||
except Exception:
|
||||
return raw
|
||||
scheme = str(p.scheme or "").lower()
|
||||
host = str(p.hostname or "").lower()
|
||||
if not scheme or not host:
|
||||
return raw
|
||||
netloc = host
|
||||
if p.port:
|
||||
netloc = f"{host}:{int(p.port)}"
|
||||
path = p.path or "/"
|
||||
return urlunsplit((scheme, netloc, path, p.query or "", ""))
|
||||
|
||||
|
||||
def cache_key_for_avatar_user(username: str) -> str:
|
||||
u = str(username or "").strip()
|
||||
return hashlib.sha1(f"user:{u}".encode("utf-8", errors="ignore")).hexdigest()
|
||||
|
||||
|
||||
def cache_key_for_avatar_url(url: str) -> str:
|
||||
u = normalize_avatar_source_url(url)
|
||||
return hashlib.sha1(f"url:{u}".encode("utf-8", errors="ignore")).hexdigest()
|
||||
|
||||
|
||||
def get_avatar_cache_entry(account: str, cache_key: str) -> Optional[dict[str, Any]]:
|
||||
if (not is_avatar_cache_enabled()) or (not cache_key):
|
||||
return None
|
||||
try:
|
||||
conn = _connect(account)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM avatar_cache_entries WHERE account = ? AND cache_key = ? LIMIT 1",
|
||||
(str(account or ""), str(cache_key or "")),
|
||||
).fetchone()
|
||||
return _row_to_dict(row)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_avatar_cache_user_entry(account: str, username: str) -> Optional[dict[str, Any]]:
|
||||
if not username:
|
||||
return None
|
||||
return get_avatar_cache_entry(account, cache_key_for_avatar_user(username))
|
||||
|
||||
|
||||
def get_avatar_cache_url_entry(account: str, source_url: str) -> Optional[dict[str, Any]]:
|
||||
if not source_url:
|
||||
return None
|
||||
return get_avatar_cache_entry(account, cache_key_for_avatar_url(source_url))
|
||||
|
||||
|
||||
def resolve_avatar_cache_entry_path(account: str, entry: Optional[dict[str, Any]]) -> Optional[Path]:
|
||||
if not entry:
|
||||
return None
|
||||
rel = str(entry.get("rel_path") or "").strip().replace("\\", "/")
|
||||
if not rel:
|
||||
return None
|
||||
account_dir, _, _, _ = _account_layout(account)
|
||||
p = account_dir / rel
|
||||
try:
|
||||
account_dir_resolved = account_dir.resolve()
|
||||
p_resolved = p.resolve()
|
||||
if p_resolved != account_dir_resolved and account_dir_resolved not in p_resolved.parents:
|
||||
return None
|
||||
return p_resolved
|
||||
except Exception:
|
||||
return p
|
||||
|
||||
|
||||
def avatar_cache_entry_file_exists(account: str, entry: Optional[dict[str, Any]]) -> Optional[Path]:
|
||||
p = resolve_avatar_cache_entry_path(account, entry)
|
||||
if not p:
|
||||
return None
|
||||
try:
|
||||
if p.exists() and p.is_file():
|
||||
return p
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def avatar_cache_entry_is_fresh(entry: Optional[dict[str, Any]], now_ts: Optional[int] = None) -> bool:
|
||||
if not entry:
|
||||
return False
|
||||
try:
|
||||
expires = int(entry.get("expires_at") or 0)
|
||||
except Exception:
|
||||
expires = 0
|
||||
if expires <= 0:
|
||||
return False
|
||||
now0 = int(now_ts or time.time())
|
||||
return expires > now0
|
||||
|
||||
|
||||
def _guess_ext(media_type: str) -> str:
|
||||
mt = str(media_type or "").strip().lower()
|
||||
if mt == "image/jpeg":
|
||||
return "jpg"
|
||||
if mt == "image/png":
|
||||
return "png"
|
||||
if mt == "image/gif":
|
||||
return "gif"
|
||||
if mt == "image/webp":
|
||||
return "webp"
|
||||
if mt == "image/bmp":
|
||||
return "bmp"
|
||||
if mt == "image/svg+xml":
|
||||
return "svg"
|
||||
if mt == "image/avif":
|
||||
return "avif"
|
||||
if mt.startswith("image/"):
|
||||
return mt.split("/", 1)[1].split("+", 1)[0].split(";", 1)[0] or "img"
|
||||
return "dat"
|
||||
|
||||
|
||||
def _http_date_from_ts(ts: Optional[int]) -> str:
|
||||
try:
|
||||
t = int(ts or 0)
|
||||
except Exception:
|
||||
t = 0
|
||||
if t <= 0:
|
||||
return ""
|
||||
try:
|
||||
return formatdate(timeval=float(t), usegmt=True)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def upsert_avatar_cache_entry(
|
||||
account: str,
|
||||
*,
|
||||
cache_key: str,
|
||||
source_kind: str,
|
||||
username: str = "",
|
||||
source_url: str = "",
|
||||
source_md5: str = "",
|
||||
source_update_time: int = 0,
|
||||
rel_path: str = "",
|
||||
media_type: str = "application/octet-stream",
|
||||
size_bytes: int = 0,
|
||||
etag: str = "",
|
||||
last_modified: str = "",
|
||||
fetched_at: Optional[int] = None,
|
||||
checked_at: Optional[int] = None,
|
||||
expires_at: Optional[int] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
if (not is_avatar_cache_enabled()) or (not cache_key):
|
||||
return None
|
||||
|
||||
acct = str(account or "").strip()
|
||||
ck = str(cache_key or "").strip()
|
||||
sk = str(source_kind or "").strip().lower()
|
||||
if not acct or not ck or not sk:
|
||||
return None
|
||||
|
||||
source_url_norm = normalize_avatar_source_url(source_url) if source_url else ""
|
||||
|
||||
now_ts = int(time.time())
|
||||
fetched = int(fetched_at if fetched_at is not None else now_ts)
|
||||
checked = int(checked_at if checked_at is not None else now_ts)
|
||||
expire_ts = int(expires_at if expires_at is not None else (checked + AVATAR_CACHE_TTL_SECONDS))
|
||||
|
||||
try:
|
||||
conn = _connect(acct)
|
||||
except Exception as e:
|
||||
logger.warning(f"[avatar_cache_error] open db failed account={acct} err={e}")
|
||||
return None
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO avatar_cache_entries (
|
||||
account, cache_key, source_kind, username, source_url,
|
||||
source_md5, source_update_time, rel_path, media_type, size_bytes,
|
||||
etag, last_modified, fetched_at, checked_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account, cache_key) DO UPDATE SET
|
||||
source_kind=excluded.source_kind,
|
||||
username=excluded.username,
|
||||
source_url=excluded.source_url,
|
||||
source_md5=excluded.source_md5,
|
||||
source_update_time=excluded.source_update_time,
|
||||
rel_path=excluded.rel_path,
|
||||
media_type=excluded.media_type,
|
||||
size_bytes=excluded.size_bytes,
|
||||
etag=excluded.etag,
|
||||
last_modified=excluded.last_modified,
|
||||
fetched_at=excluded.fetched_at,
|
||||
checked_at=excluded.checked_at,
|
||||
expires_at=excluded.expires_at
|
||||
""",
|
||||
(
|
||||
acct,
|
||||
ck,
|
||||
sk,
|
||||
str(username or "").strip(),
|
||||
source_url_norm,
|
||||
str(source_md5 or "").strip().lower(),
|
||||
int(source_update_time or 0),
|
||||
str(rel_path or "").strip().replace("\\", "/"),
|
||||
str(media_type or "application/octet-stream").strip() or "application/octet-stream",
|
||||
int(size_bytes or 0),
|
||||
str(etag or "").strip(),
|
||||
str(last_modified or "").strip(),
|
||||
fetched,
|
||||
checked,
|
||||
expire_ts,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM avatar_cache_entries WHERE account = ? AND cache_key = ? LIMIT 1",
|
||||
(acct, ck),
|
||||
).fetchone()
|
||||
return _row_to_dict(row)
|
||||
except Exception as e:
|
||||
logger.warning(f"[avatar_cache_error] upsert failed account={acct} cache_key={ck} err={e}")
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def touch_avatar_cache_entry(account: str, cache_key: str, *, ttl_seconds: int = AVATAR_CACHE_TTL_SECONDS) -> bool:
|
||||
if (not is_avatar_cache_enabled()) or (not cache_key):
|
||||
return False
|
||||
now_ts = int(time.time())
|
||||
try:
|
||||
conn = _connect(account)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE avatar_cache_entries SET checked_at = ?, expires_at = ? WHERE account = ? AND cache_key = ?",
|
||||
(now_ts, now_ts + max(60, int(ttl_seconds or AVATAR_CACHE_TTL_SECONDS)), str(account or ""), str(cache_key or "")),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def write_avatar_cache_payload(
|
||||
account: str,
|
||||
*,
|
||||
source_kind: str,
|
||||
username: str = "",
|
||||
source_url: str = "",
|
||||
payload: bytes,
|
||||
media_type: str,
|
||||
source_md5: str = "",
|
||||
source_update_time: int = 0,
|
||||
etag: str = "",
|
||||
last_modified: str = "",
|
||||
ttl_seconds: int = AVATAR_CACHE_TTL_SECONDS,
|
||||
) -> tuple[Optional[dict[str, Any]], Optional[Path]]:
|
||||
if (not is_avatar_cache_enabled()) or (not payload):
|
||||
return None, None
|
||||
|
||||
acct = str(account or "").strip()
|
||||
sk = str(source_kind or "").strip().lower()
|
||||
if not acct or sk not in {"user", "url"}:
|
||||
return None, None
|
||||
|
||||
source_url_norm = normalize_avatar_source_url(source_url) if source_url else ""
|
||||
if sk == "user":
|
||||
cache_key = cache_key_for_avatar_user(username)
|
||||
else:
|
||||
cache_key = cache_key_for_avatar_url(source_url_norm)
|
||||
|
||||
digest = hashlib.sha1(bytes(payload)).hexdigest()
|
||||
ext = _guess_ext(media_type)
|
||||
rel_path = f"files/{digest[:2]}/{digest}.{ext}"
|
||||
|
||||
try:
|
||||
account_dir, _, tmp_dir, _ = _ensure_account_layout(acct)
|
||||
except Exception as e:
|
||||
logger.warning(f"[avatar_cache_error] ensure dirs failed account={acct} err={e}")
|
||||
return None, None
|
||||
|
||||
abs_path = account_dir / rel_path
|
||||
try:
|
||||
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if (not abs_path.exists()) or (int(abs_path.stat().st_size) != len(payload)):
|
||||
tmp_path = tmp_dir / f"{digest}.{time.time_ns()}.tmp"
|
||||
tmp_path.write_bytes(payload)
|
||||
os.replace(str(tmp_path), str(abs_path))
|
||||
except Exception as e:
|
||||
logger.warning(f"[avatar_cache_error] write file failed account={acct} path={abs_path} err={e}")
|
||||
return None, None
|
||||
|
||||
if (not etag) and digest:
|
||||
etag = f'"{digest}"'
|
||||
if (not last_modified) and source_update_time:
|
||||
last_modified = _http_date_from_ts(source_update_time)
|
||||
if not last_modified:
|
||||
last_modified = _http_date_from_ts(int(time.time()))
|
||||
|
||||
entry = upsert_avatar_cache_entry(
|
||||
acct,
|
||||
cache_key=cache_key,
|
||||
source_kind=sk,
|
||||
username=username,
|
||||
source_url=source_url_norm,
|
||||
source_md5=source_md5,
|
||||
source_update_time=int(source_update_time or 0),
|
||||
rel_path=rel_path,
|
||||
media_type=media_type,
|
||||
size_bytes=len(payload),
|
||||
etag=etag,
|
||||
last_modified=last_modified,
|
||||
fetched_at=int(time.time()),
|
||||
checked_at=int(time.time()),
|
||||
expires_at=int(time.time()) + max(60, int(ttl_seconds or AVATAR_CACHE_TTL_SECONDS)),
|
||||
)
|
||||
if not entry:
|
||||
return None, None
|
||||
return entry, abs_path
|
||||
|
||||
|
||||
def build_avatar_cache_response_headers(
|
||||
entry: Optional[dict[str, Any]], *, max_age: int = AVATAR_CACHE_TTL_SECONDS
|
||||
) -> dict[str, str]:
|
||||
headers: dict[str, str] = {
|
||||
"Cache-Control": f"public, max-age={int(max_age)}",
|
||||
}
|
||||
if not entry:
|
||||
return headers
|
||||
etag = str(entry.get("etag") or "").strip()
|
||||
last_modified = str(entry.get("last_modified") or "").strip()
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
if last_modified:
|
||||
headers["Last-Modified"] = last_modified
|
||||
return headers
|
||||
|
||||
@@ -28,6 +28,7 @@ from .chat_helpers import (
|
||||
_load_contact_rows,
|
||||
_lookup_resource_md5,
|
||||
_parse_app_message,
|
||||
_parse_system_message_content,
|
||||
_parse_pat_message,
|
||||
_pick_display_name,
|
||||
_quote_ident,
|
||||
@@ -74,6 +75,25 @@ def _safe_name(s: str, max_len: int = 80) -> str:
|
||||
return t
|
||||
|
||||
|
||||
def _resolve_export_output_dir(account_dir: Path, output_dir_raw: Any) -> Path:
|
||||
text = str(output_dir_raw or "").strip()
|
||||
if not text:
|
||||
default_dir = account_dir.parents[1] / "exports" / account_dir.name
|
||||
default_dir.mkdir(parents=True, exist_ok=True)
|
||||
return default_dir
|
||||
|
||||
out_dir = Path(text).expanduser()
|
||||
if not out_dir.is_absolute():
|
||||
raise ValueError("output_dir must be an absolute path.")
|
||||
|
||||
try:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to prepare output_dir: {e}") from e
|
||||
|
||||
return out_dir.resolve()
|
||||
|
||||
|
||||
def _format_ts(ts: int) -> str:
|
||||
if not ts:
|
||||
return ""
|
||||
@@ -99,43 +119,54 @@ def _normalize_render_type_key(value: Any) -> str:
|
||||
return lower
|
||||
|
||||
|
||||
def _render_types_to_local_types(render_types: set[str]) -> Optional[set[int]]:
|
||||
rt = {str(x or "").strip() for x in (render_types or set())}
|
||||
rt = {x for x in rt if x}
|
||||
if not rt:
|
||||
def _is_render_type_selected(render_type: Any, selected_render_types: Optional[set[str]]) -> bool:
|
||||
if selected_render_types is None:
|
||||
return True
|
||||
rt = _normalize_render_type_key(render_type) or "text"
|
||||
return rt in selected_render_types
|
||||
|
||||
|
||||
def _media_kinds_from_selected_types(selected_render_types: Optional[set[str]]) -> Optional[set[MediaKind]]:
|
||||
if selected_render_types is None:
|
||||
return None
|
||||
|
||||
out: set[int] = set()
|
||||
for k in rt:
|
||||
if k == "text":
|
||||
out.add(1)
|
||||
elif k == "image":
|
||||
out.add(3)
|
||||
elif k == "voice":
|
||||
out.add(34)
|
||||
elif k == "video":
|
||||
out.update({43, 62})
|
||||
elif k == "emoji":
|
||||
out.add(47)
|
||||
elif k == "voip":
|
||||
out.add(50)
|
||||
elif k == "system":
|
||||
out.update({10000, 266287972401})
|
||||
elif k == "quote":
|
||||
out.add(244813135921)
|
||||
out.add(49) # Some quote messages are embedded as appmsg (local_type=49).
|
||||
elif k in {"link", "file", "transfer", "redpacket"}:
|
||||
out.add(49)
|
||||
else:
|
||||
# Unknown type: cannot safely prefilter by local_type.
|
||||
return None
|
||||
out: set[MediaKind] = set()
|
||||
if "image" in selected_render_types:
|
||||
out.add("image")
|
||||
if "emoji" in selected_render_types:
|
||||
out.add("emoji")
|
||||
if "video" in selected_render_types:
|
||||
out.add("video")
|
||||
out.add("video_thumb")
|
||||
if "voice" in selected_render_types:
|
||||
out.add("voice")
|
||||
if "file" in selected_render_types:
|
||||
out.add("file")
|
||||
return out
|
||||
|
||||
|
||||
def _should_estimate_by_local_type(render_types: set[str]) -> bool:
|
||||
# Only estimate counts when every requested type maps 1:1 to local_type.
|
||||
# App messages (local_type=49) are heterogeneous and cannot be counted accurately without parsing.
|
||||
return not bool(render_types & {"link", "file", "transfer", "redpacket", "quote"})
|
||||
def _resolve_effective_media_kinds(
|
||||
*,
|
||||
include_media: bool,
|
||||
media_kinds: list[MediaKind],
|
||||
selected_render_types: Optional[set[str]],
|
||||
privacy_mode: bool,
|
||||
) -> tuple[bool, list[MediaKind]]:
|
||||
if privacy_mode or (not include_media):
|
||||
return False, []
|
||||
|
||||
kinds = [k for k in media_kinds if k in {"image", "emoji", "video", "video_thumb", "voice", "file"}]
|
||||
if not kinds:
|
||||
return False, []
|
||||
|
||||
selected_media_kinds = _media_kinds_from_selected_types(selected_render_types)
|
||||
if selected_media_kinds is not None:
|
||||
kinds = [k for k in kinds if k in selected_media_kinds]
|
||||
|
||||
kinds = list(dict.fromkeys(kinds))
|
||||
if not kinds:
|
||||
return False, []
|
||||
return True, kinds
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -235,6 +266,7 @@ class ChatExportManager:
|
||||
include_media: bool,
|
||||
media_kinds: list[MediaKind],
|
||||
message_types: list[str],
|
||||
output_dir: Optional[str],
|
||||
allow_process_key_extract: bool,
|
||||
privacy_mode: bool,
|
||||
file_name: Optional[str],
|
||||
@@ -257,6 +289,7 @@ class ChatExportManager:
|
||||
"includeMedia": bool(include_media),
|
||||
"mediaKinds": media_kinds,
|
||||
"messageTypes": list(dict.fromkeys([str(t or "").strip() for t in (message_types or []) if str(t or "").strip()])),
|
||||
"outputDir": str(output_dir or "").strip(),
|
||||
"allowProcessKeyExtract": bool(allow_process_key_extract),
|
||||
"privacyMode": bool(privacy_mode),
|
||||
"fileName": str(file_name or "").strip(),
|
||||
@@ -313,10 +346,6 @@ class ChatExportManager:
|
||||
if ks in {"image", "emoji", "video", "video_thumb", "voice", "file"}:
|
||||
media_kinds.append(ks) # type: ignore[arg-type]
|
||||
|
||||
if privacy_mode:
|
||||
include_media = False
|
||||
media_kinds = []
|
||||
|
||||
st = int(opts.get("startTime") or 0) or None
|
||||
et = int(opts.get("endTime") or 0) or None
|
||||
|
||||
@@ -328,9 +357,15 @@ class ChatExportManager:
|
||||
if want:
|
||||
want_types = want
|
||||
|
||||
local_types = _render_types_to_local_types(want_types) if want_types else None
|
||||
can_estimate = (want_types is None) or _should_estimate_by_local_type(want_types)
|
||||
estimate_local_types = local_types if (want_types and can_estimate) else None
|
||||
include_media, media_kinds = _resolve_effective_media_kinds(
|
||||
include_media=include_media,
|
||||
media_kinds=media_kinds,
|
||||
selected_render_types=want_types,
|
||||
privacy_mode=privacy_mode,
|
||||
)
|
||||
|
||||
local_types = None
|
||||
estimate_local_types = None
|
||||
|
||||
target_usernames = _resolve_export_targets(
|
||||
account_dir=account_dir,
|
||||
@@ -342,8 +377,7 @@ class ChatExportManager:
|
||||
if not target_usernames:
|
||||
raise ValueError("No target conversations to export.")
|
||||
|
||||
exports_root = account_dir.parents[1] / "exports" / account_dir.name
|
||||
exports_root.mkdir(parents=True, exist_ok=True)
|
||||
exports_root = _resolve_export_output_dir(account_dir, opts.get("outputDir"))
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
base_name = str(opts.get("fileName") or "").strip()
|
||||
@@ -456,16 +490,13 @@ class ChatExportManager:
|
||||
job.progress.current_conversation_messages_total = 0
|
||||
|
||||
try:
|
||||
if not can_estimate:
|
||||
estimated_total = 0
|
||||
else:
|
||||
estimated_total = _estimate_conversation_message_count(
|
||||
account_dir=account_dir,
|
||||
conv_username=conv_username,
|
||||
start_time=st,
|
||||
end_time=et,
|
||||
local_types=estimate_local_types,
|
||||
)
|
||||
estimated_total = _estimate_conversation_message_count(
|
||||
account_dir=account_dir,
|
||||
conv_username=conv_username,
|
||||
start_time=st,
|
||||
end_time=et,
|
||||
local_types=estimate_local_types,
|
||||
)
|
||||
except Exception:
|
||||
estimated_total = 0
|
||||
|
||||
@@ -557,6 +588,8 @@ class ChatExportManager:
|
||||
zf.writestr(f"{conv_dir}/meta.json", json.dumps(meta, ensure_ascii=False, indent=2))
|
||||
|
||||
with self._lock:
|
||||
job.progress.current_conversation_messages_exported = int(exported_count)
|
||||
job.progress.current_conversation_messages_total = int(exported_count)
|
||||
job.progress.conversations_done += 1
|
||||
|
||||
manifest = {
|
||||
@@ -922,13 +955,7 @@ def _parse_message_for_export(
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
if "revokemsg" in raw_text:
|
||||
content_text = "撤回了一条消息"
|
||||
else:
|
||||
import re as _re
|
||||
|
||||
content_text = _re.sub(r"</?[_a-zA-Z0-9]+[^>]*>", "", raw_text)
|
||||
content_text = _re.sub(r"\\s+", " ", content_text).strip() or "[系统消息]"
|
||||
content_text = _parse_system_message_content(raw_text)
|
||||
elif local_type == 49:
|
||||
parsed = _parse_app_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "text")
|
||||
@@ -1325,12 +1352,8 @@ def _write_conversation_json(
|
||||
resource_chat_id=resource_chat_id,
|
||||
sender_alias=sender_alias,
|
||||
)
|
||||
if want_types:
|
||||
rt_key = _normalize_render_type_key(msg.get("renderType"))
|
||||
if rt_key not in want_types:
|
||||
if scanned % 500 == 0 and job.cancel_requested:
|
||||
raise _JobCancelled()
|
||||
continue
|
||||
if not _is_render_type_selected(msg.get("renderType"), want_types):
|
||||
continue
|
||||
|
||||
su = str(msg.get("senderUsername") or "").strip()
|
||||
if privacy_mode:
|
||||
@@ -1506,12 +1529,8 @@ def _write_conversation_txt(
|
||||
resource_chat_id=resource_chat_id,
|
||||
sender_alias=sender_alias,
|
||||
)
|
||||
if want_types:
|
||||
rt_key = _normalize_render_type_key(msg.get("renderType"))
|
||||
if rt_key not in want_types:
|
||||
if scanned % 500 == 0 and job.cancel_requested:
|
||||
raise _JobCancelled()
|
||||
continue
|
||||
if not _is_render_type_selected(msg.get("renderType"), want_types):
|
||||
continue
|
||||
|
||||
su = str(msg.get("senderUsername") or "").strip()
|
||||
if privacy_mode:
|
||||
|
||||
@@ -8,7 +8,7 @@ from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -618,6 +618,39 @@ def _normalize_xml_url(url: str) -> str:
|
||||
return u.replace("&", "&").strip()
|
||||
|
||||
|
||||
def _is_mp_weixin_article_url(url: str) -> bool:
|
||||
u = str(url or "").strip()
|
||||
if not u:
|
||||
return False
|
||||
|
||||
try:
|
||||
host = str(urlparse(u).hostname or "").strip().lower()
|
||||
if host == "mp.weixin.qq.com" or host.endswith(".mp.weixin.qq.com"):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lu = u.lower()
|
||||
return "mp.weixin.qq.com/" in lu
|
||||
|
||||
|
||||
def _classify_link_share(*, app_type: int, url: str, source_username: str, desc: str) -> tuple[str, str]:
|
||||
src = str(source_username or "").strip().lower()
|
||||
is_official_article = bool(
|
||||
app_type in (5, 68)
|
||||
and (_is_mp_weixin_article_url(url) or src.startswith("gh_"))
|
||||
)
|
||||
|
||||
link_type = "official_article" if is_official_article else "web_link"
|
||||
|
||||
d = str(desc or "").strip()
|
||||
hashtag_count = len(re.findall(r"#[^#\s]+", d))
|
||||
|
||||
# 公众号文章中「封面图 + 底栏标题」卡片特征:摘要以 #话题# 风格为主。
|
||||
link_style = "cover" if (is_official_article and (d.startswith("#") or hashtag_count >= 2)) else "default"
|
||||
return link_type, link_style
|
||||
|
||||
|
||||
def _extract_xml_tag_text(xml_text: str, tag: str) -> str:
|
||||
if not xml_text or not tag:
|
||||
return ""
|
||||
@@ -645,6 +678,43 @@ def _extract_xml_tag_or_attr(xml_text: str, name: str) -> str:
|
||||
return _extract_xml_attr(xml_text, name)
|
||||
|
||||
|
||||
def _parse_system_message_content(raw_text: str) -> str:
|
||||
text = str(raw_text or "").strip()
|
||||
if not text:
|
||||
return "[系统消息]"
|
||||
|
||||
def _clean_system_text(value: str) -> str:
|
||||
candidate = str(value or "").strip()
|
||||
if not candidate:
|
||||
return ""
|
||||
|
||||
nested_content = _extract_xml_tag_text(candidate, "content")
|
||||
if nested_content:
|
||||
candidate = nested_content
|
||||
|
||||
candidate = re.sub(r"<!\[CDATA\[", "", candidate, flags=re.IGNORECASE)
|
||||
candidate = re.sub(r"\]\]>", "", candidate)
|
||||
candidate = re.sub(r"</?[_a-zA-Z0-9]+[^>]*>", "", candidate)
|
||||
candidate = re.sub(r"\s+", " ", candidate).strip()
|
||||
return candidate
|
||||
|
||||
if "revokemsg" in text.lower():
|
||||
replace_msg = _extract_xml_tag_text(text, "replacemsg")
|
||||
cleaned_replace_msg = _clean_system_text(replace_msg)
|
||||
if cleaned_replace_msg:
|
||||
return cleaned_replace_msg
|
||||
|
||||
revoke_msg = _extract_xml_tag_text(text, "revokemsg")
|
||||
cleaned_revoke_msg = _clean_system_text(revoke_msg)
|
||||
if cleaned_revoke_msg:
|
||||
return cleaned_revoke_msg
|
||||
|
||||
return "撤回了一条消息"
|
||||
|
||||
content_text = _clean_system_text(text)
|
||||
return content_text or "[系统消息]"
|
||||
|
||||
|
||||
def _extract_refermsg_block(xml_text: str) -> str:
|
||||
if not xml_text:
|
||||
return ""
|
||||
@@ -652,6 +722,65 @@ def _extract_refermsg_block(xml_text: str) -> str:
|
||||
return (m.group(1) or "").strip() if m else ""
|
||||
|
||||
|
||||
def _extract_refermsg_content(refer_block: str) -> str:
|
||||
if not refer_block:
|
||||
return ""
|
||||
|
||||
cdata_match = re.search(
|
||||
r"<content\b[^>]*>\s*<!\[CDATA\[(.*?)\]\]>\s*</content>",
|
||||
refer_block,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if cdata_match:
|
||||
return str(cdata_match.group(1) or "").strip()
|
||||
|
||||
return _extract_xml_tag_text(refer_block, "content")
|
||||
|
||||
|
||||
def _summarize_nested_quote_content(raw_content: str) -> str:
|
||||
candidate = str(raw_content or "").strip()
|
||||
if not candidate:
|
||||
return ""
|
||||
|
||||
lower = candidate.lower()
|
||||
if "<msg" not in lower and "<appmsg" not in lower:
|
||||
return candidate
|
||||
|
||||
for tag in ("title", "des"):
|
||||
value = _extract_xml_tag_text(candidate, tag)
|
||||
if value:
|
||||
return value
|
||||
|
||||
content_value = _extract_xml_tag_text(candidate, "content")
|
||||
if content_value and (not str(content_value).lstrip().startswith("<")):
|
||||
return content_value
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_nested_quote_thumb_url(raw_content: str) -> str:
|
||||
candidate = str(raw_content or "").strip()
|
||||
if not candidate:
|
||||
return ""
|
||||
|
||||
probes = [candidate]
|
||||
|
||||
if candidate.startswith("wxid_"):
|
||||
colon = candidate.find(":")
|
||||
if 0 < colon <= 64:
|
||||
rest = candidate[colon + 1 :].strip()
|
||||
if rest:
|
||||
probes.append(rest)
|
||||
|
||||
for probe in probes:
|
||||
for key in ("thumburl", "cdnthumburl", "cdnthumurl", "coverurl", "cover"):
|
||||
value = _normalize_xml_url(_extract_xml_tag_or_attr(probe, key))
|
||||
if value:
|
||||
return value
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _infer_transfer_status_text(
|
||||
is_sent: bool,
|
||||
paysubtype: str,
|
||||
@@ -665,7 +794,7 @@ def _infer_transfer_status_text(
|
||||
rs = str(receivestatus or "").strip()
|
||||
|
||||
if rs == "1":
|
||||
return "已收款"
|
||||
return "已被接收" if is_sent else "已收款"
|
||||
if rs == "2":
|
||||
return "已退还"
|
||||
if rs == "3":
|
||||
@@ -681,7 +810,7 @@ def _infer_transfer_status_text(
|
||||
if t == "8":
|
||||
return "发起转账"
|
||||
if t == "3":
|
||||
return "已收款" if is_sent else "已被接收"
|
||||
return "已被接收" if is_sent else "已收款"
|
||||
if t == "1":
|
||||
return "转账"
|
||||
|
||||
@@ -733,10 +862,22 @@ def _extract_sender_from_group_xml(xml_text: str) -> str:
|
||||
if not xml_text:
|
||||
return ""
|
||||
|
||||
v = _extract_xml_tag_text(xml_text, "fromusername")
|
||||
probe_text = xml_text
|
||||
try:
|
||||
# Avoid picking nested quoted-message sender from <refermsg>.
|
||||
probe_text = re.sub(
|
||||
r"(<refermsg[^>]*>.*?</refermsg>)",
|
||||
"",
|
||||
xml_text,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
except Exception:
|
||||
probe_text = xml_text
|
||||
|
||||
v = _extract_xml_tag_text(probe_text, "fromusername")
|
||||
if v:
|
||||
return v
|
||||
v = _extract_xml_attr(xml_text, "fromusername")
|
||||
v = _extract_xml_attr(probe_text, "fromusername")
|
||||
if v:
|
||||
return v
|
||||
return ""
|
||||
@@ -809,6 +950,12 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
|
||||
if app_type in (5, 68) and url:
|
||||
thumb_url = _normalize_xml_url(_extract_xml_tag_text(text, "thumburl"))
|
||||
link_type, link_style = _classify_link_share(
|
||||
app_type=app_type,
|
||||
url=url,
|
||||
source_username=str(source_username or "").strip(),
|
||||
desc=str(des or "").strip(),
|
||||
)
|
||||
return {
|
||||
"renderType": "link",
|
||||
"content": des or title or "[链接]",
|
||||
@@ -817,6 +964,8 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
"thumbUrl": thumb_url or "",
|
||||
"from": str(source_display_name or "").strip(),
|
||||
"fromUsername": str(source_username or "").strip(),
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
}
|
||||
|
||||
if app_type in (6, 74):
|
||||
@@ -870,7 +1019,7 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
or ""
|
||||
)
|
||||
refer_svrid = _extract_xml_tag_or_attr(refer_block, "svrid")
|
||||
refer_content = _extract_xml_tag_text(refer_block, "content")
|
||||
refer_content = _extract_refermsg_content(refer_block)
|
||||
refer_type = _extract_xml_tag_or_attr(refer_block, "type")
|
||||
|
||||
rt = (reply_text or "").strip()
|
||||
@@ -887,6 +1036,7 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
refer_content = rest
|
||||
|
||||
t = str(refer_type or "").strip()
|
||||
quote_thumb_url = ""
|
||||
quote_voice_length = ""
|
||||
if t == "3":
|
||||
refer_content = "[图片]"
|
||||
@@ -907,8 +1057,29 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
except Exception:
|
||||
quote_voice_length = ""
|
||||
refer_content = "[语音]"
|
||||
elif t == "49" and refer_content:
|
||||
refer_content = f"[链接] {refer_content}".strip()
|
||||
elif t == "57":
|
||||
summarized = _summarize_nested_quote_content(str(refer_content or ""))
|
||||
if summarized:
|
||||
refer_content = summarized
|
||||
elif str(refer_content or "").lstrip().startswith("<"):
|
||||
refer_content = "[引用消息]"
|
||||
elif t in {"49", "5", "68"}:
|
||||
raw_link_content = str(refer_content or "").strip()
|
||||
summarized = _summarize_nested_quote_content(raw_link_content)
|
||||
link_text = str(summarized or raw_link_content).strip()
|
||||
quote_thumb_url = _extract_nested_quote_thumb_url(raw_link_content)
|
||||
|
||||
if link_text.startswith("wxid_"):
|
||||
colon = link_text.find(":")
|
||||
if 0 < colon <= 64:
|
||||
maybe_rest = link_text[colon + 1 :].strip()
|
||||
if maybe_rest:
|
||||
second_try = _summarize_nested_quote_content(maybe_rest)
|
||||
link_text = str(second_try or maybe_rest).strip()
|
||||
if not quote_thumb_url:
|
||||
quote_thumb_url = _extract_nested_quote_thumb_url(maybe_rest)
|
||||
|
||||
refer_content = f"[链接] {link_text}".strip() if link_text else "[链接]"
|
||||
|
||||
return {
|
||||
"renderType": "quote",
|
||||
@@ -917,6 +1088,7 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
"quoteTitle": refer_displayname or "",
|
||||
"quoteContent": refer_content or "",
|
||||
"quoteType": t,
|
||||
"quoteThumbUrl": quote_thumb_url,
|
||||
"quoteServerId": str(refer_svrid or "").strip(),
|
||||
"quoteVoiceLength": quote_voice_length,
|
||||
}
|
||||
@@ -1053,11 +1225,7 @@ def _build_latest_message_preview(
|
||||
|
||||
content_text = ""
|
||||
if local_type == 10000:
|
||||
if "revokemsg" in raw_text:
|
||||
content_text = "撤回了一条消息"
|
||||
else:
|
||||
content_text = re.sub(r"</?[_a-zA-Z0-9]+[^>]*>", "", raw_text)
|
||||
content_text = re.sub(r"\s+", " ", content_text).strip() or "[系统消息]"
|
||||
content_text = _parse_system_message_content(raw_text)
|
||||
elif local_type == 244813135921:
|
||||
parsed = _parse_app_message(raw_text)
|
||||
qt = str(parsed.get("quoteTitle") or "").strip()
|
||||
@@ -1093,7 +1261,7 @@ def _build_latest_message_preview(
|
||||
elif local_type == 43 or local_type == 62:
|
||||
content_text = "[视频]"
|
||||
elif local_type == 47:
|
||||
content_text = "[表情]"
|
||||
content_text = "[动画表情]"
|
||||
else:
|
||||
if raw_text and (not raw_text.startswith("<")) and (not raw_text.startswith('"<')):
|
||||
content_text = raw_text
|
||||
@@ -1107,6 +1275,101 @@ def _build_latest_message_preview(
|
||||
return content_text
|
||||
|
||||
|
||||
def _extract_group_preview_sender_username(preview_text: str) -> str:
|
||||
text = str(preview_text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
match = re.match(r"^([^:\s]{1,128}):\s*.+$", text)
|
||||
if not match:
|
||||
return ""
|
||||
|
||||
sender = str(match.group(1) or "").strip()
|
||||
if not sender:
|
||||
return ""
|
||||
|
||||
if sender.startswith("wxid_") or sender.endswith("@chatroom") or ("@" in sender):
|
||||
return sender
|
||||
if re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{1,127}", sender):
|
||||
return sender
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_session_preview_text(
|
||||
preview_text: str,
|
||||
*,
|
||||
is_group: bool,
|
||||
sender_display_names: Optional[dict[str, str]] = None,
|
||||
) -> str:
|
||||
text = re.sub(r"\s+", " ", str(preview_text or "").strip()).strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
text = text.replace("[表情]", "[动画表情]")
|
||||
if (not is_group) or text.startswith("[草稿]"):
|
||||
return text
|
||||
|
||||
match = re.match(r"^([^:\s]{1,128}):\s*(.+)$", text)
|
||||
if not match:
|
||||
return text
|
||||
|
||||
sender_username = str(match.group(1) or "").strip()
|
||||
body = str(match.group(2) or "").strip()
|
||||
if (not sender_username) or (not body):
|
||||
return text
|
||||
|
||||
display_name = str((sender_display_names or {}).get(sender_username) or "").strip()
|
||||
if display_name and display_name != sender_username:
|
||||
return f"{display_name}: {body}"
|
||||
return text
|
||||
|
||||
|
||||
def _replace_preview_sender_prefix(preview_text: str, sender_display_name: str) -> str:
|
||||
text = re.sub(r"\s+", " ", str(preview_text or "").strip()).strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
display_name = str(sender_display_name or "").strip()
|
||||
if (not display_name) or text.startswith("[草稿]"):
|
||||
return text
|
||||
|
||||
match = re.match(r"^([^:\n]{1,128}):\s*(.+)$", text)
|
||||
if not match:
|
||||
return text
|
||||
|
||||
body = re.sub(r"\s+", " ", str(match.group(2) or "").strip()).strip()
|
||||
if not body:
|
||||
return text
|
||||
return f"{display_name}: {body}"
|
||||
|
||||
|
||||
def _build_group_sender_display_name_map(
|
||||
contact_db_path: Path,
|
||||
previews: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
group_sender_usernames: set[str] = set()
|
||||
for conv_username, preview_text in previews.items():
|
||||
if not str(conv_username or "").endswith("@chatroom"):
|
||||
continue
|
||||
sender_username = _extract_group_preview_sender_username(preview_text)
|
||||
if sender_username:
|
||||
group_sender_usernames.add(sender_username)
|
||||
|
||||
if not group_sender_usernames:
|
||||
return {}
|
||||
|
||||
display_names: dict[str, str] = {}
|
||||
sender_contact_rows = _load_contact_rows(contact_db_path, list(group_sender_usernames))
|
||||
for sender_username in group_sender_usernames:
|
||||
row = sender_contact_rows.get(sender_username)
|
||||
if row is None:
|
||||
continue
|
||||
display_name = _pick_display_name(row, sender_username)
|
||||
if display_name and display_name != sender_username:
|
||||
display_names[sender_username] = display_name
|
||||
return display_names
|
||||
|
||||
|
||||
def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> dict[str, str]:
|
||||
if not usernames:
|
||||
return {}
|
||||
@@ -1338,6 +1601,208 @@ def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str,
|
||||
conn.close()
|
||||
|
||||
|
||||
def _load_group_nickname_map_from_contact_db(
|
||||
contact_db_path: Path,
|
||||
chatroom_id: str,
|
||||
sender_usernames: list[str],
|
||||
) -> dict[str, str]:
|
||||
"""Best-effort mapping for group member nickname (aka group card) from contact.db.
|
||||
|
||||
WeChat stores per-chatroom member nicknames in `contact.db.chat_room.ext_buffer` as a protobuf-like blob.
|
||||
This helper parses that blob and returns { sender_username -> group_nickname } for the requested senders.
|
||||
|
||||
Notes:
|
||||
- Best-effort: never raises; returns {} on any failure.
|
||||
- Only resolves usernames included in `sender_usernames` to keep parsing cheap.
|
||||
"""
|
||||
|
||||
chatroom = str(chatroom_id or "").strip()
|
||||
if not chatroom.endswith("@chatroom"):
|
||||
return {}
|
||||
|
||||
targets = list(dict.fromkeys([str(x or "").strip() for x in sender_usernames if str(x or "").strip()]))
|
||||
if not targets:
|
||||
return {}
|
||||
target_set = set(targets)
|
||||
|
||||
def decode_varint(raw: bytes, offset: int) -> tuple[Optional[int], int]:
|
||||
value = 0
|
||||
shift = 0
|
||||
pos = int(offset)
|
||||
n = len(raw)
|
||||
while pos < n:
|
||||
byte = raw[pos]
|
||||
pos += 1
|
||||
value |= (byte & 0x7F) << shift
|
||||
if (byte & 0x80) == 0:
|
||||
return value, pos
|
||||
shift += 7
|
||||
if shift > 63:
|
||||
return None, n
|
||||
return None, n
|
||||
|
||||
def iter_fields(raw: bytes):
|
||||
idx = 0
|
||||
n = len(raw)
|
||||
while idx < n:
|
||||
tag, idx_next = decode_varint(raw, idx)
|
||||
if tag is None or idx_next <= idx:
|
||||
break
|
||||
idx = idx_next
|
||||
field_no = int(tag) >> 3
|
||||
wire_type = int(tag) & 0x7
|
||||
|
||||
if wire_type == 0:
|
||||
_, idx_next = decode_varint(raw, idx)
|
||||
if idx_next <= idx:
|
||||
break
|
||||
idx = idx_next
|
||||
continue
|
||||
|
||||
if wire_type == 2:
|
||||
size, idx_next = decode_varint(raw, idx)
|
||||
if size is None or idx_next <= idx:
|
||||
break
|
||||
idx = idx_next
|
||||
end = idx + int(size)
|
||||
if end > n:
|
||||
break
|
||||
chunk = raw[idx:end]
|
||||
idx = end
|
||||
yield field_no, wire_type, chunk
|
||||
continue
|
||||
|
||||
if wire_type == 1:
|
||||
idx += 8
|
||||
continue
|
||||
if wire_type == 5:
|
||||
idx += 4
|
||||
continue
|
||||
break
|
||||
|
||||
def is_strong_username_hint(s: str) -> bool:
|
||||
v = str(s or "").strip()
|
||||
return v.startswith("wxid_") or v.endswith("@chatroom") or v.startswith("gh_") or ("@" in v)
|
||||
|
||||
def looks_like_username(s: str) -> bool:
|
||||
v = str(s or "").strip()
|
||||
if not v:
|
||||
return False
|
||||
if is_strong_username_hint(v):
|
||||
return True
|
||||
# Common alias-style WeChat IDs are ASCII-ish and do not contain whitespace.
|
||||
if len(v) < 6 or len(v) > 32:
|
||||
return False
|
||||
if re.search(r"\s", v):
|
||||
return False
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]+$", v):
|
||||
return False
|
||||
if v.isdigit():
|
||||
return False
|
||||
return True
|
||||
|
||||
def pick_display(strings: list[tuple[int, str]], target: str) -> str:
|
||||
best_score = -1
|
||||
best = ""
|
||||
for i, (fno, value) in enumerate(strings):
|
||||
v = str(value or "").strip()
|
||||
if (not v) or v == target:
|
||||
continue
|
||||
if is_strong_username_hint(v):
|
||||
continue
|
||||
if "\n" in v or "\r" in v:
|
||||
continue
|
||||
if len(v) > 64:
|
||||
continue
|
||||
|
||||
score = 0
|
||||
if int(fno) == 2:
|
||||
score += 100
|
||||
if not looks_like_username(v):
|
||||
score += 20
|
||||
score += max(0, 32 - len(v))
|
||||
# Stable tie-breaker: prefer earlier appearance.
|
||||
score = score * 1000 - i
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = v
|
||||
return best
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1",
|
||||
(chatroom,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return {}
|
||||
|
||||
ext = row[0]
|
||||
if ext is None:
|
||||
return {}
|
||||
if isinstance(ext, memoryview):
|
||||
ext_buf = ext.tobytes()
|
||||
elif isinstance(ext, (bytes, bytearray)):
|
||||
ext_buf = bytes(ext)
|
||||
else:
|
||||
return {}
|
||||
if not ext_buf:
|
||||
return {}
|
||||
|
||||
out: dict[str, str] = {}
|
||||
for _, wire_type, chunk in iter_fields(ext_buf):
|
||||
if wire_type != 2 or (not chunk):
|
||||
continue
|
||||
|
||||
# Parse submessage and collect UTF-8 strings.
|
||||
strings: list[tuple[int, str]] = []
|
||||
try:
|
||||
for sfno, swire, sval in iter_fields(chunk):
|
||||
if swire != 2:
|
||||
continue
|
||||
if not sval:
|
||||
continue
|
||||
if len(sval) > 256:
|
||||
continue
|
||||
try:
|
||||
txt = bytes(sval).decode("utf-8", errors="strict")
|
||||
except Exception:
|
||||
continue
|
||||
txt = txt.strip()
|
||||
if not txt:
|
||||
continue
|
||||
strings.append((int(sfno), txt))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not strings:
|
||||
continue
|
||||
|
||||
present = [v for _, v in strings if v in target_set and v not in out]
|
||||
if not present:
|
||||
continue
|
||||
|
||||
for target in present:
|
||||
disp = pick_display(strings, target)
|
||||
if disp:
|
||||
out[target] = disp
|
||||
if len(out) >= len(target_set):
|
||||
break
|
||||
|
||||
return out
|
||||
except Exception:
|
||||
return {}
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _load_usernames_by_display_names(contact_db_path: Path, names: list[str]) -> dict[str, str]:
|
||||
"""Best-effort mapping from display name -> username using contact.db.
|
||||
|
||||
@@ -1488,10 +1953,10 @@ def _row_to_search_hit(
|
||||
if is_group and raw_text and (not raw_text.startswith("<")) and (not raw_text.startswith('"<')):
|
||||
sender_prefix, raw_text = _split_group_sender_prefix(raw_text, sender_username)
|
||||
|
||||
if is_group and sender_prefix:
|
||||
if is_group and sender_prefix and (not sender_username):
|
||||
sender_username = sender_prefix
|
||||
|
||||
if is_group and raw_text and (raw_text.startswith("<") or raw_text.startswith('"<')):
|
||||
if is_group and (not sender_username) and raw_text and (raw_text.startswith("<") or raw_text.startswith('"<')):
|
||||
xml_sender = _extract_sender_from_group_xml(raw_text)
|
||||
if xml_sender:
|
||||
sender_username = xml_sender
|
||||
@@ -1508,6 +1973,9 @@ def _row_to_search_hit(
|
||||
quote_username = ""
|
||||
quote_title = ""
|
||||
quote_content = ""
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
amount = ""
|
||||
pay_sub_type = ""
|
||||
transfer_status = ""
|
||||
@@ -1515,11 +1983,7 @@ def _row_to_search_hit(
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
if "revokemsg" in raw_text:
|
||||
content_text = "撤回了一条消息"
|
||||
else:
|
||||
content_text = re.sub(r"</?[_a-zA-Z0-9]+[^>]*>", "", raw_text)
|
||||
content_text = re.sub(r"\s+", " ", content_text).strip() or "[系统消息]"
|
||||
content_text = _parse_system_message_content(raw_text)
|
||||
elif local_type == 49:
|
||||
parsed = _parse_app_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "text")
|
||||
@@ -1528,6 +1992,9 @@ def _row_to_search_hit(
|
||||
url = str(parsed.get("url") or "")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
amount = str(parsed.get("amount") or "")
|
||||
pay_sub_type = str(parsed.get("paySubType") or "")
|
||||
@@ -1552,6 +2019,7 @@ def _row_to_search_hit(
|
||||
content_text = str(parsed.get("content") or "[引用消息]")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
elif local_type == 3:
|
||||
render_type = "image"
|
||||
@@ -1601,6 +2069,9 @@ def _row_to_search_hit(
|
||||
url = str(parsed.get("url") or url)
|
||||
quote_title = str(parsed.get("quoteTitle") or quote_title)
|
||||
quote_content = str(parsed.get("quoteContent") or quote_content)
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
|
||||
quote_username = str(parsed.get("quoteUsername") or quote_username)
|
||||
@@ -1640,9 +2111,12 @@ def _row_to_search_hit(
|
||||
"content": content_text,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"quoteUsername": quote_username,
|
||||
"quoteTitle": quote_title,
|
||||
"quoteContent": quote_content,
|
||||
"quoteThumbUrl": quote_thumb_url,
|
||||
"amount": amount,
|
||||
"paySubType": pay_sub_type,
|
||||
"transferStatus": transfer_status,
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
# import sys
|
||||
# import requests
|
||||
|
||||
try:
|
||||
import wx_key
|
||||
except ImportError:
|
||||
print('[!] 环境中未安装wx_key依赖,可能无法自动获取数据库密钥')
|
||||
wx_key = None
|
||||
# sys.exit(1)
|
||||
|
||||
import time
|
||||
import psutil
|
||||
import subprocess
|
||||
import hashlib
|
||||
import os
|
||||
import json
|
||||
import random
|
||||
import logging
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
from packaging import version as pkg_version # 建议使用 packaging 库处理版本比较
|
||||
from .wechat_detection import detect_wechat_installation
|
||||
from .key_store import upsert_account_keys_in_store
|
||||
from .media_helpers import _resolve_account_dir, _resolve_account_wxid_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ====================== 以下是hook逻辑 ======================================
|
||||
|
||||
@dataclass
|
||||
class HookConfig:
|
||||
min_version: str
|
||||
pattern: str # 用 00 不要用 ? !!!! 否则C++内存会炸
|
||||
mask: str
|
||||
offset: int
|
||||
|
||||
|
||||
class WeChatKeyFetcher:
|
||||
def __init__(self):
|
||||
self.process_name = "Weixin.exe"
|
||||
self.timeout_seconds = 60
|
||||
|
||||
@staticmethod
|
||||
def _hex_array_to_str(hex_array: List[int]) -> str:
|
||||
return " ".join([f"{b:02X}" for b in hex_array])
|
||||
|
||||
def _get_hook_config(self, version_str: str) -> Optional[HookConfig]:
|
||||
"""搬运自wx_key代码,未来用ida脚本直接获取即可"""
|
||||
try:
|
||||
v_curr = pkg_version.parse(version_str)
|
||||
except Exception as e:
|
||||
logger.error(f"版本号解析失败: {version_str} || {e}")
|
||||
return None
|
||||
|
||||
if v_curr > pkg_version.parse("4.1.6.14"):
|
||||
return HookConfig(
|
||||
min_version=">4.1.6.14",
|
||||
pattern=self._hex_array_to_str([
|
||||
0x24, 0x50, 0x48, 0xC7, 0x45, 0x00, 0xFE, 0xFF, 0xFF, 0xFF,
|
||||
0x44, 0x89, 0xCF, 0x44, 0x89, 0xC3, 0x49, 0x89, 0xD6, 0x48,
|
||||
0x89, 0xCE, 0x48, 0x89
|
||||
]),
|
||||
mask="xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
offset=-3
|
||||
)
|
||||
|
||||
if pkg_version.parse("4.1.4") <= v_curr <= pkg_version.parse("4.1.6.14"):
|
||||
return HookConfig(
|
||||
min_version="4.1.4-4.1.6.14",
|
||||
pattern=self._hex_array_to_str([
|
||||
0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74,
|
||||
0x00, 0x18, 0x48, 0x89, 0x7c, 0x00, 0x20, 0x41, 0x56, 0x48,
|
||||
0x83, 0xec, 0x50, 0x41
|
||||
]),
|
||||
mask="xxxxxxxxxx?xxxx?xxxxxxxx",
|
||||
offset=-3
|
||||
)
|
||||
|
||||
if v_curr < pkg_version.parse("4.1.4"):
|
||||
return HookConfig(
|
||||
min_version="<4.1.4",
|
||||
pattern=self._hex_array_to_str([
|
||||
0x24, 0x50, 0x48, 0xc7, 0x45, 0x00, 0xfe, 0xff, 0xff, 0xff,
|
||||
0x44, 0x89, 0xcf, 0x44, 0x89, 0xc3, 0x49, 0x89, 0xd6, 0x48,
|
||||
0x89, 0xce, 0x48, 0x89
|
||||
]),
|
||||
mask="xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
offset=-15 # -0xf
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def kill_wechat(self):
|
||||
"""检测并查杀微信进程"""
|
||||
killed = False
|
||||
for proc in psutil.process_iter(['pid', 'name']):
|
||||
try:
|
||||
if proc.info['name'] == self.process_name:
|
||||
logger.info(f"Killing WeChat process: {proc.info['pid']}")
|
||||
proc.terminate()
|
||||
killed = True
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
pass
|
||||
|
||||
if killed:
|
||||
time.sleep(1) # 等待完全退出
|
||||
|
||||
def launch_wechat(self, exe_path: str) -> int:
|
||||
"""启动微信并返回 PID"""
|
||||
try:
|
||||
|
||||
process = subprocess.Popen(exe_path)
|
||||
|
||||
time.sleep(2)
|
||||
candidates = []
|
||||
for proc in psutil.process_iter(['pid', 'name', 'create_time']):
|
||||
if proc.info['name'] == self.process_name:
|
||||
candidates.append(proc)
|
||||
|
||||
if candidates:
|
||||
|
||||
candidates.sort(key=lambda x: x.info['create_time'], reverse=True)
|
||||
target_pid = candidates[0].info['pid']
|
||||
return target_pid
|
||||
|
||||
return process.pid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"启动微信失败: {e}")
|
||||
raise RuntimeError(f"无法启动微信: {e}")
|
||||
|
||||
def fetch_key(self) -> str:
|
||||
"""没有wx_key模块无法自动获取密钥"""
|
||||
if wx_key is None:
|
||||
raise RuntimeError("wx_key 模块未安装或加载失败")
|
||||
|
||||
install_info = detect_wechat_installation()
|
||||
|
||||
exe_path = install_info.get('wechat_exe_path')
|
||||
version = install_info.get('wechat_version')
|
||||
|
||||
if not exe_path or not version:
|
||||
raise RuntimeError("无法自动定位微信安装路径或版本")
|
||||
|
||||
logger.info(f"Detect WeChat: {version} at {exe_path}")
|
||||
|
||||
config = self._get_hook_config(version)
|
||||
if not config:
|
||||
raise RuntimeError(f"不支持的微信版本: {version}")
|
||||
|
||||
self.kill_wechat()
|
||||
|
||||
pid = self.launch_wechat(exe_path)
|
||||
logger.info(f"WeChat launched, PID: {pid}")
|
||||
|
||||
logger.info(f"Initializing Hook with pattern: {config.pattern[:20]}... Offset: {config.offset}")
|
||||
|
||||
if not wx_key.initialize_hook(pid, "", config.pattern, config.mask, config.offset):
|
||||
err = wx_key.get_last_error_msg()
|
||||
raise RuntimeError(f"Hook初始化失败: {err}")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
try:
|
||||
while True:
|
||||
if time.time() - start_time > self.timeout_seconds:
|
||||
raise TimeoutError("获取密钥超时 (60s)")
|
||||
|
||||
key = wx_key.poll_key_data()
|
||||
if key:
|
||||
found_key = key
|
||||
break
|
||||
|
||||
while True:
|
||||
msg, level = wx_key.get_status_message()
|
||||
if msg is None:
|
||||
break
|
||||
if level == 2:
|
||||
logger.error(f"[Hook Error] {msg}")
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
finally:
|
||||
logger.info("Cleaning up hook...")
|
||||
wx_key.cleanup_hook()
|
||||
|
||||
if found_key:
|
||||
return found_key
|
||||
else:
|
||||
raise RuntimeError("未知错误,未获取到密钥")
|
||||
|
||||
def get_db_key_workflow():
|
||||
fetcher = WeChatKeyFetcher()
|
||||
return fetcher.fetch_key()
|
||||
|
||||
|
||||
# ============================== 以下是图片密钥逻辑 =====================================
|
||||
|
||||
|
||||
# 远程 API 配置
|
||||
REMOTE_URL = "https://view.free.c3o.re/dashboard"
|
||||
NEXT_ACTION_ID = "7c8f99280c70626ccf5960cc4a68f368197e15f8e9"
|
||||
|
||||
|
||||
def get_wechat_internal_global_config(wx_dir: Path, file_name1) -> bytes:
|
||||
"""
|
||||
读微信目录下的主配置文件
|
||||
"""
|
||||
xwechat_files_root = wx_dir.parent
|
||||
|
||||
target_path = os.path.join(xwechat_files_root, "all_users", "config", file_name1)
|
||||
|
||||
if not os.path.exists(target_path):
|
||||
logger.error(f"未找到微信内部 global_config: {target_path}")
|
||||
raise FileNotFoundError(f"找不到文件: {target_path},请确认微信数据目录结构是否完整")
|
||||
|
||||
return Path(target_path).read_bytes()
|
||||
|
||||
|
||||
# def get_local_config_sha3_224() -> bytes:
|
||||
# """
|
||||
# 不要在意,抽象的实现 哈哈哈
|
||||
# """
|
||||
# content = json.dumps({
|
||||
# "wxfile_dir": "C:\\Users\\17078\\xwechat_files",
|
||||
# "weixin_id_folder": "wxid_lnyf4hdo9csb12_f1c4",
|
||||
# "cache_dir": "C:\\Users\\17078\\Desktop\\wxDBHook\\test\\wx-dat\\wx-dat\\.cache",
|
||||
# "db_key": "",
|
||||
# "port": 8001
|
||||
# }, indent=4).encode("utf-8")
|
||||
#
|
||||
# # 计算 SHA3-224
|
||||
# digest = hashlib.sha3_224(content).digest()
|
||||
# return digest
|
||||
|
||||
# async def log_request(request):
|
||||
# print(f"--- Request Raw ---")
|
||||
# print(f"{request.method} {request.url} {request.extensions.get('http_version', b'HTTP/1.1').decode()}")
|
||||
# for name, value in request.headers.items():
|
||||
# print(f"{name}: {value}")
|
||||
#
|
||||
# print()
|
||||
#
|
||||
# body = request.read()
|
||||
# if body:
|
||||
# print(body.decode(errors='replace'))
|
||||
# print(f"-------------------\n")
|
||||
|
||||
|
||||
async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str, Any]:
|
||||
account_dir = _resolve_account_dir(account)
|
||||
wx_id_dir = _resolve_account_wxid_dir(account_dir)
|
||||
wxid = wx_id_dir.name
|
||||
|
||||
logger.info(f"正在为账号 {wxid} 获取密钥...")
|
||||
|
||||
try:
|
||||
blob1_bytes = get_wechat_internal_global_config(wx_id_dir, file_name1= "global_config") # 估计这是唯一有效的数据!!
|
||||
logger.info(f"获取微信内部配置成功,大小: {len(blob1_bytes)} bytes")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取微信内部文件失败: {e}")
|
||||
|
||||
try:
|
||||
blob2_bytes = get_wechat_internal_global_config(wx_id_dir, file_name1= "global_config.crc")
|
||||
logger.info(f"获取微信内部配置成功,大小: {len(blob2_bytes)} bytes")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取微信内部文件失败: {e}")
|
||||
|
||||
blob3_bytes = b""
|
||||
|
||||
headers = {
|
||||
"Accept": "text/x-component",
|
||||
"Next-Action": NEXT_ACTION_ID,
|
||||
"Next-Router-State-Tree": "%5B%22%22%2C%7B%22children%22%3A%5B%22dashboard%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D",
|
||||
"Origin": "https://view.free.c3o.re",
|
||||
"Referer": "https://view.free.c3o.re/dashboard",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
files = {
|
||||
'1': ('blob', blob1_bytes, 'application/octet-stream'),
|
||||
'2': ('blob', blob2_bytes, 'application/octet-stream'),
|
||||
'3': ('blob', blob3_bytes, 'application/octet-stream'),
|
||||
'0': (None, json.dumps([wxid, "$A1", "$A2", "$A3", 0],separators=(",",":")).encode('utf-8')),
|
||||
}
|
||||
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
logger.info("向远程服务器发送请求...")
|
||||
response = await client.post(REMOTE_URL, headers=headers, files=files)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"远程服务器错误: {response.status_code} - {response.text[:100]}")
|
||||
|
||||
|
||||
result_data = {}
|
||||
lines = response.text.split('\n')
|
||||
|
||||
found_config = False
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith('1:'):
|
||||
try:
|
||||
json_part = line[2:] # 去掉 "1:"
|
||||
data_obj = json.loads(json_part)
|
||||
|
||||
if "config" in data_obj:
|
||||
config = data_obj["config"]
|
||||
result_data = {
|
||||
"xor_key": config.get("xor_key", ""),
|
||||
"aes_key": config.get("aes_key", ""),
|
||||
"nick_name": config.get("nick_name", ""),
|
||||
"avatar_url": config.get("avatar_url", "")
|
||||
}
|
||||
found_config = True
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"解析响应行失败: {e}")
|
||||
continue
|
||||
|
||||
if not found_config or not result_data.get("aes_key"):
|
||||
logger.error(f"响应中未找到密钥信息。Full Response: {response.text[:500]}")
|
||||
raise RuntimeError("解析失败: 服务器未返回 config 数据")
|
||||
|
||||
# 6. 处理并保存密钥
|
||||
xor_raw = str(result_data["xor_key"])
|
||||
aes_val = str(result_data["aes_key"])
|
||||
|
||||
try:
|
||||
if xor_raw.startswith("0x"):
|
||||
xor_int = int(xor_raw, 16)
|
||||
else:
|
||||
xor_int = int(xor_raw)
|
||||
xor_hex_str = f"0x{xor_int:02X}"
|
||||
except:
|
||||
xor_hex_str = xor_raw
|
||||
|
||||
upsert_account_keys_in_store(
|
||||
account=wxid,
|
||||
image_xor_key=xor_hex_str,
|
||||
image_aes_key=aes_val
|
||||
)
|
||||
|
||||
return {
|
||||
"wxid": wxid,
|
||||
"xor_key": xor_hex_str,
|
||||
"aes_key": aes_val,
|
||||
"nick_name": result_data["nick_name"]
|
||||
}
|
||||
|
||||
@@ -23,17 +23,17 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
# 运行时输出目录(桌面端可通过 WECHAT_TOOL_DATA_DIR 指向可写目录)
|
||||
_OUTPUT_DATABASES_DIR = get_output_databases_dir()
|
||||
_PACKAGE_ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _list_decrypted_accounts() -> list[str]:
|
||||
"""列出已解密输出的账号目录名(仅保留包含 session.db + contact.db 的账号)"""
|
||||
if not _OUTPUT_DATABASES_DIR.exists():
|
||||
output_db_dir = get_output_databases_dir()
|
||||
if not output_db_dir.exists():
|
||||
return []
|
||||
|
||||
accounts: list[str] = []
|
||||
for p in _OUTPUT_DATABASES_DIR.iterdir():
|
||||
for p in output_db_dir.iterdir():
|
||||
if not p.is_dir():
|
||||
continue
|
||||
if (p / "session.db").exists() and (p / "contact.db").exists():
|
||||
@@ -45,6 +45,7 @@ def _list_decrypted_accounts() -> list[str]:
|
||||
|
||||
def _resolve_account_dir(account: Optional[str]) -> Path:
|
||||
"""解析账号目录,并进行路径安全校验(防止路径穿越)"""
|
||||
output_db_dir = get_output_databases_dir()
|
||||
accounts = _list_decrypted_accounts()
|
||||
if not accounts:
|
||||
raise HTTPException(
|
||||
@@ -53,8 +54,8 @@ def _resolve_account_dir(account: Optional[str]) -> Path:
|
||||
)
|
||||
|
||||
selected = account or accounts[0]
|
||||
base = _OUTPUT_DATABASES_DIR.resolve()
|
||||
candidate = (_OUTPUT_DATABASES_DIR / selected).resolve()
|
||||
base = output_db_dir.resolve()
|
||||
candidate = (output_db_dir / selected).resolve()
|
||||
|
||||
if candidate != base and base not in candidate.parents:
|
||||
raise HTTPException(status_code=400, detail="Invalid account path.")
|
||||
|
||||
@@ -0,0 +1,749 @@
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..chat_helpers import (
|
||||
_build_avatar_url,
|
||||
_pick_avatar_url,
|
||||
_pick_display_name,
|
||||
_resolve_account_dir,
|
||||
_should_keep_session,
|
||||
)
|
||||
from ..path_fix import PathFixRoute
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
|
||||
_SYSTEM_USERNAMES = {
|
||||
"filehelper",
|
||||
"fmessage",
|
||||
"floatbottle",
|
||||
"medianote",
|
||||
"newsapp",
|
||||
"qmessage",
|
||||
"qqmail",
|
||||
"tmessage",
|
||||
"brandsessionholder",
|
||||
"brandservicesessionholder",
|
||||
"notifymessage",
|
||||
"opencustomerservicemsg",
|
||||
"notification_messages",
|
||||
"userexperience_alarm",
|
||||
}
|
||||
|
||||
_SOURCE_SCENE_LABELS = {
|
||||
1: "通过QQ号添加",
|
||||
3: "通过微信号添加",
|
||||
6: "通过手机号添加",
|
||||
10: "通过名片添加",
|
||||
14: "通过群聊添加",
|
||||
30: "通过扫一扫添加",
|
||||
}
|
||||
|
||||
_COUNTRY_LABELS = {
|
||||
"CN": "中国大陆",
|
||||
}
|
||||
|
||||
|
||||
class ContactTypeFilter(BaseModel):
|
||||
friends: bool = True
|
||||
groups: bool = True
|
||||
officials: bool = True
|
||||
|
||||
|
||||
class ContactExportRequest(BaseModel):
|
||||
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
|
||||
output_dir: str = Field(..., description="导出目录绝对路径")
|
||||
format: str = Field("json", description="导出格式,仅支持 json/csv")
|
||||
include_avatar_link: bool = Field(True, description="是否导出 avatarLink 字段")
|
||||
contact_types: ContactTypeFilter = Field(default_factory=ContactTypeFilter)
|
||||
keyword: Optional[str] = Field(None, description="关键词筛选(可选)")
|
||||
|
||||
|
||||
def _normalize_text(v: Any) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
return str(v).strip()
|
||||
|
||||
|
||||
def _to_int(v: Any) -> int:
|
||||
try:
|
||||
return int(v or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _to_optional_int(v: Any) -> Optional[int]:
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, bool):
|
||||
return int(v)
|
||||
if isinstance(v, int):
|
||||
return v
|
||||
s = _normalize_text(v)
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return int(s)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _decode_varint(raw: bytes, offset: int) -> tuple[Optional[int], int]:
|
||||
value = 0
|
||||
shift = 0
|
||||
pos = int(offset)
|
||||
n = len(raw)
|
||||
while pos < n:
|
||||
byte = raw[pos]
|
||||
pos += 1
|
||||
value |= (byte & 0x7F) << shift
|
||||
if (byte & 0x80) == 0:
|
||||
return value, pos
|
||||
shift += 7
|
||||
if shift > 63:
|
||||
return None, n
|
||||
return None, n
|
||||
|
||||
|
||||
def _decode_proto_text(raw: bytes) -> str:
|
||||
if not raw:
|
||||
return ""
|
||||
try:
|
||||
text = raw.decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return ""
|
||||
return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text).strip()
|
||||
|
||||
|
||||
def _parse_contact_extra_buffer(extra_buffer: Any) -> dict[str, Any]:
|
||||
out = {
|
||||
"signature": "",
|
||||
"country": "",
|
||||
"province": "",
|
||||
"city": "",
|
||||
"source_scene": None,
|
||||
}
|
||||
if extra_buffer is None:
|
||||
return out
|
||||
|
||||
raw: bytes
|
||||
if isinstance(extra_buffer, memoryview):
|
||||
raw = extra_buffer.tobytes()
|
||||
elif isinstance(extra_buffer, (bytes, bytearray)):
|
||||
raw = bytes(extra_buffer)
|
||||
else:
|
||||
return out
|
||||
|
||||
if not raw:
|
||||
return out
|
||||
|
||||
idx = 0
|
||||
n = len(raw)
|
||||
while idx < n:
|
||||
tag, idx_next = _decode_varint(raw, idx)
|
||||
if tag is None:
|
||||
break
|
||||
idx = idx_next
|
||||
field_no = tag >> 3
|
||||
wire_type = tag & 0x7
|
||||
|
||||
if wire_type == 0:
|
||||
val, idx_next = _decode_varint(raw, idx)
|
||||
if val is None:
|
||||
break
|
||||
idx = idx_next
|
||||
if field_no == 8:
|
||||
out["source_scene"] = int(val)
|
||||
continue
|
||||
|
||||
if wire_type == 2:
|
||||
size, idx_next = _decode_varint(raw, idx)
|
||||
if size is None:
|
||||
break
|
||||
idx = idx_next
|
||||
end = idx + int(size)
|
||||
if end > n:
|
||||
break
|
||||
chunk = raw[idx:end]
|
||||
idx = end
|
||||
|
||||
if field_no in {4, 5, 6, 7}:
|
||||
text = _decode_proto_text(chunk)
|
||||
if field_no == 4:
|
||||
out["signature"] = text
|
||||
elif field_no == 5:
|
||||
out["country"] = text
|
||||
elif field_no == 6:
|
||||
out["province"] = text
|
||||
elif field_no == 7:
|
||||
out["city"] = text
|
||||
continue
|
||||
|
||||
if wire_type == 1:
|
||||
idx += 8
|
||||
continue
|
||||
if wire_type == 5:
|
||||
idx += 4
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _country_label(country: str) -> str:
|
||||
c = _normalize_text(country)
|
||||
if not c:
|
||||
return ""
|
||||
return _COUNTRY_LABELS.get(c.upper(), c)
|
||||
|
||||
|
||||
def _source_scene_label(source_scene: Optional[int]) -> str:
|
||||
if source_scene is None:
|
||||
return ""
|
||||
if source_scene in _SOURCE_SCENE_LABELS:
|
||||
return _SOURCE_SCENE_LABELS[source_scene]
|
||||
return f"场景码 {source_scene}"
|
||||
|
||||
|
||||
def _build_region(country: str, province: str, city: str) -> str:
|
||||
parts: list[str] = []
|
||||
country_text = _country_label(country)
|
||||
province_text = _normalize_text(province)
|
||||
city_text = _normalize_text(city)
|
||||
if country_text:
|
||||
parts.append(country_text)
|
||||
if province_text:
|
||||
parts.append(province_text)
|
||||
if city_text:
|
||||
parts.append(city_text)
|
||||
return "·".join(parts)
|
||||
|
||||
|
||||
def _safe_export_part(s: str) -> str:
|
||||
cleaned = re.sub(r"[^0-9A-Za-z._-]+", "_", str(s or "").strip())
|
||||
cleaned = cleaned.strip("._-")
|
||||
return cleaned or "account"
|
||||
|
||||
|
||||
def _is_valid_contact_username(username: str) -> bool:
|
||||
u = _normalize_text(username)
|
||||
if not u:
|
||||
return False
|
||||
if u in _SYSTEM_USERNAMES:
|
||||
return False
|
||||
if u.startswith("fake_"):
|
||||
return False
|
||||
if not _should_keep_session(u, include_official=True) and not u.startswith("gh_") and u != "weixin":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _get_table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||
try:
|
||||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
out: set[str] = set()
|
||||
for row in rows:
|
||||
try:
|
||||
name = _normalize_text(row["name"] if "name" in row.keys() else row[1]).lower()
|
||||
except Exception:
|
||||
continue
|
||||
if name:
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
def _build_contact_select_sql(table: str, columns: set[str]) -> Optional[str]:
|
||||
if "username" not in columns:
|
||||
return None
|
||||
|
||||
specs: list[tuple[str, str, str]] = [
|
||||
("username", "username", "''"),
|
||||
("remark", "remark", "''"),
|
||||
("nick_name", "nick_name", "''"),
|
||||
("alias", "alias", "''"),
|
||||
("local_type", "local_type", "0"),
|
||||
("verify_flag", "verify_flag", "0"),
|
||||
("big_head_url", "big_head_url", "''"),
|
||||
("small_head_url", "small_head_url", "''"),
|
||||
("extra_buffer", "extra_buffer", "x''"),
|
||||
]
|
||||
|
||||
select_parts: list[str] = []
|
||||
for key, alias, fallback in specs:
|
||||
if key in columns:
|
||||
select_parts.append(key)
|
||||
else:
|
||||
select_parts.append(f"{fallback} AS {alias}")
|
||||
return f"SELECT {', '.join(select_parts)} FROM {table}"
|
||||
|
||||
|
||||
def _load_contact_rows_map(contact_db_path: Path) -> dict[str, dict[str, Any]]:
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
if not contact_db_path.exists():
|
||||
return out
|
||||
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
def read_rows(table: str) -> list[sqlite3.Row]:
|
||||
columns = _get_table_columns(conn, table)
|
||||
sql = _build_contact_select_sql(table, columns)
|
||||
if not sql:
|
||||
return []
|
||||
try:
|
||||
return conn.execute(sql).fetchall()
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
for table in ("contact", "stranger"):
|
||||
rows = read_rows(table)
|
||||
for row in rows:
|
||||
username = _normalize_text(row["username"] if "username" in row.keys() else "")
|
||||
if (not username) or (username in out):
|
||||
continue
|
||||
|
||||
extra_info = _parse_contact_extra_buffer(
|
||||
row["extra_buffer"] if "extra_buffer" in row.keys() else b""
|
||||
)
|
||||
out[username] = {
|
||||
"username": username,
|
||||
"remark": _normalize_text(row["remark"] if "remark" in row.keys() else ""),
|
||||
"nick_name": _normalize_text(row["nick_name"] if "nick_name" in row.keys() else ""),
|
||||
"alias": _normalize_text(row["alias"] if "alias" in row.keys() else ""),
|
||||
"local_type": _to_int(row["local_type"] if "local_type" in row.keys() else 0),
|
||||
"verify_flag": _to_int(row["verify_flag"] if "verify_flag" in row.keys() else 0),
|
||||
"big_head_url": _normalize_text(row["big_head_url"] if "big_head_url" in row.keys() else ""),
|
||||
"small_head_url": _normalize_text(row["small_head_url"] if "small_head_url" in row.keys() else ""),
|
||||
"country": _normalize_text(extra_info.get("country")),
|
||||
"province": _normalize_text(extra_info.get("province")),
|
||||
"city": _normalize_text(extra_info.get("city")),
|
||||
"source_scene": _to_optional_int(extra_info.get("source_scene")),
|
||||
}
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _load_session_sort_timestamps(session_db_path: Path) -> dict[str, int]:
|
||||
out: dict[str, int] = {}
|
||||
if not session_db_path.exists():
|
||||
return out
|
||||
|
||||
conn = sqlite3.connect(str(session_db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows: list[sqlite3.Row] = []
|
||||
queries = [
|
||||
"SELECT username, COALESCE(sort_timestamp, 0) AS ts FROM SessionTable",
|
||||
"SELECT username, COALESCE(last_timestamp, 0) AS ts FROM SessionTable",
|
||||
]
|
||||
for sql in queries:
|
||||
try:
|
||||
rows = conn.execute(sql).fetchall()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for row in rows:
|
||||
username = _normalize_text(row["username"] if "username" in row.keys() else "")
|
||||
if not username:
|
||||
continue
|
||||
ts = _to_int(row["ts"] if "ts" in row.keys() else 0)
|
||||
prev = out.get(username, 0)
|
||||
if ts > prev:
|
||||
out[username] = ts
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _load_session_group_usernames(session_db_path: Path) -> set[str]:
|
||||
out: set[str] = set()
|
||||
if not session_db_path.exists():
|
||||
return out
|
||||
|
||||
conn = sqlite3.connect(str(session_db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
queries = [
|
||||
"SELECT username FROM SessionTable",
|
||||
"SELECT username FROM sessiontable",
|
||||
]
|
||||
for sql in queries:
|
||||
try:
|
||||
rows = conn.execute(sql).fetchall()
|
||||
except Exception:
|
||||
continue
|
||||
for row in rows:
|
||||
username = _normalize_text(row["username"] if "username" in row.keys() else "")
|
||||
if username and ("@chatroom" in username):
|
||||
out.add(username)
|
||||
return out
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _infer_contact_type(username: str, row: dict[str, Any]) -> Optional[str]:
|
||||
if not username:
|
||||
return None
|
||||
|
||||
if "@chatroom" in username:
|
||||
return "group"
|
||||
|
||||
verify_flag = _to_int(row.get("verify_flag"))
|
||||
if username.startswith("gh_") or verify_flag != 0:
|
||||
return "official"
|
||||
|
||||
local_type = _to_int(row.get("local_type"))
|
||||
if local_type == 1:
|
||||
return "friend"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _matches_keyword(contact: dict[str, Any], keyword: str) -> bool:
|
||||
kw = _normalize_text(keyword).lower()
|
||||
if not kw:
|
||||
return True
|
||||
|
||||
fields = [
|
||||
contact.get("username", ""),
|
||||
contact.get("displayName", ""),
|
||||
contact.get("remark", ""),
|
||||
contact.get("nickname", ""),
|
||||
contact.get("alias", ""),
|
||||
contact.get("region", ""),
|
||||
contact.get("source", ""),
|
||||
contact.get("country", ""),
|
||||
contact.get("province", ""),
|
||||
contact.get("city", ""),
|
||||
]
|
||||
for field in fields:
|
||||
if kw in _normalize_text(field).lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _collect_contacts_for_account(
|
||||
*,
|
||||
account_dir: Path,
|
||||
base_url: str,
|
||||
keyword: Optional[str],
|
||||
include_friends: bool,
|
||||
include_groups: bool,
|
||||
include_officials: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not (include_friends or include_groups or include_officials):
|
||||
return []
|
||||
|
||||
contact_db_path = account_dir / "contact.db"
|
||||
session_db_path = account_dir / "session.db"
|
||||
contact_rows = _load_contact_rows_map(contact_db_path)
|
||||
session_ts_map = _load_session_sort_timestamps(session_db_path)
|
||||
session_group_usernames = _load_session_group_usernames(session_db_path)
|
||||
|
||||
contacts: list[dict[str, Any]] = []
|
||||
for username, row in contact_rows.items():
|
||||
if not _is_valid_contact_username(username):
|
||||
continue
|
||||
|
||||
contact_type = _infer_contact_type(username, row)
|
||||
if contact_type is None:
|
||||
continue
|
||||
if contact_type == "friend" and not include_friends:
|
||||
continue
|
||||
if contact_type == "group" and not include_groups:
|
||||
continue
|
||||
if contact_type == "official" and not include_officials:
|
||||
continue
|
||||
|
||||
display_name = _pick_display_name(row, username)
|
||||
if not display_name:
|
||||
display_name = username
|
||||
|
||||
avatar_link = _normalize_text(_pick_avatar_url(row) or "")
|
||||
avatar = base_url + _build_avatar_url(account_dir.name, username)
|
||||
country = _normalize_text(row.get("country"))
|
||||
province = _normalize_text(row.get("province"))
|
||||
city = _normalize_text(row.get("city"))
|
||||
source_scene = _to_optional_int(row.get("source_scene"))
|
||||
|
||||
item = {
|
||||
"username": username,
|
||||
"displayName": display_name,
|
||||
"remark": _normalize_text(row.get("remark")),
|
||||
"nickname": _normalize_text(row.get("nick_name")),
|
||||
"alias": _normalize_text(row.get("alias")),
|
||||
"type": contact_type,
|
||||
"country": country,
|
||||
"province": province,
|
||||
"city": city,
|
||||
"region": _build_region(country, province, city),
|
||||
"sourceScene": source_scene,
|
||||
"source": _source_scene_label(source_scene),
|
||||
"avatar": avatar,
|
||||
"avatarLink": avatar_link,
|
||||
"_sortTs": _to_int(session_ts_map.get(username, 0)),
|
||||
}
|
||||
|
||||
if not _matches_keyword(item, keyword or ""):
|
||||
continue
|
||||
contacts.append(item)
|
||||
|
||||
if include_groups:
|
||||
for username in session_group_usernames:
|
||||
if username in contact_rows:
|
||||
continue
|
||||
if not _is_valid_contact_username(username):
|
||||
continue
|
||||
|
||||
avatar_link = ""
|
||||
avatar = base_url + _build_avatar_url(account_dir.name, username)
|
||||
|
||||
item = {
|
||||
"username": username,
|
||||
"displayName": username,
|
||||
"remark": "",
|
||||
"nickname": "",
|
||||
"alias": "",
|
||||
"type": "group",
|
||||
"country": "",
|
||||
"province": "",
|
||||
"city": "",
|
||||
"region": "",
|
||||
"sourceScene": None,
|
||||
"source": "",
|
||||
"avatar": avatar,
|
||||
"avatarLink": avatar_link,
|
||||
"_sortTs": _to_int(session_ts_map.get(username, 0)),
|
||||
}
|
||||
|
||||
if not _matches_keyword(item, keyword or ""):
|
||||
continue
|
||||
contacts.append(item)
|
||||
|
||||
contacts.sort(
|
||||
key=lambda x: (
|
||||
-_to_int(x.get("_sortTs", 0)),
|
||||
_normalize_text(x.get("displayName", "")).lower(),
|
||||
_normalize_text(x.get("username", "")).lower(),
|
||||
)
|
||||
)
|
||||
for item in contacts:
|
||||
item.pop("_sortTs", None)
|
||||
return contacts
|
||||
|
||||
|
||||
def _build_counts(contacts: list[dict[str, Any]]) -> dict[str, int]:
|
||||
counts = {
|
||||
"friends": 0,
|
||||
"groups": 0,
|
||||
"officials": 0,
|
||||
"total": 0,
|
||||
}
|
||||
for item in contacts:
|
||||
t = _normalize_text(item.get("type"))
|
||||
if t == "friend":
|
||||
counts["friends"] += 1
|
||||
elif t == "group":
|
||||
counts["groups"] += 1
|
||||
elif t == "official":
|
||||
counts["officials"] += 1
|
||||
counts["total"] = len(contacts)
|
||||
return counts
|
||||
|
||||
|
||||
def _build_export_contacts(
|
||||
contacts: list[dict[str, Any]],
|
||||
*,
|
||||
include_avatar_link: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in contacts:
|
||||
row = {
|
||||
"username": _normalize_text(item.get("username")),
|
||||
"displayName": _normalize_text(item.get("displayName")),
|
||||
"remark": _normalize_text(item.get("remark")),
|
||||
"nickname": _normalize_text(item.get("nickname")),
|
||||
"alias": _normalize_text(item.get("alias")),
|
||||
"type": _normalize_text(item.get("type")),
|
||||
"region": _normalize_text(item.get("region")),
|
||||
"country": _normalize_text(item.get("country")),
|
||||
"province": _normalize_text(item.get("province")),
|
||||
"city": _normalize_text(item.get("city")),
|
||||
"source": _normalize_text(item.get("source")),
|
||||
"sourceScene": _to_optional_int(item.get("sourceScene")),
|
||||
}
|
||||
if include_avatar_link:
|
||||
row["avatarLink"] = _normalize_text(item.get("avatarLink"))
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def _write_json_export(
|
||||
output_path: Path,
|
||||
*,
|
||||
account: str,
|
||||
contacts: list[dict[str, Any]],
|
||||
include_avatar_link: bool,
|
||||
keyword: str,
|
||||
contact_types: ContactTypeFilter,
|
||||
) -> None:
|
||||
payload = {
|
||||
"exportedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"account": account,
|
||||
"count": len(contacts),
|
||||
"filters": {
|
||||
"keyword": keyword,
|
||||
"contactTypes": {
|
||||
"friends": bool(contact_types.friends),
|
||||
"groups": bool(contact_types.groups),
|
||||
"officials": bool(contact_types.officials),
|
||||
},
|
||||
"includeAvatarLink": bool(include_avatar_link),
|
||||
},
|
||||
"contacts": contacts,
|
||||
}
|
||||
output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_csv_export(
|
||||
output_path: Path,
|
||||
*,
|
||||
contacts: list[dict[str, Any]],
|
||||
include_avatar_link: bool,
|
||||
) -> None:
|
||||
columns: list[tuple[str, str]] = [
|
||||
("username", "用户名"),
|
||||
("displayName", "显示名称"),
|
||||
("remark", "备注"),
|
||||
("nickname", "昵称"),
|
||||
("alias", "微信号"),
|
||||
("type", "类型"),
|
||||
("region", "地区"),
|
||||
("country", "国家/地区码"),
|
||||
("province", "省份"),
|
||||
("city", "城市"),
|
||||
("source", "来源"),
|
||||
("sourceScene", "来源场景码"),
|
||||
]
|
||||
if include_avatar_link:
|
||||
columns.append(("avatarLink", "头像链接"))
|
||||
|
||||
with output_path.open("w", encoding="utf-8-sig", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([label for _, label in columns])
|
||||
for item in contacts:
|
||||
writer.writerow([_normalize_text(item.get(key, "")) for key, _ in columns])
|
||||
|
||||
|
||||
@router.get("/api/chat/contacts", summary="获取联系人列表")
|
||||
def list_chat_contacts(
|
||||
request: Request,
|
||||
account: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
include_friends: bool = True,
|
||||
include_groups: bool = True,
|
||||
include_officials: bool = True,
|
||||
):
|
||||
account_dir = _resolve_account_dir(account)
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
|
||||
contacts = _collect_contacts_for_account(
|
||||
account_dir=account_dir,
|
||||
base_url=base_url,
|
||||
keyword=keyword,
|
||||
include_friends=bool(include_friends),
|
||||
include_groups=bool(include_groups),
|
||||
include_officials=bool(include_officials),
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
"total": len(contacts),
|
||||
"counts": _build_counts(contacts),
|
||||
"contacts": contacts,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/chat/contacts/export", summary="导出联系人")
|
||||
def export_chat_contacts(request: Request, req: ContactExportRequest):
|
||||
account_dir = _resolve_account_dir(req.account)
|
||||
|
||||
output_dir_raw = _normalize_text(req.output_dir)
|
||||
if not output_dir_raw:
|
||||
raise HTTPException(status_code=400, detail="output_dir is required.")
|
||||
|
||||
output_dir = Path(output_dir_raw).expanduser()
|
||||
if not output_dir.is_absolute():
|
||||
raise HTTPException(status_code=400, detail="output_dir must be an absolute path.")
|
||||
|
||||
try:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to prepare output_dir: {e}")
|
||||
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
contacts = _collect_contacts_for_account(
|
||||
account_dir=account_dir,
|
||||
base_url=base_url,
|
||||
keyword=req.keyword,
|
||||
include_friends=bool(req.contact_types.friends),
|
||||
include_groups=bool(req.contact_types.groups),
|
||||
include_officials=bool(req.contact_types.officials),
|
||||
)
|
||||
|
||||
export_contacts = _build_export_contacts(
|
||||
contacts,
|
||||
include_avatar_link=bool(req.include_avatar_link),
|
||||
)
|
||||
|
||||
fmt = _normalize_text(req.format).lower()
|
||||
if fmt not in {"json", "csv"}:
|
||||
raise HTTPException(status_code=400, detail="Unsupported format, use 'json' or 'csv'.")
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_account = _safe_export_part(account_dir.name)
|
||||
output_path = output_dir / f"contacts_{safe_account}_{ts}.{fmt}"
|
||||
|
||||
try:
|
||||
if fmt == "json":
|
||||
_write_json_export(
|
||||
output_path,
|
||||
account=account_dir.name,
|
||||
contacts=export_contacts,
|
||||
include_avatar_link=bool(req.include_avatar_link),
|
||||
keyword=_normalize_text(req.keyword),
|
||||
contact_types=req.contact_types,
|
||||
)
|
||||
else:
|
||||
_write_csv_export(
|
||||
output_path,
|
||||
contacts=export_contacts,
|
||||
include_avatar_link=bool(req.include_avatar_link),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to export contacts: {e}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
"format": fmt,
|
||||
"outputPath": str(output_path),
|
||||
"count": len(export_contacts),
|
||||
}
|
||||
@@ -27,15 +27,16 @@ class ChatExportCreateRequest(BaseModel):
|
||||
end_time: Optional[int] = Field(None, description="结束时间(Unix 秒,含)")
|
||||
include_hidden: bool = Field(False, description="是否包含隐藏会话(scope!=selected 时)")
|
||||
include_official: bool = Field(False, description="是否包含公众号/官方账号会话(scope!=selected 时)")
|
||||
include_media: bool = Field(True, description="是否打包离线媒体(图片/表情/视频/语音/文件)")
|
||||
include_media: bool = Field(True, description="是否允许打包离线媒体(最终仍受 message_types 与 privacy_mode 约束)")
|
||||
media_kinds: list[MediaKind] = Field(
|
||||
default_factory=lambda: ["image", "emoji", "video", "video_thumb", "voice", "file"],
|
||||
description="打包的媒体类型",
|
||||
description="允许打包的媒体类型(最终仍受 message_types 勾选约束)",
|
||||
)
|
||||
message_types: list[MessageType] = Field(
|
||||
default_factory=list,
|
||||
description="导出消息类型(renderType)过滤:为空=导出全部消息;可多选(如仅 voice / 仅 transfer / 仅 redPacket 等)",
|
||||
description="导出消息类型(renderType)过滤:为空=导出全部类型;不为空时,仅导出勾选类型",
|
||||
)
|
||||
output_dir: Optional[str] = Field(None, description="导出目录绝对路径(可选;不填时使用默认目录)")
|
||||
allow_process_key_extract: bool = Field(
|
||||
False,
|
||||
description="预留字段:本项目不从微信进程提取媒体密钥,请使用 wx_key 获取并保存/批量解密",
|
||||
@@ -61,6 +62,7 @@ async def create_chat_export(req: ChatExportCreateRequest):
|
||||
include_media=req.include_media,
|
||||
media_kinds=req.media_kinds,
|
||||
message_types=req.message_types,
|
||||
output_dir=req.output_dir,
|
||||
allow_process_key_extract=req.allow_process_key_extract,
|
||||
privacy_mode=req.privacy_mode,
|
||||
file_name=req.file_name,
|
||||
|
||||
@@ -8,7 +8,7 @@ import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
@@ -16,6 +16,21 @@ from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..avatar_cache import (
|
||||
AVATAR_CACHE_TTL_SECONDS,
|
||||
avatar_cache_entry_file_exists,
|
||||
avatar_cache_entry_is_fresh,
|
||||
build_avatar_cache_response_headers,
|
||||
cache_key_for_avatar_user,
|
||||
cache_key_for_avatar_url,
|
||||
get_avatar_cache_url_entry,
|
||||
get_avatar_cache_user_entry,
|
||||
is_avatar_cache_enabled,
|
||||
normalize_avatar_source_url,
|
||||
touch_avatar_cache_entry,
|
||||
upsert_avatar_cache_entry,
|
||||
write_avatar_cache_payload,
|
||||
)
|
||||
from ..logging_config import get_logger
|
||||
from ..media_helpers import (
|
||||
_convert_silk_to_wav,
|
||||
@@ -43,14 +58,56 @@ from ..media_helpers import (
|
||||
_try_find_decrypted_resource,
|
||||
_try_strip_media_prefix,
|
||||
)
|
||||
from ..chat_helpers import _extract_md5_from_packed_info
|
||||
from ..chat_helpers import _extract_md5_from_packed_info, _load_contact_rows, _pick_avatar_url
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..wcdb_realtime import WCDB_REALTIME, get_avatar_urls as _wcdb_get_avatar_urls
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
|
||||
def _resolve_avatar_remote_url(*, account_dir: Path, username: str) -> str:
|
||||
u = str(username or "").strip()
|
||||
if not u:
|
||||
return ""
|
||||
|
||||
# 1) contact.db first (cheap local lookup)
|
||||
try:
|
||||
rows = _load_contact_rows(account_dir / "contact.db", [u])
|
||||
row = rows.get(u)
|
||||
raw = str(_pick_avatar_url(row) or "").strip()
|
||||
if raw.lower().startswith(("http://", "https://")):
|
||||
return normalize_avatar_source_url(raw)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) WCDB fallback (more complete on enterprise/openim IDs)
|
||||
try:
|
||||
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
|
||||
with wcdb_conn.lock:
|
||||
mp = _wcdb_get_avatar_urls(wcdb_conn.handle, [u])
|
||||
wa = str(mp.get(u) or "").strip()
|
||||
if wa.lower().startswith(("http://", "https://")):
|
||||
return normalize_avatar_source_url(wa)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_304_headers(headers: Any) -> tuple[str, str]:
|
||||
try:
|
||||
etag = str((headers or {}).get("ETag") or "").strip()
|
||||
except Exception:
|
||||
etag = ""
|
||||
try:
|
||||
last_modified = str((headers or {}).get("Last-Modified") or "").strip()
|
||||
except Exception:
|
||||
last_modified = ""
|
||||
return etag, last_modified
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _fast_probe_image_path_in_chat_attach(
|
||||
*,
|
||||
@@ -267,27 +324,309 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
if not username:
|
||||
raise HTTPException(status_code=400, detail="Missing username.")
|
||||
account_dir = _resolve_account_dir(account)
|
||||
account_name = str(account_dir.name or "").strip()
|
||||
user_key = str(username or "").strip()
|
||||
|
||||
# 1) Try on-disk cache first (fast path)
|
||||
user_entry = None
|
||||
cached_file = None
|
||||
if is_avatar_cache_enabled() and account_name and user_key:
|
||||
try:
|
||||
user_entry = get_avatar_cache_user_entry(account_name, user_key)
|
||||
cached_file = avatar_cache_entry_file_exists(account_name, user_entry)
|
||||
if cached_file is not None:
|
||||
logger.info(f"[avatar_cache_hit] kind=user account={account_name} username={user_key}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[avatar_cache_error] read user cache failed account={account_name} username={user_key} err={e}")
|
||||
|
||||
head_image_db_path = account_dir / "head_image.db"
|
||||
if not head_image_db_path.exists():
|
||||
# No local head_image.db: allow fallback from cached/remote URL path.
|
||||
if cached_file is not None and user_entry:
|
||||
headers = build_avatar_cache_response_headers(user_entry)
|
||||
return FileResponse(
|
||||
str(cached_file),
|
||||
media_type=str(user_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="head_image.db not found.")
|
||||
|
||||
conn = sqlite3.connect(str(head_image_db_path))
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT image_buffer FROM head_image WHERE username = ? ORDER BY update_time DESC LIMIT 1",
|
||||
meta = conn.execute(
|
||||
"SELECT md5, update_time FROM head_image WHERE username = ? ORDER BY update_time DESC LIMIT 1",
|
||||
(username,),
|
||||
).fetchone()
|
||||
if meta and meta[0] is not None:
|
||||
db_md5 = str(meta[0] or "").strip().lower()
|
||||
try:
|
||||
db_update_time = int(meta[1] or 0)
|
||||
except Exception:
|
||||
db_update_time = 0
|
||||
|
||||
# Cache still valid against head_image metadata.
|
||||
if cached_file is not None and user_entry:
|
||||
cached_md5 = str(user_entry.get("source_md5") or "").strip().lower()
|
||||
try:
|
||||
cached_update = int(user_entry.get("source_update_time") or 0)
|
||||
except Exception:
|
||||
cached_update = 0
|
||||
if cached_md5 == db_md5 and cached_update == db_update_time:
|
||||
touch_avatar_cache_entry(account_name, str(user_entry.get("cache_key") or ""))
|
||||
headers = build_avatar_cache_response_headers(user_entry)
|
||||
return FileResponse(
|
||||
str(cached_file),
|
||||
media_type=str(user_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Refresh from blob (changed or first-load)
|
||||
row = conn.execute(
|
||||
"SELECT image_buffer FROM head_image WHERE username = ? ORDER BY update_time DESC LIMIT 1",
|
||||
(username,),
|
||||
).fetchone()
|
||||
if row and row[0] is not None:
|
||||
data = bytes(row[0]) if isinstance(row[0], (memoryview, bytearray)) else row[0]
|
||||
if not isinstance(data, (bytes, bytearray)):
|
||||
data = bytes(data)
|
||||
if data:
|
||||
media_type = _detect_image_media_type(data)
|
||||
media_type = media_type if media_type.startswith("image/") else "application/octet-stream"
|
||||
entry, out_path = write_avatar_cache_payload(
|
||||
account_name,
|
||||
source_kind="user",
|
||||
username=user_key,
|
||||
payload=bytes(data),
|
||||
media_type=media_type,
|
||||
source_md5=db_md5,
|
||||
source_update_time=db_update_time,
|
||||
ttl_seconds=AVATAR_CACHE_TTL_SECONDS,
|
||||
)
|
||||
if entry and out_path:
|
||||
logger.info(
|
||||
f"[avatar_cache_download] kind=user account={account_name} username={user_key} src=head_image"
|
||||
)
|
||||
headers = build_avatar_cache_response_headers(entry)
|
||||
return FileResponse(str(out_path), media_type=media_type, headers=headers)
|
||||
|
||||
# cache write failed: fallback to response bytes
|
||||
logger.warning(
|
||||
f"[avatar_cache_error] kind=user account={account_name} username={user_key} action=write_fallback"
|
||||
)
|
||||
return Response(content=bytes(data), media_type=media_type)
|
||||
|
||||
# meta not found (no local avatar blob)
|
||||
row = None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not row or row[0] is None:
|
||||
raise HTTPException(status_code=404, detail="Avatar not found.")
|
||||
# 2) Fallback: remote avatar URL (contact/WCDB), cache by URL.
|
||||
remote_url = _resolve_avatar_remote_url(account_dir=account_dir, username=user_key)
|
||||
if remote_url and is_avatar_cache_enabled():
|
||||
url_entry = get_avatar_cache_url_entry(account_name, remote_url)
|
||||
url_file = avatar_cache_entry_file_exists(account_name, url_entry)
|
||||
if url_entry and url_file and avatar_cache_entry_is_fresh(url_entry):
|
||||
logger.info(f"[avatar_cache_hit] kind=url account={account_name} username={user_key}")
|
||||
touch_avatar_cache_entry(account_name, str(url_entry.get("cache_key") or ""))
|
||||
# Keep user-key mapping aligned, so next user lookup is direct.
|
||||
try:
|
||||
upsert_avatar_cache_entry(
|
||||
account_name,
|
||||
cache_key=cache_key_for_avatar_user(user_key),
|
||||
source_kind="user",
|
||||
username=user_key,
|
||||
source_url=remote_url,
|
||||
source_md5=str(url_entry.get("source_md5") or ""),
|
||||
source_update_time=int(url_entry.get("source_update_time") or 0),
|
||||
rel_path=str(url_entry.get("rel_path") or ""),
|
||||
media_type=str(url_entry.get("media_type") or "application/octet-stream"),
|
||||
size_bytes=int(url_entry.get("size_bytes") or 0),
|
||||
etag=str(url_entry.get("etag") or ""),
|
||||
last_modified=str(url_entry.get("last_modified") or ""),
|
||||
fetched_at=int(url_entry.get("fetched_at") or 0),
|
||||
checked_at=int(url_entry.get("checked_at") or 0),
|
||||
expires_at=int(url_entry.get("expires_at") or 0),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
headers = build_avatar_cache_response_headers(url_entry)
|
||||
return FileResponse(
|
||||
str(url_file),
|
||||
media_type=str(url_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
data = bytes(row[0]) if isinstance(row[0], (memoryview, bytearray)) else row[0]
|
||||
if not isinstance(data, (bytes, bytearray)):
|
||||
data = bytes(data)
|
||||
media_type = _detect_image_media_type(data)
|
||||
return Response(content=data, media_type=media_type)
|
||||
# Revalidate / download remote avatar
|
||||
def _download_remote_avatar(
|
||||
source_url: str,
|
||||
*,
|
||||
etag: str,
|
||||
last_modified: str,
|
||||
) -> tuple[bytes, str, str, str, bool]:
|
||||
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",
|
||||
}
|
||||
|
||||
header_variants = [
|
||||
{
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
|
||||
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Referer": "https://servicewechat.com/",
|
||||
"Origin": "https://servicewechat.com",
|
||||
"Range": "bytes=0-",
|
||||
},
|
||||
{"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)
|
||||
if etag:
|
||||
headers["If-None-Match"] = etag
|
||||
if last_modified:
|
||||
headers["If-Modified-Since"] = last_modified
|
||||
|
||||
r = requests.get(source_url, headers=headers, timeout=20, stream=True)
|
||||
try:
|
||||
if r.status_code == 304:
|
||||
e2, lm2 = _parse_304_headers(r.headers)
|
||||
return b"", "", (e2 or etag), (lm2 or last_modified), True
|
||||
r.raise_for_status()
|
||||
content_type = str(r.headers.get("Content-Type") or "").strip()
|
||||
e2, lm2 = _parse_304_headers(r.headers)
|
||||
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="Avatar too large (>10MB).")
|
||||
return b"".join(chunks), content_type, e2, lm2, False
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
finally:
|
||||
try:
|
||||
r.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise last_err or RuntimeError("avatar remote download failed")
|
||||
|
||||
etag0 = str((url_entry or {}).get("etag") or "").strip()
|
||||
lm0 = str((url_entry or {}).get("last_modified") or "").strip()
|
||||
try:
|
||||
payload, ct, etag_new, lm_new, not_modified = await asyncio.to_thread(
|
||||
_download_remote_avatar,
|
||||
remote_url,
|
||||
etag=etag0,
|
||||
last_modified=lm0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[avatar_cache_error] kind=url account={account_name} username={user_key} err={e}")
|
||||
if url_entry and url_file:
|
||||
headers = build_avatar_cache_response_headers(url_entry)
|
||||
return FileResponse(
|
||||
str(url_file),
|
||||
media_type=str(url_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Avatar not found.")
|
||||
|
||||
if not_modified and url_entry and url_file:
|
||||
touch_avatar_cache_entry(account_name, cache_key_for_avatar_url(remote_url))
|
||||
if etag_new or lm_new:
|
||||
try:
|
||||
upsert_avatar_cache_entry(
|
||||
account_name,
|
||||
cache_key=cache_key_for_avatar_url(remote_url),
|
||||
source_kind="url",
|
||||
username=user_key,
|
||||
source_url=remote_url,
|
||||
source_md5=str(url_entry.get("source_md5") or ""),
|
||||
source_update_time=int(url_entry.get("source_update_time") or 0),
|
||||
rel_path=str(url_entry.get("rel_path") or ""),
|
||||
media_type=str(url_entry.get("media_type") or "application/octet-stream"),
|
||||
size_bytes=int(url_entry.get("size_bytes") or 0),
|
||||
etag=etag_new or etag0,
|
||||
last_modified=lm_new or lm0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"[avatar_cache_revalidate] kind=url account={account_name} username={user_key} status=304")
|
||||
headers = build_avatar_cache_response_headers(url_entry)
|
||||
return FileResponse(
|
||||
str(url_file),
|
||||
media_type=str(url_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if payload:
|
||||
payload2, media_type, _ext = _detect_media_type_and_ext(payload)
|
||||
if media_type == "application/octet-stream" and ct:
|
||||
try:
|
||||
mt = ct.split(";")[0].strip()
|
||||
if mt.startswith("image/"):
|
||||
media_type = mt
|
||||
except Exception:
|
||||
pass
|
||||
if str(media_type or "").startswith("image/"):
|
||||
entry, out_path = write_avatar_cache_payload(
|
||||
account_name,
|
||||
source_kind="url",
|
||||
username=user_key,
|
||||
source_url=remote_url,
|
||||
payload=payload2,
|
||||
media_type=media_type,
|
||||
etag=etag_new,
|
||||
last_modified=lm_new,
|
||||
ttl_seconds=AVATAR_CACHE_TTL_SECONDS,
|
||||
)
|
||||
if entry and out_path:
|
||||
# bind user-key record to same file for quicker next access
|
||||
try:
|
||||
upsert_avatar_cache_entry(
|
||||
account_name,
|
||||
cache_key=cache_key_for_avatar_user(user_key),
|
||||
source_kind="user",
|
||||
username=user_key,
|
||||
source_url=remote_url,
|
||||
source_md5=str(entry.get("source_md5") or ""),
|
||||
source_update_time=int(entry.get("source_update_time") or 0),
|
||||
rel_path=str(entry.get("rel_path") or ""),
|
||||
media_type=str(entry.get("media_type") or "application/octet-stream"),
|
||||
size_bytes=int(entry.get("size_bytes") or 0),
|
||||
etag=str(entry.get("etag") or ""),
|
||||
last_modified=str(entry.get("last_modified") or ""),
|
||||
fetched_at=int(entry.get("fetched_at") or 0),
|
||||
checked_at=int(entry.get("checked_at") or 0),
|
||||
expires_at=int(entry.get("expires_at") or 0),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"[avatar_cache_download] kind=url account={account_name} username={user_key}")
|
||||
headers = build_avatar_cache_response_headers(entry)
|
||||
return FileResponse(str(out_path), media_type=media_type, headers=headers)
|
||||
|
||||
if cached_file is not None and user_entry:
|
||||
headers = build_avatar_cache_response_headers(user_entry)
|
||||
return FileResponse(
|
||||
str(cached_file),
|
||||
media_type=str(user_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Avatar not found.")
|
||||
|
||||
|
||||
class EmojiDownloadRequest(BaseModel):
|
||||
@@ -349,6 +688,83 @@ def _lookup_resource_md5_by_server_id(account_dir_str: str, server_id: int, want
|
||||
pass
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _lookup_image_md5_by_server_id_from_messages(account_dir_str: str, server_id: int, username: str) -> str:
|
||||
account_dir_str = str(account_dir_str or "").strip()
|
||||
username = str(username or "").strip()
|
||||
if not account_dir_str or not username:
|
||||
return ""
|
||||
|
||||
try:
|
||||
sid = int(server_id or 0)
|
||||
except Exception:
|
||||
sid = 0
|
||||
if not sid:
|
||||
return ""
|
||||
|
||||
try:
|
||||
chat_hash = hashlib.md5(username.encode()).hexdigest()
|
||||
except Exception:
|
||||
return ""
|
||||
if not chat_hash:
|
||||
return ""
|
||||
|
||||
table_name = f"Msg_{chat_hash}"
|
||||
account_dir = Path(account_dir_str)
|
||||
|
||||
db_paths: list[Path] = []
|
||||
try:
|
||||
for p in account_dir.glob("message_*.db"):
|
||||
try:
|
||||
if p.is_file():
|
||||
db_paths.append(p)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
db_paths = []
|
||||
|
||||
if not db_paths:
|
||||
return ""
|
||||
db_paths.sort(key=lambda p: p.name)
|
||||
|
||||
for db_path in db_paths:
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
try:
|
||||
row = conn.execute(
|
||||
f"SELECT local_type, packed_info_data FROM {table_name} "
|
||||
"WHERE server_id = ? ORDER BY create_time DESC LIMIT 1",
|
||||
(sid,),
|
||||
).fetchone()
|
||||
except Exception:
|
||||
row = None
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not row:
|
||||
continue
|
||||
|
||||
try:
|
||||
local_type = int(row[0] or 0)
|
||||
except Exception:
|
||||
local_type = 0
|
||||
if local_type != 3:
|
||||
continue
|
||||
|
||||
md5 = _extract_md5_from_packed_info(row[1])
|
||||
md5_norm = str(md5 or "").strip().lower()
|
||||
if _is_valid_md5(md5_norm):
|
||||
return md5_norm
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _is_safe_http_url(url: str) -> bool:
|
||||
u = str(url or "").strip()
|
||||
if not u:
|
||||
@@ -434,7 +850,25 @@ async def proxy_image(url: str):
|
||||
if not _is_allowed_proxy_image_host(host):
|
||||
raise HTTPException(status_code=400, detail="Unsupported url host for proxy_image.")
|
||||
|
||||
def _download_bytes() -> tuple[bytes, str]:
|
||||
source_url = normalize_avatar_source_url(u)
|
||||
proxy_account = "_proxy"
|
||||
cache_entry = get_avatar_cache_url_entry(proxy_account, source_url) if is_avatar_cache_enabled() else None
|
||||
cache_file = avatar_cache_entry_file_exists(proxy_account, cache_entry)
|
||||
if cache_entry and cache_file and avatar_cache_entry_is_fresh(cache_entry):
|
||||
logger.info(f"[avatar_cache_hit] kind=proxy_url account={proxy_account}")
|
||||
touch_avatar_cache_entry(proxy_account, cache_key_for_avatar_url(source_url))
|
||||
headers = build_avatar_cache_response_headers(cache_entry)
|
||||
return FileResponse(
|
||||
str(cache_file),
|
||||
media_type=str(cache_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
def _download_bytes(
|
||||
*,
|
||||
if_none_match: str = "",
|
||||
if_modified_since: str = "",
|
||||
) -> tuple[bytes, str, str, str, bool]:
|
||||
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",
|
||||
@@ -464,10 +898,20 @@ async def proxy_image(url: str):
|
||||
for extra in header_variants:
|
||||
headers = dict(base_headers)
|
||||
headers.update(extra)
|
||||
if if_none_match:
|
||||
headers["If-None-Match"] = if_none_match
|
||||
if if_modified_since:
|
||||
headers["If-Modified-Since"] = if_modified_since
|
||||
r = requests.get(u, headers=headers, timeout=20, stream=True)
|
||||
try:
|
||||
if r.status_code == 304:
|
||||
etag0 = str(r.headers.get("ETag") or "").strip()
|
||||
lm0 = str(r.headers.get("Last-Modified") or "").strip()
|
||||
return b"", "", etag0, lm0, True
|
||||
r.raise_for_status()
|
||||
content_type = str(r.headers.get("Content-Type") or "").strip()
|
||||
etag0 = str(r.headers.get("ETag") or "").strip()
|
||||
lm0 = str(r.headers.get("Last-Modified") or "").strip()
|
||||
max_bytes = 10 * 1024 * 1024
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
@@ -478,7 +922,7 @@ async def proxy_image(url: str):
|
||||
total += len(ch)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=400, detail="Proxy image too large (>10MB).")
|
||||
return b"".join(chunks), content_type
|
||||
return b"".join(chunks), content_type, etag0, lm0, False
|
||||
except HTTPException:
|
||||
# Hard failure, don't retry with another referer.
|
||||
raise
|
||||
@@ -493,14 +937,50 @@ async def proxy_image(url: str):
|
||||
# All variants failed.
|
||||
raise last_err or RuntimeError("proxy_image download failed")
|
||||
|
||||
etag0 = str((cache_entry or {}).get("etag") or "").strip()
|
||||
lm0 = str((cache_entry or {}).get("last_modified") or "").strip()
|
||||
try:
|
||||
data, ct = await asyncio.to_thread(_download_bytes)
|
||||
data, ct, etag_new, lm_new, not_modified = await asyncio.to_thread(
|
||||
_download_bytes,
|
||||
if_none_match=etag0,
|
||||
if_modified_since=lm0,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"proxy_image failed: url={u} err={e}")
|
||||
if cache_entry and cache_file:
|
||||
headers = build_avatar_cache_response_headers(cache_entry)
|
||||
return FileResponse(
|
||||
str(cache_file),
|
||||
media_type=str(cache_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=f"Proxy image failed: {e}")
|
||||
|
||||
if not_modified and cache_entry and cache_file:
|
||||
logger.info(f"[avatar_cache_revalidate] kind=proxy_url account={proxy_account} status=304")
|
||||
upsert_avatar_cache_entry(
|
||||
proxy_account,
|
||||
cache_key=cache_key_for_avatar_url(source_url),
|
||||
source_kind="url",
|
||||
source_url=source_url,
|
||||
username="",
|
||||
source_md5=str(cache_entry.get("source_md5") or ""),
|
||||
source_update_time=int(cache_entry.get("source_update_time") or 0),
|
||||
rel_path=str(cache_entry.get("rel_path") or ""),
|
||||
media_type=str(cache_entry.get("media_type") or "application/octet-stream"),
|
||||
size_bytes=int(cache_entry.get("size_bytes") or 0),
|
||||
etag=etag_new or etag0,
|
||||
last_modified=lm_new or lm0,
|
||||
)
|
||||
headers = build_avatar_cache_response_headers(cache_entry)
|
||||
return FileResponse(
|
||||
str(cache_file),
|
||||
media_type=str(cache_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if not data:
|
||||
raise HTTPException(status_code=502, detail="Proxy returned empty body.")
|
||||
|
||||
@@ -518,8 +998,24 @@ async def proxy_image(url: str):
|
||||
if not str(media_type or "").startswith("image/"):
|
||||
raise HTTPException(status_code=502, detail="Proxy did not return an image.")
|
||||
|
||||
if is_avatar_cache_enabled():
|
||||
entry, out_path = write_avatar_cache_payload(
|
||||
proxy_account,
|
||||
source_kind="url",
|
||||
source_url=source_url,
|
||||
payload=payload,
|
||||
media_type=media_type,
|
||||
etag=etag_new,
|
||||
last_modified=lm_new,
|
||||
ttl_seconds=AVATAR_CACHE_TTL_SECONDS,
|
||||
)
|
||||
if entry and out_path:
|
||||
logger.info(f"[avatar_cache_download] kind=proxy_url account={proxy_account}")
|
||||
headers = build_avatar_cache_response_headers(entry)
|
||||
return FileResponse(str(out_path), media_type=media_type, headers=headers)
|
||||
|
||||
resp = Response(content=payload, media_type=media_type)
|
||||
resp.headers["Cache-Control"] = "public, max-age=86400"
|
||||
resp.headers["Cache-Control"] = f"public, max-age={AVATAR_CACHE_TTL_SECONDS}"
|
||||
return resp
|
||||
|
||||
|
||||
@@ -643,6 +1139,12 @@ async def get_chat_image(
|
||||
resource_md5 = _lookup_resource_md5_by_server_id(str(account_dir), int(server_id), want_local_type=3)
|
||||
if resource_md5:
|
||||
md5 = resource_md5
|
||||
elif username:
|
||||
md5_from_msg = _lookup_image_md5_by_server_id_from_messages(
|
||||
str(account_dir), int(server_id), str(username)
|
||||
)
|
||||
if md5_from_msg:
|
||||
md5 = md5_from_msg
|
||||
|
||||
# md5 模式:优先从解密资源目录读取(更快)
|
||||
if md5:
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..key_store import get_account_keys_from_store
|
||||
from ..key_service import get_db_key_workflow, fetch_and_save_remote_keys
|
||||
from ..media_helpers import _load_media_keys, _resolve_account_dir
|
||||
from ..path_fix import PathFixRoute
|
||||
|
||||
@@ -51,3 +52,76 @@ async def get_saved_keys(account: Optional[str] = None):
|
||||
"keys": result,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/get_db_key", summary="自动获取微信数据库密钥")
|
||||
async def get_wechat_db_key():
|
||||
"""
|
||||
自动流程:
|
||||
1. 结束微信进程
|
||||
2. 启动微信
|
||||
3. 根据版本注入 Hook
|
||||
4. 抓取密钥并返回
|
||||
"""
|
||||
try:
|
||||
# 不需要async吧,我相信fastapi的线程池
|
||||
db_key = get_db_key_workflow()
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
"errmsg": "ok",
|
||||
"data": {
|
||||
"db_key": db_key
|
||||
}
|
||||
}
|
||||
|
||||
except TimeoutError:
|
||||
return {
|
||||
"status": -1,
|
||||
"errmsg": "获取超时,请确保微信没有开启自动登录 或者 加快手速",
|
||||
"data": {}
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": -1,
|
||||
"errmsg": f"获取失败: {str(e)}",
|
||||
"data": {}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/get_image_key", summary="获取并保存微信图片密钥")
|
||||
async def get_image_key(account: Optional[str] = None):
|
||||
"""
|
||||
通过模拟 Next.js Server Action 协议,利用本地微信配置文件换取 AES/XOR 密钥。
|
||||
|
||||
1. 读取 [wx_dir]/all_users/config/global_config (Blob 1)
|
||||
2. 读 同上目录下的global_config.crc
|
||||
3. 构造 Multipart 包发送至远程服务器
|
||||
4. 解析返回流,自动存入本地数据库
|
||||
"""
|
||||
try:
|
||||
result = await fetch_and_save_remote_keys(account)
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
"errmsg": "ok",
|
||||
"data": {
|
||||
"xor_key": result["xor_key"],
|
||||
"aes_key": result["aes_key"],
|
||||
"nick_name": result.get("nick_name"),
|
||||
"account": result["wxid"]
|
||||
}
|
||||
}
|
||||
except FileNotFoundError as e:
|
||||
return {
|
||||
"status": -1,
|
||||
"errmsg": f"文件缺失: {str(e)}",
|
||||
"data": {}
|
||||
}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"status": -1,
|
||||
"errmsg": f"获取失败: {str(e)}",
|
||||
"data": {}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from typing import Optional
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..logging_config import get_logger
|
||||
@@ -71,3 +71,49 @@ async def detect_current_account(data_root_path: Optional[str] = None):
|
||||
'error': str(e),
|
||||
'data': None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/wechat/status", summary="检查微信运行状态")
|
||||
async def check_wechat_status():
|
||||
"""
|
||||
检查系统中是否有 Weixin.exe 或 WeChat.exe 进程在运行
|
||||
返回: status=0 成功, wx_status={is_running: bool, pid: int, ...}
|
||||
"""
|
||||
process_name_targets = ["Weixin.exe", "WeChat.exe"]
|
||||
|
||||
wx_status = {
|
||||
"is_running": False,
|
||||
"pid": None,
|
||||
"exe_path": None,
|
||||
"memory_usage_mb": 0.0
|
||||
}
|
||||
|
||||
try:
|
||||
for proc in psutil.process_iter(['pid', 'name', 'exe', 'memory_info']):
|
||||
try:
|
||||
if proc.info['name'] and proc.info['name'] in process_name_targets:
|
||||
wx_status["is_running"] = True
|
||||
wx_status["pid"] = proc.info['pid']
|
||||
wx_status["exe_path"] = proc.info['exe']
|
||||
|
||||
mem = proc.info['memory_info']
|
||||
if mem:
|
||||
wx_status["memory_usage_mb"] = round(mem.rss / (1024 * 1024), 2)
|
||||
|
||||
break
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
"errmsg": "ok",
|
||||
"wx_status": wx_status
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# 即使出错也返回 JSON,但 status 非 0
|
||||
return {
|
||||
"status": -1,
|
||||
"errmsg": f"检查进程失败: {str(e)}",
|
||||
"wx_status": wx_status
|
||||
}
|
||||
|
||||
@@ -102,6 +102,17 @@ 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 (newer DLLs): wcdb_get_group_nicknames(handle, chatroom_id, out_json)
|
||||
try:
|
||||
lib.wcdb_get_group_nicknames.argtypes = [
|
||||
ctypes.c_int64,
|
||||
ctypes.c_char_p,
|
||||
ctypes.POINTER(ctypes.c_char_p),
|
||||
]
|
||||
lib.wcdb_get_group_nicknames.restype = ctypes.c_int
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Optional: execute arbitrary SQL on a selected database kind/path.
|
||||
# Signature: wcdb_exec_query(handle, kind, path, sql, out_json)
|
||||
try:
|
||||
@@ -355,6 +366,41 @@ def get_avatar_urls(handle: int, usernames: list[str]) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
def get_group_members(handle: int, chatroom_id: str) -> list[dict[str, Any]]:
|
||||
_ensure_initialized()
|
||||
lib = _load_wcdb_lib()
|
||||
cid = str(chatroom_id or "").strip()
|
||||
if not cid:
|
||||
return []
|
||||
out_json = _call_out_json(lib.wcdb_get_group_members, ctypes.c_int64(int(handle)), cid.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_group_nicknames(handle: int, chatroom_id: str) -> dict[str, str]:
|
||||
_ensure_initialized()
|
||||
lib = _load_wcdb_lib()
|
||||
fn = getattr(lib, "wcdb_get_group_nicknames", None)
|
||||
if not fn:
|
||||
return {}
|
||||
|
||||
cid = str(chatroom_id or "").strip()
|
||||
if not cid:
|
||||
return {}
|
||||
|
||||
out_json = _call_out_json(fn, ctypes.c_int64(int(handle)), cid.encode("utf-8"))
|
||||
decoded = _safe_load_json(out_json)
|
||||
if isinstance(decoded, dict):
|
||||
return {str(k): str(v) for k, v in decoded.items()}
|
||||
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.
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from ...chat_helpers import (
|
||||
_decode_sqlite_text,
|
||||
_iter_message_db_paths,
|
||||
_load_contact_rows,
|
||||
_pick_avatar_url,
|
||||
_pick_display_name,
|
||||
_quote_ident,
|
||||
_should_keep_session,
|
||||
@@ -701,7 +700,7 @@ def build_card_00_global_overview(
|
||||
u, cnt = stats.top_contact
|
||||
row = contact_rows.get(u)
|
||||
display = _pick_display_name(row, u)
|
||||
avatar = _pick_avatar_url(row) or (_build_avatar_url(str(account_dir.name or ""), u) if u else "")
|
||||
avatar = _build_avatar_url(str(account_dir.name or ""), u) if u else ""
|
||||
top_contact_obj = {
|
||||
"username": u,
|
||||
"displayName": display,
|
||||
@@ -716,7 +715,7 @@ def build_card_00_global_overview(
|
||||
u, cnt = stats.top_group
|
||||
row = contact_rows.get(u)
|
||||
display = _pick_display_name(row, u)
|
||||
avatar = _pick_avatar_url(row) or (_build_avatar_url(str(account_dir.name or ""), u) if u else "")
|
||||
avatar = _build_avatar_url(str(account_dir.name or ""), u) if u else ""
|
||||
top_group_obj = {
|
||||
"username": u,
|
||||
"displayName": display,
|
||||
|
||||
@@ -14,7 +14,6 @@ from ...chat_helpers import (
|
||||
_build_avatar_url,
|
||||
_iter_message_db_paths,
|
||||
_load_contact_rows,
|
||||
_pick_avatar_url,
|
||||
_pick_display_name,
|
||||
_quote_ident,
|
||||
_row_to_search_hit,
|
||||
@@ -713,7 +712,7 @@ def _fetch_message_moment_payload(
|
||||
|
||||
contact_row = contact_rows.get(username)
|
||||
display = _pick_display_name(contact_row, username)
|
||||
avatar = _pick_avatar_url(contact_row) or (_build_avatar_url(str(account_dir.name or ""), username) if username else "")
|
||||
avatar = _build_avatar_url(str(account_dir.name or ""), username) if username else ""
|
||||
|
||||
return {
|
||||
"timestamp": int(ref.ts),
|
||||
|
||||
@@ -12,7 +12,6 @@ from typing import Any, Optional
|
||||
from ...chat_helpers import (
|
||||
_build_avatar_url,
|
||||
_load_contact_rows,
|
||||
_pick_avatar_url,
|
||||
_pick_display_name,
|
||||
_should_keep_session,
|
||||
)
|
||||
@@ -385,7 +384,7 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
|
||||
def conv_to_obj(score: float | None, agg: _ConvAgg) -> dict[str, Any]:
|
||||
row = contact_rows.get(agg.username)
|
||||
display = _pick_display_name(row, agg.username)
|
||||
avatar = _pick_avatar_url(row) or (_build_avatar_url(str(account_dir.name or ""), agg.username) if agg.username else "")
|
||||
avatar = _build_avatar_url(str(account_dir.name or ""), agg.username) if agg.username else ""
|
||||
avg_s = agg.avg_gap()
|
||||
out: dict[str, Any] = {
|
||||
"username": agg.username,
|
||||
@@ -420,7 +419,7 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
|
||||
else:
|
||||
row = contact_rows.get(global_fastest_u)
|
||||
display = _pick_display_name(row, global_fastest_u)
|
||||
avatar = _pick_avatar_url(row) or (_build_avatar_url(str(account_dir.name or ""), global_fastest_u) if global_fastest_u else "")
|
||||
avatar = _build_avatar_url(str(account_dir.name or ""), global_fastest_u) if global_fastest_u else ""
|
||||
fastest_obj = {
|
||||
"username": global_fastest_u,
|
||||
"displayName": display,
|
||||
@@ -440,7 +439,7 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
|
||||
else:
|
||||
row = contact_rows.get(global_slowest_u)
|
||||
display = _pick_display_name(row, global_slowest_u)
|
||||
avatar = _pick_avatar_url(row) or (_build_avatar_url(str(account_dir.name or ""), global_slowest_u) if global_slowest_u else "")
|
||||
avatar = _build_avatar_url(str(account_dir.name or ""), global_slowest_u) if global_slowest_u else ""
|
||||
slowest_obj = {
|
||||
"username": global_slowest_u,
|
||||
"displayName": display,
|
||||
@@ -547,7 +546,7 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
|
||||
|
||||
row = contact_rows.get(u)
|
||||
display = _pick_display_name(row, u)
|
||||
avatar = _pick_avatar_url(row) or (_build_avatar_url(str(account_dir.name or ""), u) if u else "")
|
||||
avatar = _build_avatar_url(str(account_dir.name or ""), u) if u else ""
|
||||
series.append(
|
||||
{
|
||||
"username": u,
|
||||
@@ -595,7 +594,7 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
|
||||
if not u:
|
||||
continue
|
||||
display = _pick_display_name(r, u)
|
||||
avatar = _pick_avatar_url(r) or (_build_avatar_url(str(account_dir.name or ""), u) if u else "")
|
||||
avatar = _build_avatar_url(str(account_dir.name or ""), u) if u else ""
|
||||
all_contacts_list.append({
|
||||
"username": u,
|
||||
"displayName": display,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestAvatarCacheChatMedia(unittest.TestCase):
|
||||
def _seed_contact_db(self, path: Path, *, username: str = "wxid_friend") -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
username,
|
||||
"",
|
||||
"测试好友",
|
||||
"",
|
||||
1,
|
||||
0,
|
||||
"https://wx.qlogo.cn/mmhead/ver_1/test_remote_avatar/132",
|
||||
"",
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_session_db(self, path: Path, *, username: str = "wxid_friend") -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT,
|
||||
sort_timestamp INTEGER,
|
||||
last_timestamp INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", (username, 200, 200))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_head_image_db(self, path: Path, *, username: str = "wxid_friend") -> None:
|
||||
# 1x1 PNG
|
||||
png = bytes.fromhex(
|
||||
"89504E470D0A1A0A"
|
||||
"0000000D49484452000000010000000108060000001F15C489"
|
||||
"0000000D49444154789C6360606060000000050001A5F64540"
|
||||
"0000000049454E44AE426082"
|
||||
)
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE head_image(username TEXT PRIMARY KEY, md5 TEXT, image_buffer BLOB, update_time INTEGER)")
|
||||
conn.execute(
|
||||
"INSERT INTO head_image VALUES (?, ?, ?, ?)",
|
||||
(username, "0123456789abcdef0123456789abcdef", sqlite3.Binary(png), 1735689600),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_chat_avatar_caches_to_output_avatar_cache(self):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
account_dir = root / "output" / "databases" / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", username=username)
|
||||
self._seed_session_db(account_dir / "session.db", username=username)
|
||||
self._seed_head_image_db(account_dir / "head_image.db", username=username)
|
||||
|
||||
prev_data = None
|
||||
prev_cache = None
|
||||
try:
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
prev_cache = os.environ.get("WECHAT_TOOL_AVATAR_CACHE_ENABLED")
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
os.environ["WECHAT_TOOL_AVATAR_CACHE_ENABLED"] = "1"
|
||||
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.chat_helpers as chat_helpers
|
||||
import wechat_decrypt_tool.avatar_cache as avatar_cache
|
||||
import wechat_decrypt_tool.routers.chat_media as chat_media
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(chat_helpers)
|
||||
importlib.reload(avatar_cache)
|
||||
importlib.reload(chat_media)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(chat_media.router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/chat/avatar", params={"account": account, "username": username})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTrue(resp.headers.get("content-type", "").startswith("image/"))
|
||||
|
||||
cache_db = root / "output" / "avatar_cache" / account / "avatar_cache.db"
|
||||
self.assertTrue(cache_db.exists())
|
||||
|
||||
conn = sqlite3.connect(str(cache_db))
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT cache_key, source_kind, username, rel_path, media_type FROM avatar_cache_entries WHERE source_kind = 'user' LIMIT 1"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(row)
|
||||
rel_path = str(row[3] or "")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
self.assertTrue(rel_path)
|
||||
cache_file = (root / "output" / "avatar_cache" / account / rel_path).resolve()
|
||||
self.assertTrue(cache_file.exists())
|
||||
|
||||
resp2 = client.get("/api/chat/avatar", params={"account": account, "username": username})
|
||||
self.assertEqual(resp2.status_code, 200)
|
||||
self.assertEqual(resp2.content, resp.content)
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
if prev_cache is None:
|
||||
os.environ.pop("WECHAT_TOOL_AVATAR_CACHE_ENABLED", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_AVATAR_CACHE_ENABLED"] = prev_cache
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
import zipfile
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestChatExportMessageTypesSemantics(unittest.TestCase):
|
||||
def _reload_export_modules(self):
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.chat_helpers as chat_helpers
|
||||
import wechat_decrypt_tool.media_helpers as media_helpers
|
||||
import wechat_decrypt_tool.chat_export_service as chat_export_service
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(chat_helpers)
|
||||
importlib.reload(media_helpers)
|
||||
importlib.reload(chat_export_service)
|
||||
return chat_export_service
|
||||
|
||||
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(account, "", "我", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(username, "", "测试好友", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_session_db(self, path: Path, *, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT,
|
||||
is_hidden INTEGER,
|
||||
sort_timestamp INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO SessionTable VALUES (?, ?, ?)",
|
||||
(username, 0, 1735689600),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_message_db(self, path: Path, *, account: str, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE Name2Id (rowid INTEGER PRIMARY KEY, user_name TEXT)")
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (1, account))
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (2, username))
|
||||
|
||||
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE {table_name} (
|
||||
local_id INTEGER,
|
||||
server_id INTEGER,
|
||||
local_type INTEGER,
|
||||
sort_seq INTEGER,
|
||||
real_sender_id INTEGER,
|
||||
create_time INTEGER,
|
||||
message_content TEXT,
|
||||
compress_content BLOB
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
image_xml = '<msg><img md5="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" cdnthumburl="img_file_id_1" /></msg>'
|
||||
video_xml = '<msg><videomsg md5="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" cdnthumbmd5="cccccccccccccccccccccccccccccccc" cdnvideourl="video_file_id_1" cdnthumburl="video_thumb_id_1" /></msg>'
|
||||
|
||||
rows = [
|
||||
(1, 1001, 3, 1, 2, 1735689601, image_xml, None),
|
||||
(2, 1002, 43, 2, 2, 1735689602, video_xml, None),
|
||||
(3, 1003, 49, 3, 2, 1735689603, '<msg><appmsg><type>2000</type><des>收到转账0.01元</des></appmsg></msg>', None),
|
||||
(4, 1004, 1, 4, 2, 1735689604, '普通文本消息', None),
|
||||
(5, 1005, 10000, 5, 2, 1735689605, '系统提示消息', None),
|
||||
(
|
||||
6,
|
||||
1006,
|
||||
10000,
|
||||
6,
|
||||
2,
|
||||
1735689606,
|
||||
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA[“测试好友”撤回了一条消息]]></replacemsg></revokemsg></sysmsg>',
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_media_files(self, account_dir: Path) -> None:
|
||||
resource_root = account_dir / "resource"
|
||||
(resource_root / "aa").mkdir(parents=True, exist_ok=True)
|
||||
(resource_root / "bb").mkdir(parents=True, exist_ok=True)
|
||||
(resource_root / "cc").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(resource_root / "aa" / "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg").write_bytes(b"\xff\xd8\xff\xd9")
|
||||
(resource_root / "bb" / "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp4").write_bytes(b"video-bytes")
|
||||
(resource_root / "cc" / "cccccccccccccccccccccccccccccccc.jpg").write_bytes(b"\xff\xd8\xff\xd9")
|
||||
|
||||
def _seed_source_info(self, account_dir: Path, wxid_dir: Path) -> None:
|
||||
payload = {
|
||||
"wxid_dir": str(wxid_dir),
|
||||
"db_storage_path": str(wxid_dir / "db_storage"),
|
||||
}
|
||||
(account_dir / "_source.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
def _seed_wxid_media_files(self, wxid_dir: Path) -> None:
|
||||
(wxid_dir / "msg" / "video").mkdir(parents=True, exist_ok=True)
|
||||
(wxid_dir / "msg" / "attach").mkdir(parents=True, exist_ok=True)
|
||||
(wxid_dir / "cache").mkdir(parents=True, exist_ok=True)
|
||||
(wxid_dir / "db_storage").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(wxid_dir / "msg" / "video" / "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp4").write_bytes(b"video-bytes")
|
||||
(wxid_dir / "msg" / "video" / "cccccccccccccccccccccccccccccccc.jpg").write_bytes(b"\xff\xd8\xff\xd9")
|
||||
|
||||
def _prepare_account(self, root: Path, *, account: str, username: str) -> Path:
|
||||
account_dir = root / "output" / "databases" / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
wxid_dir = root / "wxid_data" / account
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
|
||||
self._seed_session_db(account_dir / "session.db", username=username)
|
||||
self._seed_message_db(account_dir / "message_0.db", account=account, username=username)
|
||||
self._seed_media_files(account_dir)
|
||||
self._seed_wxid_media_files(wxid_dir)
|
||||
self._seed_source_info(account_dir, wxid_dir)
|
||||
return account_dir
|
||||
|
||||
def _create_job(self, manager, *, account: str, username: str, message_types, include_media=True, media_kinds=None, privacy_mode=False):
|
||||
if media_kinds is None:
|
||||
media_kinds = ["image", "emoji", "video", "video_thumb", "voice", "file"]
|
||||
|
||||
job = manager.create_job(
|
||||
account=account,
|
||||
scope="selected",
|
||||
usernames=[username],
|
||||
export_format="json",
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
include_hidden=False,
|
||||
include_official=False,
|
||||
include_media=include_media,
|
||||
media_kinds=media_kinds,
|
||||
message_types=message_types,
|
||||
output_dir=None,
|
||||
allow_process_key_extract=False,
|
||||
privacy_mode=privacy_mode,
|
||||
file_name=None,
|
||||
)
|
||||
|
||||
for _ in range(200):
|
||||
latest = manager.get_job(job.export_id)
|
||||
if latest and latest.status in {"done", "error", "cancelled"}:
|
||||
return latest
|
||||
import time as _time
|
||||
|
||||
_time.sleep(0.05)
|
||||
self.fail("export job did not finish in time")
|
||||
|
||||
def _load_export_payload(self, zip_path: Path):
|
||||
self.assertTrue(zip_path.exists())
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
msg_path = next((n for n in names if n.endswith("/messages.json")), "")
|
||||
self.assertTrue(msg_path)
|
||||
import json as _json
|
||||
|
||||
payload = _json.loads(zf.read(msg_path).decode("utf-8"))
|
||||
manifest = _json.loads(zf.read("manifest.json").decode("utf-8"))
|
||||
return payload, manifest, names
|
||||
|
||||
def test_unchecked_image_is_filtered_out(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=["text", "transfer"],
|
||||
include_media=True,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, _, names = self._load_export_payload(job.zip_path)
|
||||
image_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 3), None)
|
||||
self.assertIsNone(image_msg)
|
||||
render_types = {str(m.get("renderType") or "") for m in payload.get("messages", [])}
|
||||
self.assertTrue(render_types.issubset({"text", "transfer"}))
|
||||
self.assertFalse(any(n.startswith("media/images/") for n in names))
|
||||
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_checked_image_exports_media_file(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=["image", "text"],
|
||||
include_media=True,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, _, names = self._load_export_payload(job.zip_path)
|
||||
image_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 3), None)
|
||||
self.assertIsNotNone(image_msg)
|
||||
self.assertEqual(str(image_msg.get("renderType") or ""), "image")
|
||||
self.assertTrue(isinstance(image_msg.get("offlineMedia"), list) and image_msg.get("offlineMedia"))
|
||||
self.assertTrue(any(n.startswith("media/images/") for n in names))
|
||||
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_unchecked_non_media_type_is_filtered_out(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=["text"],
|
||||
include_media=True,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, manifest, _ = self._load_export_payload(job.zip_path)
|
||||
system_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 10000), None)
|
||||
self.assertIsNone(system_msg)
|
||||
self.assertTrue(all(str(m.get("renderType") or "") == "text" for m in payload.get("messages", [])))
|
||||
self.assertEqual(manifest.get("filters", {}).get("messageTypes"), ["text"])
|
||||
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_checked_video_exports_video_and_thumb(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=["video", "text"],
|
||||
include_media=True,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, _, names = self._load_export_payload(job.zip_path)
|
||||
video_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 43), None)
|
||||
self.assertIsNotNone(video_msg)
|
||||
self.assertEqual(str(video_msg.get("renderType") or ""), "video")
|
||||
image_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 3), None)
|
||||
self.assertIsNone(image_msg)
|
||||
media_items = video_msg.get("offlineMedia") or []
|
||||
kinds = sorted(str(x.get("kind") or "") for x in media_items)
|
||||
self.assertIn("video", kinds)
|
||||
self.assertIn("video_thumb", kinds)
|
||||
self.assertTrue(any(n.startswith("media/videos/") for n in names))
|
||||
self.assertTrue(any(n.startswith("media/video_thumbs/") for n in names))
|
||||
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)
|
||||
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=["image", "video", "text"],
|
||||
include_media=True,
|
||||
privacy_mode=True,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, manifest, names = self._load_export_payload(job.zip_path)
|
||||
self.assertFalse(any(n.startswith("media/images/") for n in names))
|
||||
self.assertFalse(any(n.startswith("media/videos/") for n in names))
|
||||
self.assertFalse(any(n.startswith("media/video_thumbs/") for n in names))
|
||||
|
||||
for msg in payload.get("messages", []):
|
||||
self.assertFalse(msg.get("offlineMedia"))
|
||||
|
||||
self.assertFalse(bool(manifest.get("options", {}).get("includeMedia")))
|
||||
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_transfer_only_exports_transfer_messages(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=["transfer"],
|
||||
include_media=True,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, manifest, _ = self._load_export_payload(job.zip_path)
|
||||
messages = list(payload.get("messages", []))
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertTrue(all(str(m.get("renderType") or "") == "transfer" for m in messages))
|
||||
self.assertEqual(manifest.get("filters", {}).get("messageTypes"), ["transfer"])
|
||||
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_system_revoke_exports_readable_revoker_content(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=["system"],
|
||||
include_media=False,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, _, _ = self._load_export_payload(job.zip_path)
|
||||
revoke_msg = next((m for m in payload.get("messages", []) if int(m.get("serverId") or 0) == 1006), None)
|
||||
self.assertIsNotNone(revoke_msg)
|
||||
self.assertEqual(str(revoke_msg.get("renderType") or ""), "system")
|
||||
self.assertEqual(str(revoke_msg.get("content") or ""), "“测试好友”撤回了一条消息")
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,111 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.routers import chat as chat_router
|
||||
|
||||
|
||||
class _DummyRequest:
|
||||
base_url = "http://testserver/"
|
||||
|
||||
|
||||
class _DummyConn:
|
||||
def __init__(self) -> None:
|
||||
self.handle = 1
|
||||
self.lock = threading.Lock()
|
||||
|
||||
|
||||
def _seed_session_db(session_db_path: Path) -> None:
|
||||
conn = sqlite3.connect(str(session_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT PRIMARY KEY,
|
||||
unread_count INTEGER DEFAULT 0,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
summary TEXT DEFAULT '',
|
||||
draft TEXT DEFAULT '',
|
||||
last_timestamp INTEGER DEFAULT 0,
|
||||
sort_timestamp INTEGER DEFAULT 0,
|
||||
last_msg_locald_id INTEGER DEFAULT 0,
|
||||
last_msg_type INTEGER DEFAULT 0,
|
||||
last_msg_sub_type INTEGER DEFAULT 0,
|
||||
last_msg_sender TEXT DEFAULT '',
|
||||
last_sender_display_name TEXT DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestChatRealtimeSyncAllUpdatesSenderDisplayName(unittest.TestCase):
|
||||
def test_sync_all_upserts_last_sender_display_name(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
_seed_session_db(account_dir / "session.db")
|
||||
|
||||
conn = _DummyConn()
|
||||
sessions_rows = [
|
||||
{
|
||||
"username": "demo@chatroom",
|
||||
"unread_count": 0,
|
||||
"is_hidden": 0,
|
||||
"summary": "hello",
|
||||
"draft": "",
|
||||
"last_timestamp": 123,
|
||||
"sort_timestamp": 123,
|
||||
"last_msg_type": 1,
|
||||
"last_msg_sub_type": 0,
|
||||
"last_msg_sender": "wxid_demo",
|
||||
"last_sender_display_name": "群名片A",
|
||||
"last_msg_locald_id": 777,
|
||||
}
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
|
||||
patch.object(chat_router.WCDB_REALTIME, "ensure_connected", return_value=conn),
|
||||
patch.object(chat_router, "_wcdb_get_sessions", return_value=sessions_rows),
|
||||
patch.object(chat_router, "_ensure_decrypted_message_tables", return_value={}),
|
||||
patch.object(chat_router, "_should_keep_session", return_value=True),
|
||||
):
|
||||
resp = chat_router.sync_chat_realtime_messages_all(
|
||||
_DummyRequest(),
|
||||
account="acc",
|
||||
max_scan=20,
|
||||
include_hidden=True,
|
||||
include_official=True,
|
||||
)
|
||||
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
|
||||
db = sqlite3.connect(str(account_dir / "session.db"))
|
||||
try:
|
||||
row = db.execute(
|
||||
"SELECT last_sender_display_name, last_msg_sender, last_msg_locald_id FROM SessionTable WHERE username = ? LIMIT 1",
|
||||
("demo@chatroom",),
|
||||
).fetchone()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(str(row[0] or ""), "群名片A")
|
||||
self.assertEqual(str(row[1] or ""), "wxid_demo")
|
||||
self.assertEqual(int(row[2] or 0), 777)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
from wechat_decrypt_tool.routers import chat as chat_router
|
||||
|
||||
|
||||
class _DummyRequest:
|
||||
base_url = "http://testserver/"
|
||||
|
||||
|
||||
class _DummyConn:
|
||||
def __init__(self) -> None:
|
||||
self.handle = 1
|
||||
self.lock = threading.Lock()
|
||||
|
||||
|
||||
class TestChatRealtimeVideoThumbMd5FromPackedInfo(unittest.TestCase):
|
||||
def test_video_thumb_md5_filled_from_packed_info(self):
|
||||
packed_md5 = "faff984641f9dd174e01c74f0796c9ae"
|
||||
file_id = "3057020100044b3049020100020445eb9d5102032f54690204749999db0204698c336b0424deadbeef"
|
||||
video_md5 = "22e6612411898b6d43b7e773e504d506"
|
||||
xml = (
|
||||
'<?xml version="1.0"?>\n'
|
||||
"<msg>\n"
|
||||
f' <videomsg fromusername="wxid_sender" md5="{video_md5}" cdnthumburl="{file_id}" cdnvideourl="{file_id}" />\n'
|
||||
"</msg>\n"
|
||||
)
|
||||
|
||||
wcdb_rows = [
|
||||
{
|
||||
"localId": 1,
|
||||
"serverId": 123,
|
||||
"localType": 43,
|
||||
"sortSeq": 1700000000000,
|
||||
"realSenderId": 1,
|
||||
"createTime": 1700000000,
|
||||
"messageContent": xml,
|
||||
"compressContent": None,
|
||||
"packedInfoData": packed_md5.encode("ascii"),
|
||||
"senderUsername": "wxid_sender",
|
||||
}
|
||||
]
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
conn = _DummyConn()
|
||||
|
||||
with (
|
||||
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
|
||||
patch.object(chat_router.WCDB_REALTIME, "ensure_connected", return_value=conn),
|
||||
patch.object(chat_router, "_wcdb_get_messages", return_value=wcdb_rows),
|
||||
patch.object(chat_router, "_load_contact_rows", return_value={}),
|
||||
patch.object(chat_router, "_query_head_image_usernames", return_value=set()),
|
||||
patch.object(chat_router, "_wcdb_get_display_names", return_value={}),
|
||||
patch.object(chat_router, "_wcdb_get_avatar_urls", return_value={}),
|
||||
patch.object(chat_router, "_load_usernames_by_display_names", return_value={}),
|
||||
patch.object(chat_router, "_load_group_nickname_map", return_value={}),
|
||||
):
|
||||
resp = chat_router.list_chat_messages(
|
||||
_DummyRequest(),
|
||||
username="demo@chatroom",
|
||||
account="acc",
|
||||
limit=50,
|
||||
offset=0,
|
||||
order="asc",
|
||||
render_types=None,
|
||||
source="realtime",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
messages = resp.get("messages") or []
|
||||
self.assertEqual(len(messages), 1)
|
||||
msg = messages[0]
|
||||
self.assertEqual(msg.get("renderType"), "video")
|
||||
self.assertEqual(msg.get("videoThumbMd5"), packed_md5)
|
||||
thumb_url = str(msg.get("videoThumbUrl") or "")
|
||||
self.assertIn(f"md5={packed_md5}", thumb_url)
|
||||
self.assertNotIn("file_id=", thumb_url)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.chat_helpers import (
|
||||
_build_group_sender_display_name_map,
|
||||
_normalize_session_preview_text,
|
||||
_replace_preview_sender_prefix,
|
||||
)
|
||||
|
||||
|
||||
class TestChatSessionPreviewFormatting(unittest.TestCase):
|
||||
def test_normalize_session_preview_emoji_label(self):
|
||||
out = _normalize_session_preview_text("[表情]", is_group=False, sender_display_names={})
|
||||
self.assertEqual(out, "[动画表情]")
|
||||
|
||||
def test_normalize_group_preview_sender_display_name(self):
|
||||
out = _normalize_session_preview_text(
|
||||
"wxid_u3gwceqvne2m22: [表情]",
|
||||
is_group=True,
|
||||
sender_display_names={"wxid_u3gwceqvne2m22": "食神"},
|
||||
)
|
||||
self.assertEqual(out, "食神: [动画表情]")
|
||||
|
||||
def test_build_group_sender_display_name_map_from_contact_db(self):
|
||||
with TemporaryDirectory() as td:
|
||||
contact_db_path = Path(td) / "contact.db"
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("wxid_u3gwceqvne2m22", "", "食神", "", "", ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
mapping = _build_group_sender_display_name_map(
|
||||
contact_db_path,
|
||||
{"demo@chatroom": "wxid_u3gwceqvne2m22: [动画表情]"},
|
||||
)
|
||||
self.assertEqual(mapping.get("wxid_u3gwceqvne2m22"), "食神")
|
||||
|
||||
def test_replace_preview_sender_prefix_uses_group_nickname(self):
|
||||
out = _replace_preview_sender_prefix("去码头整点🍟: [动画表情]", "麻辣香锅")
|
||||
self.assertEqual(out, "麻辣香锅: [动画表情]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,211 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
from wechat_decrypt_tool.routers import chat as chat_router
|
||||
|
||||
|
||||
class _DummyRequest:
|
||||
base_url = "http://testserver/"
|
||||
|
||||
|
||||
def _seed_session_db(path: Path, rows: list[tuple[str, int, int, str]]) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable(
|
||||
username TEXT PRIMARY KEY,
|
||||
unread_count INTEGER,
|
||||
is_hidden INTEGER,
|
||||
summary TEXT,
|
||||
draft TEXT,
|
||||
last_timestamp INTEGER,
|
||||
sort_timestamp INTEGER,
|
||||
last_msg_type INTEGER,
|
||||
last_msg_sub_type INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
for username, sort_timestamp, last_timestamp, summary in rows:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO SessionTable(
|
||||
username, unread_count, is_hidden, summary, draft,
|
||||
last_timestamp, sort_timestamp, last_msg_type, last_msg_sub_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
username,
|
||||
0,
|
||||
0,
|
||||
summary,
|
||||
"",
|
||||
int(last_timestamp),
|
||||
int(sort_timestamp),
|
||||
1,
|
||||
0,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_contact_db_with_flag(path: Path, flags: dict[str, int]) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact(
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT,
|
||||
flag INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger(
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT,
|
||||
flag INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
for username, flag in flags.items():
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(username, "", "", "", "", "", int(flag)),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_contact_db_without_flag(path: Path, usernames: list[str]) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact(
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger(
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
for username in usernames:
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(username, "", "", "", "", ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestChatSessionsPinning(unittest.TestCase):
|
||||
def test_pinned_session_is_sorted_first_and_has_is_top(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_seed_session_db(
|
||||
account_dir / "session.db",
|
||||
[
|
||||
("wxid_new", 200, 200, "new message"),
|
||||
("wxid_top", 100, 100, "top older message"),
|
||||
],
|
||||
)
|
||||
_seed_contact_db_with_flag(
|
||||
account_dir / "contact.db",
|
||||
{
|
||||
"wxid_new": 0,
|
||||
"wxid_top": 1 << 11,
|
||||
},
|
||||
)
|
||||
|
||||
with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir):
|
||||
resp = chat_router.list_chat_sessions(
|
||||
_DummyRequest(),
|
||||
account="acc",
|
||||
limit=50,
|
||||
include_hidden=True,
|
||||
include_official=True,
|
||||
preview="session",
|
||||
source="",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
sessions = resp.get("sessions") or []
|
||||
self.assertEqual(len(sessions), 2)
|
||||
self.assertEqual(sessions[0].get("username"), "wxid_top")
|
||||
self.assertTrue(bool(sessions[0].get("isTop")))
|
||||
self.assertEqual(sessions[1].get("username"), "wxid_new")
|
||||
self.assertFalse(bool(sessions[1].get("isTop")))
|
||||
|
||||
def test_missing_flag_column_does_not_error_and_defaults_false(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_seed_session_db(
|
||||
account_dir / "session.db",
|
||||
[
|
||||
("wxid_top", 100, 100, "hello"),
|
||||
],
|
||||
)
|
||||
_seed_contact_db_without_flag(account_dir / "contact.db", ["wxid_top"])
|
||||
|
||||
with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir):
|
||||
resp = chat_router.list_chat_sessions(
|
||||
_DummyRequest(),
|
||||
account="acc",
|
||||
limit=50,
|
||||
include_hidden=True,
|
||||
include_official=True,
|
||||
preview="session",
|
||||
source="",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
sessions = resp.get("sessions") or []
|
||||
self.assertEqual(len(sessions), 1)
|
||||
self.assertFalse(bool(sessions[0].get("isTop")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
from wechat_decrypt_tool.routers import chat as chat_router
|
||||
|
||||
|
||||
class _DummyRequest:
|
||||
base_url = "http://testserver/"
|
||||
|
||||
|
||||
class _DummyConn:
|
||||
def __init__(self) -> None:
|
||||
self.handle = 1
|
||||
self.lock = threading.Lock()
|
||||
|
||||
|
||||
class TestChatSessionsRealtimeSenderPreview(unittest.TestCase):
|
||||
def _run(self, sessions_rows: list[dict]) -> dict:
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
conn = _DummyConn()
|
||||
with (
|
||||
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
|
||||
patch.object(chat_router.WCDB_REALTIME, "ensure_connected", return_value=conn),
|
||||
patch.object(chat_router, "_wcdb_get_sessions", return_value=sessions_rows),
|
||||
patch.object(chat_router, "_wcdb_get_display_names", return_value={}),
|
||||
patch.object(chat_router, "_wcdb_get_avatar_urls", return_value={}),
|
||||
patch.object(chat_router, "_load_contact_rows", return_value={}),
|
||||
patch.object(chat_router, "_query_head_image_usernames", return_value=set()),
|
||||
patch.object(chat_router, "_should_keep_session", return_value=True),
|
||||
patch.object(chat_router, "_avatar_url_unified", return_value="/avatar"),
|
||||
):
|
||||
return chat_router.list_chat_sessions(
|
||||
_DummyRequest(),
|
||||
account="acc",
|
||||
limit=50,
|
||||
include_hidden=True,
|
||||
include_official=True,
|
||||
preview="latest",
|
||||
source="realtime",
|
||||
)
|
||||
|
||||
def test_realtime_sessions_group_summary_prefixed_by_sender_display_name(self):
|
||||
resp = self._run(
|
||||
[
|
||||
{
|
||||
"username": "demo@chatroom",
|
||||
"summary": "hello",
|
||||
"draft": "",
|
||||
"unread_count": 0,
|
||||
"is_hidden": 0,
|
||||
"last_timestamp": 123,
|
||||
"sort_timestamp": 123,
|
||||
"last_msg_type": 1,
|
||||
"last_msg_sub_type": 0,
|
||||
"last_msg_sender": "wxid_demo",
|
||||
"last_sender_display_name": "群名片A",
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
sessions = resp.get("sessions") or []
|
||||
self.assertEqual(len(sessions), 1)
|
||||
self.assertEqual(sessions[0].get("lastMessage"), "群名片A: hello")
|
||||
|
||||
def test_realtime_sessions_group_url_summary_keeps_scheme(self):
|
||||
resp = self._run(
|
||||
[
|
||||
{
|
||||
"username": "url@chatroom",
|
||||
"summary": "https://example.com/x",
|
||||
"draft": "",
|
||||
"unread_count": 0,
|
||||
"is_hidden": 0,
|
||||
"last_timestamp": 123,
|
||||
"sort_timestamp": 123,
|
||||
"last_msg_type": 1,
|
||||
"last_msg_sub_type": 0,
|
||||
"last_msg_sender": "wxid_demo",
|
||||
"last_sender_display_name": "群名片B",
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
sessions = resp.get("sessions") or []
|
||||
self.assertEqual(len(sessions), 1)
|
||||
self.assertEqual(sessions[0].get("lastMessage"), "群名片B: https://example.com/x")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.chat_helpers import _parse_system_message_content
|
||||
|
||||
|
||||
class TestChatSystemMessageParsing(unittest.TestCase):
|
||||
def test_extract_replacemsg_for_revoke(self):
|
||||
raw_text = (
|
||||
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA[“张三”撤回了一条消息]]>'
|
||||
"</replacemsg></revokemsg></sysmsg>"
|
||||
)
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "“张三”撤回了一条消息")
|
||||
|
||||
def test_extract_nested_content_in_replacemsg(self):
|
||||
raw_text = (
|
||||
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA['
|
||||
'<content>"黄智欢" 撤回了一条消息</content><revoketime>0</revoketime>'
|
||||
']]></replacemsg></revokemsg></sysmsg>'
|
||||
)
|
||||
self.assertEqual(_parse_system_message_content(raw_text), '"黄智欢" 撤回了一条消息')
|
||||
|
||||
def test_extract_revokemsg_text_when_replacemsg_missing(self):
|
||||
raw_text = "<revokemsg>你撤回了一条消息</revokemsg>"
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "你撤回了一条消息")
|
||||
|
||||
def test_revoke_fallback_when_no_readable_text(self):
|
||||
raw_text = '<sysmsg type="revokemsg"></sysmsg>'
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "撤回了一条消息")
|
||||
|
||||
def test_normal_system_message_still_cleaned(self):
|
||||
raw_text = "<sysmsg><template><![CDATA[ 张三 加入了群聊 ]]></template></sysmsg>"
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "张三 加入了群聊")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestContactTypeDetection(unittest.TestCase):
|
||||
def test_infer_group(self):
|
||||
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
|
||||
|
||||
row = {"local_type": 0, "alias": "", "remark": "", "nick_name": ""}
|
||||
self.assertEqual(_infer_contact_type("123@chatroom", row), "group")
|
||||
|
||||
def test_infer_official_by_prefix(self):
|
||||
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
|
||||
|
||||
row = {"local_type": 0, "verify_flag": 0, "alias": "", "remark": "", "nick_name": ""}
|
||||
self.assertEqual(_infer_contact_type("gh_xxx", row), "official")
|
||||
|
||||
def test_infer_official_by_verify_flag(self):
|
||||
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
|
||||
|
||||
row = {"local_type": 1, "verify_flag": 24, "alias": "", "remark": "", "nick_name": ""}
|
||||
self.assertEqual(_infer_contact_type("wxid_xxx", row), "official")
|
||||
|
||||
def test_infer_none_for_local_type_3_without_verify(self):
|
||||
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
|
||||
|
||||
row = {"local_type": 3, "verify_flag": 0, "alias": "", "remark": "", "nick_name": "普通联系人"}
|
||||
self.assertIsNone(_infer_contact_type("wxid_xxx", row))
|
||||
|
||||
def test_infer_none_from_wxid_alias_when_local_type_not_1(self):
|
||||
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
|
||||
|
||||
row = {"local_type": 0, "verify_flag": 0, "alias": "wechat_id", "remark": "", "nick_name": ""}
|
||||
self.assertIsNone(_infer_contact_type("wxid_xxx", row))
|
||||
|
||||
def test_infer_friend_from_local_type_1(self):
|
||||
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
|
||||
|
||||
row = {"local_type": 1, "verify_flag": 0, "alias": "", "remark": "", "nick_name": ""}
|
||||
self.assertEqual(_infer_contact_type("wxid_xxx", row), "friend")
|
||||
|
||||
def test_infer_none_from_local_type_2(self):
|
||||
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
|
||||
|
||||
row = {"local_type": 2, "verify_flag": 0, "alias": "", "remark": "", "nick_name": ""}
|
||||
self.assertIsNone(_infer_contact_type("wxid_xxx", row))
|
||||
|
||||
def test_infer_none_when_empty_type_0(self):
|
||||
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
|
||||
|
||||
row = {"local_type": 0, "verify_flag": 0, "alias": "", "remark": "", "nick_name": ""}
|
||||
self.assertIsNone(_infer_contact_type("wxid_xxx", row))
|
||||
|
||||
def test_valid_contact_username_filters_system_accounts(self):
|
||||
from wechat_decrypt_tool.routers.chat_contacts import _is_valid_contact_username
|
||||
|
||||
self.assertFalse(_is_valid_contact_username("filehelper"))
|
||||
self.assertFalse(_is_valid_contact_username("notifymessage"))
|
||||
self.assertFalse(_is_valid_contact_username("fake_abc"))
|
||||
self.assertTrue(_is_valid_contact_username("weixin"))
|
||||
self.assertTrue(_is_valid_contact_username("wxid_abc"))
|
||||
self.assertTrue(_is_valid_contact_username("123@chatroom"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,546 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestContactsExport(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _encode_varint(value: int) -> bytes:
|
||||
v = int(value)
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = v & 0x7F
|
||||
v >>= 7
|
||||
if v:
|
||||
out.append(b | 0x80)
|
||||
else:
|
||||
out.append(b)
|
||||
break
|
||||
return bytes(out)
|
||||
|
||||
@classmethod
|
||||
def _encode_field_len(cls, field_no: int, raw: bytes) -> bytes:
|
||||
tag = (int(field_no) << 3) | 2
|
||||
payload = bytes(raw)
|
||||
return cls._encode_varint(tag) + cls._encode_varint(len(payload)) + payload
|
||||
|
||||
@classmethod
|
||||
def _encode_field_varint(cls, field_no: int, value: int) -> bytes:
|
||||
tag = int(field_no) << 3
|
||||
return cls._encode_varint(tag) + cls._encode_varint(int(value))
|
||||
|
||||
@classmethod
|
||||
def _build_extra_buffer(cls, *, country: str, province: str, city: str, source_scene: int) -> bytes:
|
||||
return b"".join(
|
||||
[
|
||||
cls._encode_field_len(5, country.encode("utf-8")),
|
||||
cls._encode_field_len(6, province.encode("utf-8")),
|
||||
cls._encode_field_len(7, city.encode("utf-8")),
|
||||
cls._encode_field_varint(8, source_scene),
|
||||
]
|
||||
)
|
||||
|
||||
def _seed_contact_db(self, path: Path) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT,
|
||||
extra_buffer BLOB
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT,
|
||||
extra_buffer BLOB
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
friend_extra_buffer = self._build_extra_buffer(
|
||||
country="CN",
|
||||
province="Sichuan",
|
||||
city="Chengdu",
|
||||
source_scene=14,
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"wxid_friend",
|
||||
"好友备注",
|
||||
"好友昵称",
|
||||
"friend_alias",
|
||||
1,
|
||||
0,
|
||||
"https://cdn.example.com/friend_big.jpg",
|
||||
"https://cdn.example.com/friend_small.jpg",
|
||||
friend_extra_buffer,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"room@chatroom",
|
||||
"",
|
||||
"测试群",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
"https://cdn.example.com/group_big.jpg",
|
||||
"",
|
||||
b"",
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"gh_official",
|
||||
"",
|
||||
"公众号",
|
||||
"",
|
||||
4,
|
||||
8,
|
||||
"",
|
||||
"https://cdn.example.com/official_small.jpg",
|
||||
b"",
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"wxid_local_type_3",
|
||||
"",
|
||||
"不应计入联系人",
|
||||
"",
|
||||
3,
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
b"",
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"weixin",
|
||||
"",
|
||||
"微信团队",
|
||||
"",
|
||||
1,
|
||||
56,
|
||||
"",
|
||||
"",
|
||||
b"",
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"filehelper",
|
||||
"",
|
||||
"文件传输助手",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
b"",
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO stranger VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"stranger_verified",
|
||||
"",
|
||||
"陌生人认证号",
|
||||
"",
|
||||
4,
|
||||
24,
|
||||
"",
|
||||
"",
|
||||
b"",
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_session_db(self, path: Path) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT,
|
||||
sort_timestamp INTEGER,
|
||||
last_timestamp INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("room@chatroom", 300, 300))
|
||||
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("wxid_friend", 200, 200))
|
||||
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("gh_official", 100, 100))
|
||||
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("missing@chatroom", 250, 250))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_contact_db_legacy(self, path: Path) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"wxid_legacy_friend",
|
||||
"旧版好友备注",
|
||||
"旧版好友昵称",
|
||||
"legacy_friend_alias",
|
||||
1,
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_export_json_and_csv(self):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
account_dir = root / "output" / "databases" / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db")
|
||||
self._seed_session_db(account_dir / "session.db")
|
||||
|
||||
prev = None
|
||||
try:
|
||||
prev = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
|
||||
import wechat_decrypt_tool.chat_helpers as chat_helpers
|
||||
import wechat_decrypt_tool.routers.chat_contacts as chat_contacts
|
||||
|
||||
importlib.reload(chat_helpers)
|
||||
importlib.reload(chat_contacts)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(chat_contacts.router)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
list_resp = client.get(
|
||||
"/api/chat/contacts",
|
||||
params={
|
||||
"account": account,
|
||||
"include_friends": True,
|
||||
"include_groups": True,
|
||||
"include_officials": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(list_resp.status_code, 200)
|
||||
list_payload = list_resp.json()
|
||||
self.assertEqual(list_payload["status"], "success")
|
||||
self.assertEqual(list_payload["total"], 6)
|
||||
self.assertEqual(list_payload["counts"]["friends"], 1)
|
||||
self.assertEqual(list_payload["counts"]["groups"], 2)
|
||||
self.assertEqual(list_payload["counts"]["officials"], 3)
|
||||
usernames = {str(x.get("username")) for x in list_payload.get("contacts", [])}
|
||||
self.assertIn("missing@chatroom", usernames)
|
||||
self.assertIn("weixin", usernames)
|
||||
self.assertNotIn("wxid_local_type_3", usernames)
|
||||
first = list_payload["contacts"][0]
|
||||
self.assertIn("avatarLink", first)
|
||||
|
||||
friend_contact = next(
|
||||
(x for x in list_payload.get("contacts", []) if str(x.get("username")) == "wxid_friend"),
|
||||
{},
|
||||
)
|
||||
self.assertEqual(friend_contact.get("country"), "CN")
|
||||
self.assertEqual(friend_contact.get("province"), "Sichuan")
|
||||
self.assertEqual(friend_contact.get("city"), "Chengdu")
|
||||
self.assertEqual(friend_contact.get("region"), "中国大陆·Sichuan·Chengdu")
|
||||
self.assertEqual(friend_contact.get("sourceScene"), 14)
|
||||
self.assertEqual(friend_contact.get("source"), "通过群聊添加")
|
||||
|
||||
export_dir = root / "exports"
|
||||
export_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
json_resp = client.post(
|
||||
"/api/chat/contacts/export",
|
||||
json={
|
||||
"account": account,
|
||||
"output_dir": str(export_dir),
|
||||
"format": "json",
|
||||
"include_avatar_link": True,
|
||||
"contact_types": {
|
||||
"friends": True,
|
||||
"groups": True,
|
||||
"officials": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(json_resp.status_code, 200)
|
||||
json_payload = json_resp.json()
|
||||
self.assertEqual(json_payload["status"], "success")
|
||||
self.assertEqual(json_payload["count"], 6)
|
||||
json_path = Path(json_payload["outputPath"])
|
||||
self.assertTrue(json_path.exists())
|
||||
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(data["count"], 6)
|
||||
self.assertIn("avatarLink", data["contacts"][0])
|
||||
self.assertIn("region", data["contacts"][0])
|
||||
self.assertIn("country", data["contacts"][0])
|
||||
self.assertIn("province", data["contacts"][0])
|
||||
self.assertIn("city", data["contacts"][0])
|
||||
self.assertIn("source", data["contacts"][0])
|
||||
self.assertIn("sourceScene", data["contacts"][0])
|
||||
export_usernames = {str(x.get("username")) for x in data.get("contacts", [])}
|
||||
self.assertIn("missing@chatroom", export_usernames)
|
||||
self.assertNotIn("wxid_local_type_3", export_usernames)
|
||||
|
||||
friend_export = next(
|
||||
(x for x in data.get("contacts", []) if str(x.get("username")) == "wxid_friend"),
|
||||
{},
|
||||
)
|
||||
self.assertEqual(friend_export.get("region"), "中国大陆·Sichuan·Chengdu")
|
||||
self.assertEqual(friend_export.get("sourceScene"), 14)
|
||||
self.assertEqual(friend_export.get("source"), "通过群聊添加")
|
||||
|
||||
csv_resp = client.post(
|
||||
"/api/chat/contacts/export",
|
||||
json={
|
||||
"account": account,
|
||||
"output_dir": str(export_dir),
|
||||
"format": "csv",
|
||||
"include_avatar_link": False,
|
||||
"contact_types": {
|
||||
"friends": True,
|
||||
"groups": False,
|
||||
"officials": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(csv_resp.status_code, 200)
|
||||
csv_payload = csv_resp.json()
|
||||
self.assertEqual(csv_payload["count"], 1)
|
||||
csv_path = Path(csv_payload["outputPath"])
|
||||
text = csv_path.read_text(encoding="utf-8-sig")
|
||||
self.assertIn("用户名,显示名称,备注,昵称,微信号,类型,地区,国家/地区码,省份,城市,来源,来源场景码", text.splitlines()[0])
|
||||
self.assertNotIn("头像链接", text.splitlines()[0])
|
||||
self.assertIn("wxid_friend", text)
|
||||
self.assertIn("中国大陆·Sichuan·Chengdu", text)
|
||||
self.assertIn("通过群聊添加", text)
|
||||
self.assertIn(",14", text)
|
||||
self.assertNotIn("wxid_local_type_3", text)
|
||||
finally:
|
||||
if prev is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev
|
||||
|
||||
def test_export_invalid_format_returns_400(self):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
account_dir = root / "output" / "databases" / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db")
|
||||
self._seed_session_db(account_dir / "session.db")
|
||||
|
||||
prev = None
|
||||
try:
|
||||
prev = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
|
||||
import wechat_decrypt_tool.chat_helpers as chat_helpers
|
||||
import wechat_decrypt_tool.routers.chat_contacts as chat_contacts
|
||||
|
||||
importlib.reload(chat_helpers)
|
||||
importlib.reload(chat_contacts)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(chat_contacts.router)
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/chat/contacts/export",
|
||||
json={
|
||||
"account": account,
|
||||
"output_dir": str(root / "exports"),
|
||||
"format": "vcf",
|
||||
"include_avatar_link": True,
|
||||
"contact_types": {
|
||||
"friends": True,
|
||||
"groups": True,
|
||||
"officials": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
finally:
|
||||
if prev is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev
|
||||
|
||||
def test_missing_contact_db_returns_404(self):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
account_dir = root / "output" / "databases" / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# only session.db exists
|
||||
self._seed_session_db(account_dir / "session.db")
|
||||
|
||||
prev = None
|
||||
try:
|
||||
prev = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
|
||||
import wechat_decrypt_tool.chat_helpers as chat_helpers
|
||||
import wechat_decrypt_tool.routers.chat_contacts as chat_contacts
|
||||
|
||||
importlib.reload(chat_helpers)
|
||||
importlib.reload(chat_contacts)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(chat_contacts.router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/chat/contacts", params={"account": account})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
finally:
|
||||
if prev is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev
|
||||
|
||||
def test_legacy_schema_without_extra_buffer_is_compatible(self):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_legacy"
|
||||
account_dir = root / "output" / "databases" / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db_legacy(account_dir / "contact.db")
|
||||
self._seed_session_db(account_dir / "session.db")
|
||||
|
||||
prev = None
|
||||
try:
|
||||
prev = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
|
||||
import wechat_decrypt_tool.chat_helpers as chat_helpers
|
||||
import wechat_decrypt_tool.routers.chat_contacts as chat_contacts
|
||||
|
||||
importlib.reload(chat_helpers)
|
||||
importlib.reload(chat_contacts)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(chat_contacts.router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get(
|
||||
"/api/chat/contacts",
|
||||
params={
|
||||
"account": account,
|
||||
"include_friends": True,
|
||||
"include_groups": False,
|
||||
"include_officials": False,
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = resp.json()
|
||||
self.assertEqual(payload.get("status"), "success")
|
||||
self.assertEqual(int(payload.get("total", 0)), 1)
|
||||
|
||||
contact = payload.get("contacts", [])[0]
|
||||
self.assertEqual(contact.get("username"), "wxid_legacy_friend")
|
||||
self.assertEqual(contact.get("country"), "")
|
||||
self.assertEqual(contact.get("province"), "")
|
||||
self.assertEqual(contact.get("city"), "")
|
||||
self.assertEqual(contact.get("region"), "")
|
||||
self.assertIsNone(contact.get("sourceScene"))
|
||||
self.assertEqual(contact.get("source"), "")
|
||||
finally:
|
||||
if prev is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,114 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.chat_helpers import _load_group_nickname_map_from_contact_db
|
||||
|
||||
|
||||
def _enc_varint(n: int) -> bytes:
|
||||
v = int(n)
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = v & 0x7F
|
||||
v >>= 7
|
||||
if v:
|
||||
out.append(b | 0x80)
|
||||
else:
|
||||
out.append(b)
|
||||
break
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _enc_tag(field_no: int, wire_type: int) -> bytes:
|
||||
return _enc_varint((int(field_no) << 3) | int(wire_type))
|
||||
|
||||
|
||||
def _enc_len(field_no: int, data: bytes) -> bytes:
|
||||
b = bytes(data or b"")
|
||||
return _enc_tag(field_no, 2) + _enc_varint(len(b)) + b
|
||||
|
||||
|
||||
def _member_entry(*, inner: bytes) -> bytes:
|
||||
# contact.db ext_buffer uses repeated length-delimited submessages; the top-level field number is not important
|
||||
# for our best-effort parser, so we use field 1.
|
||||
return _enc_len(1, inner)
|
||||
|
||||
|
||||
class TestGroupNicknameExtBufferParsing(unittest.TestCase):
|
||||
def test_parse_pattern_a_field1_username_field2_display(self):
|
||||
chatroom = "demo@chatroom"
|
||||
username = "wxid_demo_123456"
|
||||
display = "群名片A"
|
||||
|
||||
inner = _enc_len(1, username.encode("utf-8")) + _enc_len(2, display.encode("utf-8"))
|
||||
ext_buffer = _member_entry(inner=inner)
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
contact_db_path = Path(td) / "contact.db"
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE chat_room(id INTEGER PRIMARY KEY, username TEXT, owner TEXT, ext_buffer BLOB)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO chat_room(id, username, owner, ext_buffer) VALUES (?, ?, ?, ?)",
|
||||
(1, chatroom, "", ext_buffer),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out = _load_group_nickname_map_from_contact_db(contact_db_path, chatroom, [username])
|
||||
self.assertEqual(out.get(username), display)
|
||||
|
||||
def test_parse_pattern_b_field4_username_field1_display(self):
|
||||
chatroom = "demo2@chatroom"
|
||||
username = "wxid_demo_abcdef"
|
||||
display = "hjlbingo"
|
||||
|
||||
inner = _enc_len(4, username.encode("utf-8")) + _enc_len(1, display.encode("utf-8"))
|
||||
ext_buffer = _member_entry(inner=inner)
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
contact_db_path = Path(td) / "contact.db"
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE chat_room(id INTEGER PRIMARY KEY, username TEXT, owner TEXT, ext_buffer BLOB)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO chat_room(id, username, owner, ext_buffer) VALUES (?, ?, ?, ?)",
|
||||
(1, chatroom, "", ext_buffer),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out = _load_group_nickname_map_from_contact_db(contact_db_path, chatroom, [username])
|
||||
self.assertEqual(out.get(username), display)
|
||||
|
||||
def test_non_chatroom_returns_empty(self):
|
||||
with TemporaryDirectory() as td:
|
||||
contact_db_path = Path(td) / "contact.db"
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE chat_room(id INTEGER PRIMARY KEY, username TEXT, owner TEXT, ext_buffer BLOB)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out = _load_group_nickname_map_from_contact_db(contact_db_path, "wxid_not_chatroom", ["wxid_xxx"])
|
||||
self.assertEqual(out, {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.chat_helpers import _extract_sender_from_group_xml
|
||||
|
||||
|
||||
class TestGroupXmlSenderExtraction(unittest.TestCase):
|
||||
def test_prefers_outer_fromusername_over_nested_refermsg(self):
|
||||
xml_text = (
|
||||
'<msg><appmsg><type>57</type>'
|
||||
'<refermsg><fromusername>quoted_user@chatroom</fromusername></refermsg>'
|
||||
'</appmsg><fromusername>actual_sender@chatroom</fromusername></msg>'
|
||||
)
|
||||
self.assertEqual(_extract_sender_from_group_xml(xml_text), "actual_sender@chatroom")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,115 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.chat_helpers import _parse_app_message
|
||||
|
||||
|
||||
class TestParseAppMessage(unittest.TestCase):
|
||||
def test_quote_type_57_nested_refermsg_uses_inner_title(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>一松一紧</title><des></des><action></action><type>57</type>'
|
||||
'<showtype>0</showtype><soundtype>0</soundtype><mediatagname></mediatagname>'
|
||||
'<messageext></messageext><messageaction></messageaction><content></content>'
|
||||
'<url></url><appattach><totallen>0</totallen><attachid></attachid><fileext></fileext></appattach>'
|
||||
'<extinfo></extinfo><sourceusername></sourceusername><sourcedisplayname></sourcedisplayname>'
|
||||
'<commenturl></commenturl><refermsg>'
|
||||
'<type>57</type><svrid>1173057991425172913</svrid>'
|
||||
'<fromusr>44372432598@chatroom</fromusr><chatusr>44372432598@chatroom</chatusr>'
|
||||
'<displayname><![CDATA[ㅤ磁父]]></displayname>'
|
||||
'<content><![CDATA[<msg><appmsg appid="" sdkver="0"><title>那里紧?哪里张?</title><des></des>'
|
||||
'<action></action><type>57</type><showtype>0</showtype><soundtype>0</soundtype>'
|
||||
'<mediatagname></mediatagname><messageext></messageext><messageaction></messageaction>'
|
||||
'<content></content><url></url><appattach><totallen>0</totallen><attachid></attachid>'
|
||||
'<fileext></fileext></appattach><extinfo></extinfo><sourceusername></sourceusername>'
|
||||
'<sourcedisplayname></sourcedisplayname><commenturl></commenturl></appmsg></msg>]]></content>'
|
||||
'</refermsg></appmsg></msg>'
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("renderType"), "quote")
|
||||
self.assertEqual(parsed.get("content"), "一松一紧")
|
||||
self.assertEqual(parsed.get("quoteType"), "57")
|
||||
self.assertEqual(parsed.get("quoteContent"), "那里紧?哪里张?")
|
||||
|
||||
def test_quote_type_57_plain_text_refermsg_keeps_text(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>回复</title><type>57</type>'
|
||||
'<refermsg><type>57</type><content><![CDATA[普通文本引用]]></content></refermsg>'
|
||||
'</appmsg></msg>'
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("renderType"), "quote")
|
||||
self.assertEqual(parsed.get("quoteContent"), "普通文本引用")
|
||||
|
||||
def test_quote_type_49_nested_xml_refermsg_uses_inner_title(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>这种傻逼公众号怎么还在看</title><type>57</type>'
|
||||
'<refermsg><type>49</type><displayname><![CDATA[水豚喧喧]]></displayname>'
|
||||
'<content><![CDATA[wxid_gryaI8aopjio22: <?xml version="1.0"?><msg><appmsg appid="" sdkver="0">'
|
||||
'<title>为自己的美丽漂亮善良知性发声😊</title><des></des>'
|
||||
'<type>5</type><url>https://mp.weixin.qq.com/s/example</url>'
|
||||
'<thumburl>https://mmbiz.qpic.cn/some-thumb.jpg</thumburl>'
|
||||
'</appmsg></msg>]]></content></refermsg></appmsg></msg>'
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("renderType"), "quote")
|
||||
self.assertEqual(parsed.get("quoteType"), "49")
|
||||
self.assertEqual(parsed.get("quoteTitle"), "水豚喧喧")
|
||||
self.assertEqual(parsed.get("quoteContent"), "[链接] 为自己的美丽漂亮善良知性发声😊")
|
||||
self.assertEqual(parsed.get("quoteThumbUrl"), "https://mmbiz.qpic.cn/some-thumb.jpg")
|
||||
|
||||
def test_public_account_link_exposes_link_type_and_style(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>为自己的美丽漂亮善良知性发声😊</title>'
|
||||
'<des>#日常穿搭灵感 #白色蕾丝裙穿搭 #知性美女</des>'
|
||||
'<type>5</type>'
|
||||
'<url>http://mp.weixin.qq.com/s?__biz=xx&mid=1</url>'
|
||||
'<thumburl>http://mmbiz.qpic.cn/abc/640?wx_fmt=jpeg</thumburl>'
|
||||
'<sourceusername>gh_0cef8eaa987d</sourceusername>'
|
||||
'<sourcedisplayname>草莓不甜芒果甜</sourcedisplayname>'
|
||||
'</appmsg></msg>'
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("renderType"), "link")
|
||||
self.assertEqual(parsed.get("linkType"), "official_article")
|
||||
self.assertEqual(parsed.get("linkStyle"), "cover")
|
||||
|
||||
def test_quote_type_5_nested_xml_refermsg_uses_inner_title(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>这个年龄有点大啊</title><type>57</type>'
|
||||
'<refermsg><type>5</type><displayname><![CDATA[水豚噜噜]]></displayname>'
|
||||
'<content><![CDATA[wxid_qrval8aopiio22:\n<?xml version="1.0"?>\n<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>谁说冬天不能穿裙子?</title><des></des><type>5</type>'
|
||||
'<thumburl>https://mmbiz.qpic.cn/some-thumb2.jpg</thumburl>'
|
||||
'<url>https://mp.weixin.qq.com/s/example2</url>'
|
||||
'</appmsg></msg>]]></content></refermsg></appmsg></msg>'
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("renderType"), "quote")
|
||||
self.assertEqual(parsed.get("quoteType"), "5")
|
||||
self.assertEqual(parsed.get("quoteTitle"), "水豚噜噜")
|
||||
self.assertEqual(parsed.get("quoteContent"), "[链接] 谁说冬天不能穿裙子?")
|
||||
self.assertEqual(parsed.get("quoteThumbUrl"), "https://mmbiz.qpic.cn/some-thumb2.jpg")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,68 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
from wechat_decrypt_tool.routers import chat as chat_router
|
||||
|
||||
|
||||
class TestTransferPostprocess(unittest.TestCase):
|
||||
def test_backfilled_pending_and_received_confirmation_have_expected_titles(self):
|
||||
transfer_id = "1000050001202601152035503031545"
|
||||
merged = [
|
||||
{
|
||||
"id": "message_0:Msg_x:60",
|
||||
"renderType": "transfer",
|
||||
"paySubType": "1",
|
||||
"transferId": transfer_id,
|
||||
"amount": "¥100.00",
|
||||
"createTime": 1768463200,
|
||||
"isSent": False,
|
||||
"transferStatus": "",
|
||||
},
|
||||
{
|
||||
"id": "message_0:Msg_x:65",
|
||||
"renderType": "transfer",
|
||||
"paySubType": "3",
|
||||
"transferId": transfer_id,
|
||||
"amount": "¥100.00",
|
||||
"createTime": 1768463246,
|
||||
"isSent": True,
|
||||
# Pre-inferred value (may be "已被接收") should be corrected by postprocess.
|
||||
"transferStatus": "已被接收",
|
||||
},
|
||||
]
|
||||
|
||||
chat_router._postprocess_transfer_messages(merged)
|
||||
|
||||
self.assertEqual(merged[0].get("paySubType"), "3")
|
||||
self.assertEqual(merged[0].get("transferStatus"), "已被接收")
|
||||
self.assertEqual(merged[1].get("paySubType"), "3")
|
||||
self.assertEqual(merged[1].get("transferStatus"), "已收款")
|
||||
|
||||
def test_received_message_without_pending_is_left_unchanged(self):
|
||||
merged = [
|
||||
{
|
||||
"id": "message_0:Msg_x:65",
|
||||
"renderType": "transfer",
|
||||
"paySubType": "3",
|
||||
"transferId": "t1",
|
||||
"amount": "¥100.00",
|
||||
"createTime": 1,
|
||||
"isSent": True,
|
||||
"transferStatus": "已被接收",
|
||||
}
|
||||
]
|
||||
|
||||
chat_router._postprocess_transfer_messages(merged)
|
||||
|
||||
self.assertEqual(merged[0].get("transferStatus"), "已被接收")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.chat_helpers import _infer_transfer_status_text
|
||||
|
||||
|
||||
class TestTransferStatusText(unittest.TestCase):
|
||||
def test_paysubtype_3_sent_side(self):
|
||||
status = _infer_transfer_status_text(
|
||||
is_sent=True,
|
||||
paysubtype="3",
|
||||
receivestatus="",
|
||||
sendertitle="",
|
||||
receivertitle="",
|
||||
senderdes="",
|
||||
receiverdes="",
|
||||
)
|
||||
self.assertEqual(status, "已被接收")
|
||||
|
||||
def test_paysubtype_3_received_side(self):
|
||||
status = _infer_transfer_status_text(
|
||||
is_sent=False,
|
||||
paysubtype="3",
|
||||
receivestatus="",
|
||||
sendertitle="",
|
||||
receivertitle="",
|
||||
senderdes="",
|
||||
receiverdes="",
|
||||
)
|
||||
self.assertEqual(status, "已收款")
|
||||
|
||||
def test_receivestatus_1_sent_side(self):
|
||||
status = _infer_transfer_status_text(
|
||||
is_sent=True,
|
||||
paysubtype="1",
|
||||
receivestatus="1",
|
||||
sendertitle="",
|
||||
receivertitle="",
|
||||
senderdes="",
|
||||
receiverdes="",
|
||||
)
|
||||
self.assertEqual(status, "已被接收")
|
||||
|
||||
def test_receivestatus_1_received_side(self):
|
||||
status = _infer_transfer_status_text(
|
||||
is_sent=False,
|
||||
paysubtype="1",
|
||||
receivestatus="1",
|
||||
sendertitle="",
|
||||
receivertitle="",
|
||||
senderdes="",
|
||||
receiverdes="",
|
||||
)
|
||||
self.assertEqual(status, "已收款")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,2 @@
|
||||
> 这里放wx_key模块的python预编译wheel:https://github.com/H3CoF6/py_wx_key/releases/
|
||||
> 解压放入即可
|
||||
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
@@ -230,6 +230,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.6.4"
|
||||
@@ -259,6 +272,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
@@ -844,7 +872,9 @@ dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "loguru" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pilk" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pycryptodome" },
|
||||
@@ -854,6 +884,7 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "wx-key" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
|
||||
@@ -867,7 +898,9 @@ requires-dist = [
|
||||
{ name = "aiofiles", specifier = ">=23.2.1" },
|
||||
{ name = "cryptography", specifier = ">=41.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.104.0" },
|
||||
{ name = "httpx" },
|
||||
{ name = "loguru", specifier = ">=0.7.0" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pilk", specifier = ">=0.2.4" },
|
||||
{ name = "psutil", specifier = ">=7.0.0" },
|
||||
{ name = "pycryptodome", specifier = ">=3.23.0" },
|
||||
@@ -878,6 +911,7 @@ requires-dist = [
|
||||
{ name = "requests", specifier = ">=2.32.4" },
|
||||
{ name = "typing-extensions", specifier = ">=4.8.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
|
||||
{ name = "wx-key" },
|
||||
{ name = "zstandard", specifier = ">=0.23.0" },
|
||||
]
|
||||
provides-extras = ["build"]
|
||||
@@ -891,6 +925,17 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wx-key"
|
||||
version = "1.0.0"
|
||||
source = { registry = "tools/key_wheels" }
|
||||
wheels = [
|
||||
{ path = "wx_key-1.0.0-cp311-cp311-win_amd64.whl" },
|
||||
{ path = "wx_key-1.0.0-cp312-cp312-win_amd64.whl" },
|
||||
{ path = "wx_key-1.0.0-cp313-cp313-win_amd64.whl" },
|
||||
{ path = "wx_key-1.0.0-cp314-cp314-win_amd64.whl" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
|
||||