mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
11 Commits
@@ -8,6 +8,7 @@
|
||||
<p><b>特别致谢</b>:<a href="https://github.com/ycccccccy/echotrace">echotrace</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" />
|
||||
<img src="https://img.shields.io/github/forks/LifeArchiveProject/WeChatDataAnalysis" alt="Forks" />
|
||||
<img src="https://img.shields.io/github/license/LifeArchiveProject/WeChatDataAnalysis" alt="License" />
|
||||
<img src="https://img.shields.io/badge/Python-3776AB?logo=Python&logoColor=white" alt="Python" />
|
||||
|
||||
@@ -78,3 +78,78 @@ Function WDA_InstallDirPageLeave
|
||||
FunctionEnd
|
||||
|
||||
!endif
|
||||
|
||||
!ifdef BUILD_UNINSTALLER
|
||||
!include nsDialogs.nsh
|
||||
!include LogicLib.nsh
|
||||
|
||||
Var WDA_UninstallOptionsPage
|
||||
Var WDA_UninstallDeleteDataCheckbox
|
||||
Var /GLOBAL WDA_DeleteUserData
|
||||
|
||||
!macro customUnInit
|
||||
; Default: keep user data (also applies to silent uninstall / update uninstall).
|
||||
StrCpy $WDA_DeleteUserData "0"
|
||||
!macroend
|
||||
|
||||
!macro customUnWelcomePage
|
||||
!insertmacro MUI_UNPAGE_WELCOME
|
||||
; Optional page: allow user to choose whether to delete app data.
|
||||
UninstPage custom un.WDA_UninstallOptionsCreate un.WDA_UninstallOptionsLeave
|
||||
!macroend
|
||||
|
||||
Function un.WDA_UninstallOptionsCreate
|
||||
nsDialogs::Create 1018
|
||||
Pop $WDA_UninstallOptionsPage
|
||||
|
||||
${If} $WDA_UninstallOptionsPage == error
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
${NSD_CreateLabel} 0u 0u 100% 24u "卸载选项:"
|
||||
Pop $0
|
||||
|
||||
${NSD_CreateCheckbox} 0u 24u 100% 12u "同时删除用户数据(导出的聊天记录、日志、配置等)"
|
||||
Pop $WDA_UninstallDeleteDataCheckbox
|
||||
; Safer default: do not delete.
|
||||
${NSD_Uncheck} $WDA_UninstallDeleteDataCheckbox
|
||||
|
||||
nsDialogs::Show
|
||||
FunctionEnd
|
||||
|
||||
Function un.WDA_UninstallOptionsLeave
|
||||
${NSD_GetState} $WDA_UninstallDeleteDataCheckbox $0
|
||||
${If} $0 == ${BST_CHECKED}
|
||||
StrCpy $WDA_DeleteUserData "1"
|
||||
${Else}
|
||||
StrCpy $WDA_DeleteUserData "0"
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
!macro customUnInstall
|
||||
; If this is an update uninstall, never delete user data.
|
||||
${ifNot} ${isUpdated}
|
||||
${if} $WDA_DeleteUserData == "1"
|
||||
; Electron always stores user data per-user. If the app was installed for all users,
|
||||
; switch to current user context to remove the correct AppData directory.
|
||||
${if} $installMode == "all"
|
||||
SetShellVarContext current
|
||||
${endif}
|
||||
|
||||
RMDir /r "$APPDATA\${APP_FILENAME}"
|
||||
!ifdef APP_PRODUCT_FILENAME
|
||||
RMDir /r "$APPDATA\${APP_PRODUCT_FILENAME}"
|
||||
!endif
|
||||
; Electron may use package.json "name" for some storage (cache, indexeddb, etc.).
|
||||
!ifdef APP_PACKAGE_NAME
|
||||
RMDir /r "$APPDATA\${APP_PACKAGE_NAME}"
|
||||
!endif
|
||||
|
||||
${if} $installMode == "all"
|
||||
SetShellVarContext all
|
||||
${endif}
|
||||
${endif}
|
||||
${endif}
|
||||
!macroend
|
||||
|
||||
!endif
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
@@ -2,6 +2,7 @@ const {
|
||||
app,
|
||||
BrowserWindow,
|
||||
Menu,
|
||||
Tray,
|
||||
ipcMain,
|
||||
globalShortcut,
|
||||
dialog,
|
||||
@@ -19,6 +20,10 @@ const BACKEND_HEALTH_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}/api/health`;
|
||||
let backendProc = null;
|
||||
let backendStdioStream = null;
|
||||
let resolvedDataDir = null;
|
||||
let mainWindow = null;
|
||||
let tray = null;
|
||||
let isQuitting = false;
|
||||
let desktopSettings = null;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
@@ -109,6 +114,163 @@ function logMain(line) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function getDesktopSettingsPath() {
|
||||
const dir = getUserDataDir();
|
||||
if (!dir) return null;
|
||||
return path.join(dir, "desktop-settings.json");
|
||||
}
|
||||
|
||||
function loadDesktopSettings() {
|
||||
if (desktopSettings) return desktopSettings;
|
||||
|
||||
const defaults = {
|
||||
// 'tray' (default): closing the window hides it to the system tray.
|
||||
// 'exit': closing the window quits the app.
|
||||
closeBehavior: "tray",
|
||||
};
|
||||
|
||||
const p = getDesktopSettingsPath();
|
||||
if (!p) {
|
||||
desktopSettings = { ...defaults };
|
||||
return desktopSettings;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(p)) {
|
||||
desktopSettings = { ...defaults };
|
||||
return desktopSettings;
|
||||
}
|
||||
const raw = fs.readFileSync(p, { encoding: "utf8" });
|
||||
const parsed = JSON.parse(raw || "{}");
|
||||
desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
|
||||
} catch (err) {
|
||||
desktopSettings = { ...defaults };
|
||||
logMain(`[main] failed to load settings: ${err?.message || err}`);
|
||||
}
|
||||
|
||||
return desktopSettings;
|
||||
}
|
||||
|
||||
function persistDesktopSettings() {
|
||||
const p = getDesktopSettingsPath();
|
||||
if (!p) return;
|
||||
if (!desktopSettings) return;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(desktopSettings, null, 2), { encoding: "utf8" });
|
||||
} catch (err) {
|
||||
logMain(`[main] failed to persist settings: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getCloseBehavior() {
|
||||
const v = String(loadDesktopSettings()?.closeBehavior || "").trim().toLowerCase();
|
||||
return v === "exit" ? "exit" : "tray";
|
||||
}
|
||||
|
||||
function setCloseBehavior(next) {
|
||||
const v = String(next || "").trim().toLowerCase();
|
||||
loadDesktopSettings();
|
||||
desktopSettings.closeBehavior = v === "exit" ? "exit" : "tray";
|
||||
persistDesktopSettings();
|
||||
return desktopSettings.closeBehavior;
|
||||
}
|
||||
|
||||
function getTrayIconPath() {
|
||||
// Prefer an icon shipped in `src/` so it works both in dev and packaged (asar) builds.
|
||||
const shipped = path.join(__dirname, "icon.ico");
|
||||
try {
|
||||
if (fs.existsSync(shipped)) return shipped;
|
||||
} catch {}
|
||||
|
||||
// Dev fallback (not available in packaged builds).
|
||||
const dev = path.resolve(__dirname, "..", "build", "icon.ico");
|
||||
try {
|
||||
if (fs.existsSync(dev)) return dev;
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function showMainWindow() {
|
||||
if (!mainWindow) return;
|
||||
try {
|
||||
mainWindow.setSkipTaskbar(false);
|
||||
} catch {}
|
||||
try {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
} catch {}
|
||||
try {
|
||||
mainWindow.show();
|
||||
} catch {}
|
||||
try {
|
||||
mainWindow.focus();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
if (tray) return tray;
|
||||
if (!app.isPackaged) return null;
|
||||
|
||||
const iconPath = getTrayIconPath();
|
||||
if (!iconPath) {
|
||||
logMain("[main] tray icon not found; disabling tray behavior");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
tray = new Tray(iconPath);
|
||||
} catch (err) {
|
||||
tray = null;
|
||||
logMain(`[main] failed to create tray: ${err?.message || err}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
tray.setToolTip("WeChatDataAnalysis");
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
tray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{
|
||||
label: "显示",
|
||||
click: () => showMainWindow(),
|
||||
},
|
||||
{
|
||||
label: "退出",
|
||||
click: () => {
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
tray.on("click", () => showMainWindow());
|
||||
tray.on("double-click", () => showMainWindow());
|
||||
} catch {}
|
||||
|
||||
return tray;
|
||||
}
|
||||
|
||||
function destroyTray() {
|
||||
if (!tray) return;
|
||||
try {
|
||||
tray.destroy();
|
||||
} catch {}
|
||||
tray = null;
|
||||
}
|
||||
|
||||
function ensureTrayForCloseBehavior() {
|
||||
const behavior = getCloseBehavior();
|
||||
if (behavior === "tray") createTray();
|
||||
else destroyTray();
|
||||
}
|
||||
|
||||
function getBackendStdioLogPath(dataDir) {
|
||||
return path.join(dataDir, "backend-stdio.log");
|
||||
}
|
||||
@@ -335,6 +497,26 @@ function createMainWindow() {
|
||||
},
|
||||
});
|
||||
|
||||
win.on("close", (event) => {
|
||||
// In packaged builds, we default to "close -> minimize to tray" unless the user opts out.
|
||||
if (!app.isPackaged) return;
|
||||
if (isQuitting) return;
|
||||
if (getCloseBehavior() !== "tray") return;
|
||||
if (!tray) return;
|
||||
|
||||
try {
|
||||
event.preventDefault();
|
||||
win.setSkipTaskbar(true);
|
||||
win.hide();
|
||||
try {
|
||||
tray.displayBalloon({
|
||||
title: "WeChatDataAnalysis",
|
||||
content: "已最小化到托盘,可从托盘图标再次打开。",
|
||||
});
|
||||
} catch {}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
win.on("closed", () => {
|
||||
stopBackend();
|
||||
});
|
||||
@@ -382,6 +564,53 @@ function registerWindowIpc() {
|
||||
const win = getWin(event);
|
||||
return !!win?.isMaximized();
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getAutoLaunch", () => {
|
||||
try {
|
||||
const settings = app.getLoginItemSettings();
|
||||
return !!(settings?.openAtLogin || settings?.executableWillLaunchAtLogin);
|
||||
} catch (err) {
|
||||
logMain(`[main] getAutoLaunch failed: ${err?.message || err}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:setAutoLaunch", (_event, enabled) => {
|
||||
const on = !!enabled;
|
||||
try {
|
||||
app.setLoginItemSettings({ openAtLogin: on });
|
||||
} catch (err) {
|
||||
logMain(`[main] setAutoLaunch(${on}) failed: ${err?.message || err}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = app.getLoginItemSettings();
|
||||
return !!(settings?.openAtLogin || settings?.executableWillLaunchAtLogin);
|
||||
} catch {
|
||||
return on;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getCloseBehavior", () => {
|
||||
try {
|
||||
return getCloseBehavior();
|
||||
} catch (err) {
|
||||
logMain(`[main] getCloseBehavior failed: ${err?.message || err}`);
|
||||
return "tray";
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:setCloseBehavior", (_event, behavior) => {
|
||||
try {
|
||||
const next = setCloseBehavior(behavior);
|
||||
ensureTrayForCloseBehavior();
|
||||
return next;
|
||||
} catch (err) {
|
||||
logMain(`[main] setCloseBehavior failed: ${err?.message || err}`);
|
||||
return getCloseBehavior();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -401,6 +630,8 @@ async function main() {
|
||||
await waitForBackend({ timeoutMs: 30_000 });
|
||||
|
||||
const win = createMainWindow();
|
||||
mainWindow = win;
|
||||
ensureTrayForCloseBehavior();
|
||||
|
||||
const startUrl =
|
||||
process.env.ELECTRON_START_URL ||
|
||||
@@ -428,6 +659,8 @@ app.on("will-quit", () => {
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
isQuitting = true;
|
||||
destroyTray();
|
||||
stopBackend();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,5 +5,10 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"),
|
||||
close: () => ipcRenderer.invoke("window:close"),
|
||||
isMaximized: () => ipcRenderer.invoke("window:isMaximized"),
|
||||
});
|
||||
|
||||
getAutoLaunch: () => ipcRenderer.invoke("app:getAutoLaunch"),
|
||||
setAutoLaunch: (enabled) => ipcRenderer.invoke("app:setAutoLaunch", !!enabled),
|
||||
|
||||
getCloseBehavior: () => ipcRenderer.invoke("app:getCloseBehavior"),
|
||||
setCloseBehavior: (behavior) => ipcRenderer.invoke("app:setCloseBehavior", String(behavior || "")),
|
||||
});
|
||||
|
||||
+20
-1
@@ -14,12 +14,23 @@
|
||||
// So we detect desktop onMounted and update reactively.
|
||||
const isDesktop = ref(false)
|
||||
|
||||
const updateDprVar = () => {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
document.documentElement.style.setProperty('--dpr', String(dpr))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isDesktop.value = !!window?.wechatDesktop
|
||||
updateDprVar()
|
||||
window.addEventListener('resize', updateDprVar)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateDprVar)
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
|
||||
const isChatRoute = computed(() => route.path?.startsWith('/chat') || route.path?.startsWith('/sns'))
|
||||
|
||||
const rootClass = computed(() => {
|
||||
const base = 'bg-gradient-to-br from-green-50 via-emerald-50 to-green-100'
|
||||
@@ -34,6 +45,14 @@ const contentClass = computed(() =>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--dpr: 1;
|
||||
/* Left sidebar rail (chat/sns): icon size + spacing */
|
||||
--sidebar-rail-step: 48px;
|
||||
--sidebar-rail-btn: 32px;
|
||||
--sidebar-rail-icon: 24px;
|
||||
}
|
||||
|
||||
/* Electron 桌面端使用自绘标题栏(frame: false)。
|
||||
* 页面里如果继续用 Tailwind 的 h-screen/min-h-screen(100vh),会把标题栏高度叠加进去,从而出现外层滚动条。
|
||||
* 这里把 “screen” 在桌面端视为内容区高度(100%),让标题栏高度自然内嵌在布局里。 */
|
||||
|
||||
@@ -179,6 +179,46 @@ export const useApi = () => {
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 朋友圈时间线
|
||||
const listSnsTimeline = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.limit != null) query.set('limit', String(params.limit))
|
||||
if (params && params.offset != null) query.set('offset', String(params.offset))
|
||||
if (params && params.usernames && Array.isArray(params.usernames) && params.usernames.length > 0) {
|
||||
query.set('usernames', params.usernames.join(','))
|
||||
} else if (params && params.usernames && typeof params.usernames === 'string') {
|
||||
query.set('usernames', params.usernames)
|
||||
}
|
||||
if (params && params.keyword) query.set('keyword', params.keyword)
|
||||
const url = '/sns/timeline' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 朋友圈图片本地缓存候选(用于错图时手动选择)
|
||||
const listSnsMediaCandidates = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.create_time != null) query.set('create_time', String(params.create_time))
|
||||
if (params && params.width != null) query.set('width', String(params.width))
|
||||
if (params && params.height != null) query.set('height', String(params.height))
|
||||
if (params && params.limit != null) query.set('limit', String(params.limit))
|
||||
if (params && params.offset != null) query.set('offset', String(params.offset))
|
||||
const url = '/sns/media_candidates' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 保存朋友圈图片手动匹配结果(本机)
|
||||
const saveSnsMediaPicks = async (data = {}) => {
|
||||
return await request('/sns/media_picks', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
account: data.account || null,
|
||||
picks: (data && data.picks && typeof data.picks === 'object' && !Array.isArray(data.picks)) ? data.picks : {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const openChatMediaFolder = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
@@ -288,6 +328,9 @@ export const useApi = () => {
|
||||
buildChatSearchIndex,
|
||||
listChatSearchSenders,
|
||||
getChatMessagesAround,
|
||||
listSnsTimeline,
|
||||
listSnsMediaCandidates,
|
||||
saveSnsMediaPicks,
|
||||
openChatMediaFolder,
|
||||
downloadChatEmoji,
|
||||
saveMediaKeys,
|
||||
|
||||
@@ -1,29 +1,99 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<!-- 左侧边栏 -->
|
||||
<div class="w-16 border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7">
|
||||
<div class="flex-1 flex flex-col justify-start pt-0">
|
||||
<div class="border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7; width: 60px; min-width: 60px; max-width: 60px">
|
||||
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
|
||||
<!-- 头像(类似微信侧边栏) -->
|
||||
<div class="w-full h-[60px] flex items-center justify-center">
|
||||
<div class="w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
|
||||
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
|
||||
style="background-color: #4B5563"
|
||||
>
|
||||
我
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天图标 (与 oh-my-wechat 一致) -->
|
||||
<div class="w-16 h-16 flex items-center justify-center chat-tab selected text-[#07b75b]">
|
||||
<div class="w-7 h-7">
|
||||
<svg class="w-full h-full" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z"/>
|
||||
</svg>
|
||||
<div class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group">
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md bg-transparent group-hover:bg-[#E1E1E1] flex items-center justify-center transition-colors">
|
||||
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#07b75b]">
|
||||
<svg class="w-full h-full" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 朋友圈图标(Aperture 风格) -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="朋友圈"
|
||||
@click="goSns"
|
||||
>
|
||||
<div
|
||||
class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isSnsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="14.31" y1="8" x2="20.05" y2="17.94" />
|
||||
<line x1="9.69" y1="8" x2="21.17" y2="8" />
|
||||
<line x1="7.38" y1="12" x2="13.12" y2="2.06" />
|
||||
<line x1="9.69" y1="16" x2="3.95" y2="6.06" />
|
||||
<line x1="14.31" y1="16" x2="2.83" y2="16" />
|
||||
<line x1="16.62" y1="12" x2="10.88" y2="21.94" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐私模式按钮 -->
|
||||
<div
|
||||
class="w-16 h-12 flex items-center justify-center cursor-pointer transition-colors"
|
||||
:class="privacyMode ? 'text-[#03C160]' : 'text-gray-500 hover:text-gray-700'"
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="privacyMode = !privacyMode"
|
||||
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
|
||||
>
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path v-if="privacyMode" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<div
|
||||
class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]"
|
||||
>
|
||||
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="privacyMode ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path v-if="privacyMode" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置按钮(仅桌面端) -->
|
||||
<div
|
||||
v-if="isDesktopEnv"
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="openDesktopSettings"
|
||||
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)] 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>
|
||||
@@ -272,7 +342,13 @@
|
||||
<!-- 消息发送者头像 -->
|
||||
<div class="w-[36px] h-[36px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]">
|
||||
<div v-if="message.avatar" class="w-full h-full">
|
||||
<img :src="message.avatar" :alt="message.sender + '的头像'" class="w-full h-full object-cover">
|
||||
<img
|
||||
:src="message.avatar"
|
||||
:alt="message.sender + '的头像'"
|
||||
class="w-full h-full object-cover"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="onMessageAvatarError($event, message)"
|
||||
>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
|
||||
:style="{ backgroundColor: message.avatarColor || (message.isSent ? '#4B5563' : '#6B7280') }">
|
||||
@@ -302,7 +378,9 @@
|
||||
:heading="message.title || message.content"
|
||||
:abstract="message.content"
|
||||
:preview="message.preview"
|
||||
:fromAvatar="message.fromAvatar"
|
||||
:from="message.from"
|
||||
:isSent="message.isSent"
|
||||
/>
|
||||
<div v-else-if="message.renderType === 'file'"
|
||||
class="wechat-redpacket-card wechat-special-card wechat-file-card msg-radius"
|
||||
@@ -1543,6 +1621,104 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端设置弹窗 -->
|
||||
<div
|
||||
v-if="desktopSettingsOpen"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
@click.self="closeDesktopSettings"
|
||||
>
|
||||
<div class="w-full max-w-md bg-white rounded-lg shadow-lg">
|
||||
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<div class="text-base font-medium text-gray-900">设置</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-500 hover:text-gray-700"
|
||||
@click="closeDesktopSettings"
|
||||
title="关闭"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4 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="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"
|
||||
:disabled="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 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 class="px-5 py-4 border-t border-gray-200 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm px-3 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50"
|
||||
@click="closeDesktopSettings"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1591,6 +1767,7 @@ useHead({
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
|
||||
|
||||
const routeUsername = computed(() => {
|
||||
const raw = route.params.username
|
||||
@@ -1606,6 +1783,163 @@ const selectedContact = ref(null)
|
||||
|
||||
// 隐私模式
|
||||
const privacyMode = ref(false)
|
||||
const PRIVACY_MODE_KEY = 'ui.privacy_mode'
|
||||
|
||||
onMounted(() => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
privacyMode.value = localStorage.getItem(PRIVACY_MODE_KEY) === '1'
|
||||
} catch {}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => privacyMode.value,
|
||||
(v) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(PRIVACY_MODE_KEY, v ? '1' : '0')
|
||||
} catch {}
|
||||
}
|
||||
)
|
||||
|
||||
// 桌面端设置(仅 Electron 环境可见)
|
||||
const isDesktopEnv = ref(false)
|
||||
const desktopSettingsOpen = ref(false)
|
||||
|
||||
const DESKTOP_SETTING_AUTO_REALTIME_KEY = 'desktop.settings.autoRealtime'
|
||||
const DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY = 'desktop.settings.defaultToChatWhenData'
|
||||
|
||||
const desktopAutoRealtime = ref(false)
|
||||
const desktopDefaultToChatWhenData = ref(false)
|
||||
|
||||
const desktopAutoLaunch = ref(false)
|
||||
const desktopAutoLaunchLoading = ref(false)
|
||||
const desktopAutoLaunchError = ref('')
|
||||
|
||||
const desktopCloseBehavior = ref('tray') // tray | exit
|
||||
const desktopCloseBehaviorLoading = ref(false)
|
||||
const desktopCloseBehaviorError = ref('')
|
||||
|
||||
const readLocalBool = (key) => {
|
||||
if (!process.client) return false
|
||||
try {
|
||||
return localStorage.getItem(key) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const writeLocalBool = (key, value) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(key, value ? 'true' : 'false')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 尽量早读本地设置,避免首次加载联系人时拿不到 autoRealtime 选项
|
||||
if (process.client) {
|
||||
desktopAutoRealtime.value = readLocalBool(DESKTOP_SETTING_AUTO_REALTIME_KEY)
|
||||
desktopDefaultToChatWhenData.value = readLocalBool(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY)
|
||||
}
|
||||
|
||||
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 openDesktopSettings = async () => {
|
||||
desktopSettingsOpen.value = true
|
||||
await refreshDesktopAutoLaunch()
|
||||
await refreshDesktopCloseBehavior()
|
||||
}
|
||||
|
||||
const closeDesktopSettings = () => {
|
||||
desktopSettingsOpen.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 = async (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
desktopAutoRealtime.value = checked
|
||||
writeLocalBool(DESKTOP_SETTING_AUTO_REALTIME_KEY, checked)
|
||||
if (checked) {
|
||||
// 开启后尝试立即启用实时模式(不可用则静默忽略)
|
||||
try {
|
||||
await tryEnableRealtimeAuto()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const onDesktopDefaultToChatToggle = (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
desktopDefaultToChatWhenData.value = checked
|
||||
writeLocalBool(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, checked)
|
||||
}
|
||||
|
||||
// 联系人数据
|
||||
const contacts = ref([])
|
||||
@@ -1618,6 +1952,18 @@ const selectedAccount = ref(null)
|
||||
|
||||
const availableAccounts = ref([])
|
||||
|
||||
const sidebarMediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
|
||||
const selfAvatarUrl = computed(() => {
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc) return ''
|
||||
return `${sidebarMediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
|
||||
})
|
||||
|
||||
const goSns = async () => {
|
||||
await navigateTo('/sns')
|
||||
}
|
||||
|
||||
// 实时更新(WCDB DLL + db_storage watcher)
|
||||
const realtimeEnabled = ref(false)
|
||||
const realtimeAvailable = ref(false)
|
||||
@@ -1632,6 +1978,7 @@ let realtimeSessionsRefreshQueued = false
|
||||
let realtimeFullSyncFuture = null
|
||||
let realtimeFullSyncQueued = false
|
||||
let realtimeFullSyncPriority = ''
|
||||
let realtimeChangeDebounceTimer = null
|
||||
|
||||
const allMessages = ref({})
|
||||
|
||||
@@ -3406,6 +3753,9 @@ const applyRouteSelection = async () => {
|
||||
|
||||
// 默认选择第一个联系人
|
||||
onMounted(() => {
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
isDesktopEnv.value = !!window.wechatDesktop
|
||||
}
|
||||
loadContacts()
|
||||
loadSearchHistory()
|
||||
})
|
||||
@@ -3436,6 +3786,8 @@ const loadContacts = async () => {
|
||||
} finally {
|
||||
isLoadingContacts.value = false
|
||||
}
|
||||
|
||||
await tryEnableRealtimeAuto()
|
||||
}
|
||||
|
||||
const loadSessionsForSelectedAccount = async () => {
|
||||
@@ -3653,6 +4005,36 @@ const normalizeMessage = (msg) => {
|
||||
)
|
||||
}
|
||||
|
||||
// WeChat public account thumbnails (mmbiz.qpic.cn, wx.qlogo.cn...) are hotlink-protected:
|
||||
// the browser will get a placeholder image ("此图片来自微信公众号平台").
|
||||
// Proxy them via backend with a mp.weixin.qq.com Referer to fetch the real image.
|
||||
const normalizedThumbUrl = (() => {
|
||||
// Backend may provide either `thumbUrl` (appmsg) or `preview` (some exports). Use the first usable one.
|
||||
const candidates = [msg.thumbUrl, msg.preview]
|
||||
for (const cand of candidates) {
|
||||
if (isUsableMediaUrl(cand)) return normalizeMaybeUrl(cand)
|
||||
}
|
||||
return ''
|
||||
})()
|
||||
const normalizedLinkPreviewUrl = (() => {
|
||||
const u = normalizedThumbUrl
|
||||
if (!u) return ''
|
||||
if (/^\/api\/chat\/media\//i.test(u) || /^blob:/i.test(u) || /^data:/i.test(u)) return u
|
||||
if (!/^https?:\/\//i.test(u)) return u
|
||||
try {
|
||||
const host = new URL(u).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(u)}`
|
||||
}
|
||||
} catch {}
|
||||
return u
|
||||
})()
|
||||
|
||||
const fromUsername = String(msg.fromUsername || '').trim()
|
||||
const fromAvatar = fromUsername
|
||||
? `${mediaBase}/api/chat/avatar?account=${encodeURIComponent(selectedAccount.value || '')}&username=${encodeURIComponent(fromUsername)}`
|
||||
: ''
|
||||
|
||||
const localEmojiUrl = msg.emojiMd5 ? `${mediaBase}/api/chat/media/emoji?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.emojiMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : ''
|
||||
const localImageUrl = (() => {
|
||||
if (!msg.imageMd5 && !msg.imageFileId) return ''
|
||||
@@ -3792,14 +4174,23 @@ const normalizeMessage = (msg) => {
|
||||
transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款',
|
||||
voiceUrl: normalizedVoiceUrl || '',
|
||||
voiceDuration: msg.voiceLength || msg.voiceDuration || '',
|
||||
preview: msg.thumbUrl || '',
|
||||
from: '',
|
||||
preview: normalizedLinkPreviewUrl || '',
|
||||
from: String(msg.from || '').trim(),
|
||||
fromUsername,
|
||||
fromAvatar,
|
||||
isGroup: !!selectedContact.value?.isGroup,
|
||||
avatar: msg.senderAvatar || fallbackAvatar || null,
|
||||
// Backends may use either `senderAvatar` (our API) or `avatar` (exported JSON).
|
||||
avatar: msg.senderAvatar || msg.avatar || fallbackAvatar || null,
|
||||
avatarColor: null
|
||||
}
|
||||
}
|
||||
|
||||
const onMessageAvatarError = (e, message) => {
|
||||
// Make sure we fall back to the initial avatar if the URL 404s/blocks.
|
||||
try { e?.target && (e.target.style.display = 'none') } catch {}
|
||||
try { if (message) message.avatar = null } catch {}
|
||||
}
|
||||
|
||||
const shouldShowEmojiDownload = (message) => {
|
||||
if (!message?.emojiMd5) return false
|
||||
const u = String(message?.emojiRemoteUrl || '').trim()
|
||||
@@ -4338,9 +4729,9 @@ const loadMessages = async ({ username, reset }) => {
|
||||
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
|
||||
params.render_types = messageTypeFilter.value
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
await queueRealtimeFullSync(username)
|
||||
if (realtimeEnabled.value) {
|
||||
// In realtime mode, read directly from WCDB to avoid blocking on background sync.
|
||||
params.source = 'realtime'
|
||||
}
|
||||
const resp = await api.listChatMessages(params)
|
||||
|
||||
@@ -4441,6 +4832,12 @@ const stopRealtimeStream = () => {
|
||||
} catch {}
|
||||
realtimeEventSource = null
|
||||
}
|
||||
if (realtimeChangeDebounceTimer) {
|
||||
try {
|
||||
clearTimeout(realtimeChangeDebounceTimer)
|
||||
} catch {}
|
||||
realtimeChangeDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const refreshRealtimeIncremental = async () => {
|
||||
@@ -4468,8 +4865,8 @@ const refreshRealtimeIncremental = async () => {
|
||||
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
|
||||
params.render_types = messageTypeFilter.value
|
||||
}
|
||||
params.source = 'realtime'
|
||||
|
||||
await queueRealtimeFullSync(username)
|
||||
const resp = await api.listChatMessages(params)
|
||||
if (selectedContact.value?.username !== username) return
|
||||
|
||||
@@ -4514,6 +4911,19 @@ const queueRealtimeRefresh = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const queueRealtimeChange = () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!realtimeEnabled.value) return
|
||||
if (realtimeChangeDebounceTimer) return
|
||||
|
||||
// Debounce noisy db_storage change events to avoid hammering the backend.
|
||||
realtimeChangeDebounceTimer = setTimeout(() => {
|
||||
realtimeChangeDebounceTimer = null
|
||||
queueRealtimeRefresh()
|
||||
queueRealtimeSessionsRefresh()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const startRealtimeStream = () => {
|
||||
stopRealtimeStream()
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
@@ -4534,9 +4944,7 @@ const startRealtimeStream = () => {
|
||||
try {
|
||||
const data = JSON.parse(String(ev.data || '{}'))
|
||||
if (String(data?.type || '') === 'change') {
|
||||
queueRealtimeFullSync(selectedContact.value?.username || '')
|
||||
queueRealtimeRefresh()
|
||||
queueRealtimeSessionsRefresh()
|
||||
queueRealtimeChange()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -4546,15 +4954,18 @@ const startRealtimeStream = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRealtime = async () => {
|
||||
const toggleRealtime = async (opts = {}) => {
|
||||
const silent = !!opts?.silent
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!selectedAccount.value) return
|
||||
|
||||
if (!realtimeEnabled.value) {
|
||||
await fetchRealtimeStatus()
|
||||
if (!realtimeAvailable.value) {
|
||||
window.alert(realtimeStatusError.value || '实时模式不可用:缺少密钥或 db_storage 路径。')
|
||||
return
|
||||
if (!silent) {
|
||||
window.alert(realtimeStatusError.value || '实时模式不可用:缺少密钥或 db_storage 路径。')
|
||||
}
|
||||
return false
|
||||
}
|
||||
realtimeEnabled.value = true
|
||||
startRealtimeStream()
|
||||
@@ -4562,7 +4973,7 @@ const toggleRealtime = async () => {
|
||||
if (selectedContact.value?.username) {
|
||||
await refreshSelectedMessages()
|
||||
}
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
realtimeEnabled.value = false
|
||||
@@ -4571,6 +4982,19 @@ const toggleRealtime = async () => {
|
||||
if (selectedContact.value?.username) {
|
||||
await refreshSelectedMessages()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const tryEnableRealtimeAuto = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!isDesktopEnv.value) return
|
||||
if (!desktopAutoRealtime.value) return
|
||||
if (realtimeEnabled.value) return
|
||||
if (!selectedAccount.value) return
|
||||
|
||||
try {
|
||||
await toggleRealtime({ silent: true })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
watch(selectedAccount, async () => {
|
||||
@@ -4714,28 +5138,89 @@ const LinkCard = defineComponent({
|
||||
heading: { type: String, default: '' },
|
||||
abstract: { type: String, default: '' },
|
||||
preview: { type: String, default: '' },
|
||||
from: { type: String, default: '' }
|
||||
fromAvatar: { type: String, default: '' },
|
||||
from: { type: String, default: '' },
|
||||
isSent: { type: Boolean, default: false }
|
||||
},
|
||||
setup(props) {
|
||||
return () => h(
|
||||
'a',
|
||||
{
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noreferrer',
|
||||
class: 'block max-w-sm w-full bg-white msg-radius border border-neutral-200 overflow-hidden hover:bg-gray-50 transition-colors'
|
||||
},
|
||||
[
|
||||
props.preview ? h('div', { class: 'w-full bg-black/5' }, [
|
||||
h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'w-full max-h-40 object-cover' })
|
||||
]) : null,
|
||||
h('div', { class: 'px-3 py-2' }, [
|
||||
h('div', { class: 'text-sm font-medium text-gray-900 line-clamp-2' }, props.heading || props.href),
|
||||
props.abstract ? h('div', { class: 'text-xs text-gray-600 mt-1 line-clamp-2' }, props.abstract) : null,
|
||||
props.from ? h('div', { class: 'text-[10px] text-gray-400 mt-1 truncate' }, props.from) : null
|
||||
])
|
||||
].filter(Boolean)
|
||||
)
|
||||
const getFromText = () => {
|
||||
const raw = String(props.from || '').trim()
|
||||
if (raw) return raw
|
||||
// Fallback: when the appmsg XML doesn't provide sourcedisplayname/appname,
|
||||
// show the host so the footer row still matches WeChat's fixed card layout.
|
||||
try {
|
||||
const host = new URL(String(props.href || '')).hostname
|
||||
return String(host || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
const fromText = getFromText()
|
||||
// WeChat link cards show a small avatar next to the source text. We don't
|
||||
// always have a real image URL, so fall back to the first glyph.
|
||||
const fromAvatarText = (() => {
|
||||
const t = String(fromText || '').trim()
|
||||
return t ? (Array.from(t)[0] || '') : ''
|
||||
})()
|
||||
const fromAvatarUrl = String(props.fromAvatar || '').trim()
|
||||
return h(
|
||||
'a',
|
||||
{
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noreferrer',
|
||||
class: [
|
||||
'wechat-link-card',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
].filter(Boolean).join(' '),
|
||||
// Inline size is intentional: LinkCard is a local component rendered via `h()` and
|
||||
// does not inherit the SFC scoped CSS attribute, so relying on scoped CSS for exact
|
||||
// sizing is fragile. Keep width in sync with the WeChat desktop card size.
|
||||
style: {
|
||||
width: '210px',
|
||||
minWidth: '210px',
|
||||
maxWidth: '210px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
outline: 'none'
|
||||
}
|
||||
},
|
||||
[
|
||||
h('div', { class: 'wechat-link-content' }, [
|
||||
h('div', { class: 'wechat-link-info' }, [
|
||||
h('div', { class: 'wechat-link-title' }, props.heading || props.href),
|
||||
props.abstract ? h('div', { class: 'wechat-link-desc' }, props.abstract) : null
|
||||
].filter(Boolean)),
|
||||
props.preview ? h('div', { class: 'wechat-link-thumb' }, [
|
||||
h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'wechat-link-thumb-img', referrerpolicy: 'no-referrer' })
|
||||
]) : null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-from' }, [
|
||||
h('div', { class: 'wechat-link-from-avatar', 'aria-hidden': 'true' }, [
|
||||
fromAvatarText || '\u200B',
|
||||
fromAvatarUrl ? h('img', {
|
||||
src: fromAvatarUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-from-avatar-img',
|
||||
referrerpolicy: 'no-referrer',
|
||||
onError: (e) => { try { e?.target && (e.target.style.display = 'none') } catch {} }
|
||||
}) : null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-from-name' }, fromText || '\u200B')
|
||||
])
|
||||
].filter(Boolean)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5049,24 +5534,24 @@ const LinkCard = defineComponent({
|
||||
}
|
||||
|
||||
/* 统一特殊消息尾巴(红包 / 文件等) */
|
||||
.wechat-special-card {
|
||||
:deep(.wechat-special-card) {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.wechat-special-card::after {
|
||||
:deep(.wechat-special-card)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
top: 12px;
|
||||
left: -4px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: inherit;
|
||||
transform: rotate(45deg);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.wechat-special-sent-side::after {
|
||||
:deep(.wechat-special-sent-side)::after {
|
||||
left: auto;
|
||||
right: -4px;
|
||||
}
|
||||
@@ -5418,6 +5903,138 @@ const LinkCard = defineComponent({
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 链接消息样式 - 微信风格 */
|
||||
:deep(.wechat-link-card) {
|
||||
width: 210px;
|
||||
min-width: 210px;
|
||||
max-width: 210px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-card:hover) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-content) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
box-sizing: border-box;
|
||||
/* Keep a small breathing room above the footer divider. */
|
||||
padding: 8px 10px 6px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-info) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-title) {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-desc) {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-thumb) {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: #f2f2f2;
|
||||
/* Center the thumbnail in the content area (WeChat desktop style). */
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-thumb-img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-from) {
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 10px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-from)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 11px;
|
||||
right: 11px;
|
||||
height: 1.5px;
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-from-avatar) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-from-avatar-img) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-from-name) {
|
||||
font-size: 12px;
|
||||
color: #b2b2b2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 隐私模式模糊效果 */
|
||||
.privacy-blur {
|
||||
filter: blur(9px);
|
||||
|
||||
@@ -55,6 +55,31 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
const DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY = 'desktop.settings.defaultToChatWhenData'
|
||||
|
||||
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 {}
|
||||
if (!enabled) return
|
||||
|
||||
try {
|
||||
const api = useApi()
|
||||
const resp = await api.listChatAccounts()
|
||||
const accounts = resp?.accounts || []
|
||||
if (accounts.length) {
|
||||
await navigateTo('/chat', { replace: true })
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
|
||||
// 开始检测并跳转到结果页面
|
||||
const startDetection = async () => {
|
||||
// 直接跳转到检测结果页面,让该页面处理检测
|
||||
@@ -100,4 +125,4 @@ const startDetection = async () => {
|
||||
linear-gradient(90deg, rgba(7, 193, 96, 0.1) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ from .routers.decrypt import router as _decrypt_router
|
||||
from .routers.health import router as _health_router
|
||||
from .routers.keys import router as _keys_router
|
||||
from .routers.media import router as _media_router
|
||||
from .routers.sns import router as _sns_router
|
||||
from .routers.wechat_detection import router as _wechat_detection_router
|
||||
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
|
||||
|
||||
@@ -51,6 +52,7 @@ app.include_router(_media_router)
|
||||
app.include_router(_chat_router)
|
||||
app.include_router(_chat_export_router)
|
||||
app.include_router(_chat_media_router)
|
||||
app.include_router(_sns_router)
|
||||
|
||||
|
||||
class _SPAStaticFiles(StaticFiles):
|
||||
|
||||
@@ -894,6 +894,7 @@ def _parse_message_for_export(
|
||||
content_text = raw_text
|
||||
title = ""
|
||||
url = ""
|
||||
from_name = ""
|
||||
record_item = ""
|
||||
image_md5 = ""
|
||||
image_file_id = ""
|
||||
@@ -934,6 +935,7 @@ def _parse_message_for_export(
|
||||
content_text = str(parsed.get("content") or "")
|
||||
title = str(parsed.get("title") or "")
|
||||
url = str(parsed.get("url") or "")
|
||||
from_name = str(parsed.get("from") or "")
|
||||
record_item = str(parsed.get("recordItem") or "")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
@@ -1162,6 +1164,7 @@ def _parse_message_for_export(
|
||||
"content": content_text,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"from": from_name,
|
||||
"recordItem": record_item,
|
||||
"thumbUrl": thumb_url,
|
||||
"imageMd5": image_md5,
|
||||
|
||||
@@ -773,7 +773,21 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
app_type = 0
|
||||
title = _extract_xml_tag_text(text, "title")
|
||||
des = _extract_xml_tag_text(text, "des")
|
||||
url = _extract_xml_tag_text(text, "url")
|
||||
url = _normalize_xml_url(_extract_xml_tag_text(text, "url"))
|
||||
|
||||
# Some appmsg payloads (notably mp.weixin.qq.com link shares) include a "source" block:
|
||||
# <sourceusername>gh_xxx</sourceusername>
|
||||
# <sourcedisplayname>公众号名</sourcedisplayname>
|
||||
# We'll surface that as `from` so the frontend can render the publisher line like WeChat.
|
||||
source_display_name = (
|
||||
_extract_xml_tag_text(text, "sourcedisplayname")
|
||||
or _extract_xml_tag_text(text, "sourceDisplayName")
|
||||
or _extract_xml_tag_text(text, "appname")
|
||||
)
|
||||
source_username = (
|
||||
_extract_xml_tag_text(text, "sourceusername")
|
||||
or _extract_xml_tag_text(text, "sourceUsername")
|
||||
)
|
||||
|
||||
lower = text.lower()
|
||||
|
||||
@@ -794,13 +808,15 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
if app_type in (5, 68) and url:
|
||||
thumb_url = _extract_xml_tag_text(text, "thumburl")
|
||||
thumb_url = _normalize_xml_url(_extract_xml_tag_text(text, "thumburl"))
|
||||
return {
|
||||
"renderType": "link",
|
||||
"content": des or title or "[链接]",
|
||||
"title": title or des or "",
|
||||
"url": url,
|
||||
"thumbUrl": thumb_url or "",
|
||||
"from": str(source_display_name or "").strip(),
|
||||
"fromUsername": str(source_username or "").strip(),
|
||||
}
|
||||
|
||||
if app_type in (6, 74):
|
||||
@@ -1322,6 +1338,58 @@ def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str,
|
||||
conn.close()
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Some appmsg/link payloads only provide `sourcedisplayname` (surfaced as `from`) but not
|
||||
`sourceusername` (`fromUsername`). We use this mapping to recover `fromUsername` so the
|
||||
frontend can render the publisher avatar via `/api/chat/avatar`.
|
||||
"""
|
||||
|
||||
uniq = list(dict.fromkeys([str(n or "").strip() for n in names if str(n or "").strip()]))
|
||||
if not uniq:
|
||||
return {}
|
||||
|
||||
placeholders = ",".join(["?"] * len(uniq))
|
||||
hits: dict[str, set[str]] = {}
|
||||
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
def query_table(table: str) -> None:
|
||||
for col in ("remark", "nick_name", "alias"):
|
||||
sql = f"""
|
||||
SELECT username, {col} AS display_name
|
||||
FROM {table}
|
||||
WHERE {col} IN ({placeholders})
|
||||
"""
|
||||
try:
|
||||
rows = conn.execute(sql, uniq).fetchall()
|
||||
except Exception:
|
||||
rows = []
|
||||
for r in rows:
|
||||
try:
|
||||
dn = str(r["display_name"] or "").strip()
|
||||
u = str(r["username"] or "").strip()
|
||||
except Exception:
|
||||
continue
|
||||
if not dn or not u:
|
||||
continue
|
||||
hits.setdefault(dn, set()).add(u)
|
||||
|
||||
query_table("contact")
|
||||
query_table("stranger")
|
||||
|
||||
# Only return unambiguous mappings (display name -> exactly 1 username).
|
||||
out: dict[str, str] = {}
|
||||
for dn, users in hits.items():
|
||||
if len(users) == 1:
|
||||
out[dn] = next(iter(users))
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _make_search_tokens(q: str) -> list[str]:
|
||||
tokens = [t for t in re.split(r"\s+", str(q or "").strip()) if t]
|
||||
if len(tokens) > 8:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -58,6 +59,11 @@ class WeChatLogger:
|
||||
|
||||
def setup_logging(self, log_level: str = "INFO"):
|
||||
"""设置日志配置"""
|
||||
# Allow overriding via env var for easier debugging (e.g. WECHAT_TOOL_LOG_LEVEL=DEBUG)
|
||||
env_level = str(os.environ.get("WECHAT_TOOL_LOG_LEVEL", "") or "").strip()
|
||||
if env_level:
|
||||
log_level = env_level
|
||||
|
||||
# 创建日志目录
|
||||
now = datetime.now()
|
||||
log_dir = Path("output/logs") / str(now.year) / f"{now.month:02d}" / f"{now.day:02d}"
|
||||
@@ -88,46 +94,47 @@ class WeChatLogger:
|
||||
# 文件处理器
|
||||
file_handler = logging.FileHandler(self.log_file, encoding='utf-8')
|
||||
file_handler.setFormatter(file_formatter)
|
||||
file_handler.setLevel(getattr(logging, log_level.upper()))
|
||||
level = getattr(logging, str(log_level or "INFO").upper(), logging.INFO)
|
||||
file_handler.setLevel(level)
|
||||
|
||||
# 控制台处理器
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
console_handler.setLevel(getattr(logging, log_level.upper()))
|
||||
console_handler.setLevel(level)
|
||||
|
||||
# 配置根日志器
|
||||
root_logger.setLevel(getattr(logging, log_level.upper()))
|
||||
root_logger.setLevel(level)
|
||||
root_logger.addHandler(file_handler)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# 只为uvicorn日志器添加文件处理器,保持其原有的控制台处理器(带颜色)
|
||||
uvicorn_logger = logging.getLogger("uvicorn")
|
||||
uvicorn_logger.addHandler(file_handler)
|
||||
uvicorn_logger.setLevel(getattr(logging, log_level.upper()))
|
||||
uvicorn_logger.setLevel(level)
|
||||
|
||||
# 只为uvicorn.access日志器添加文件处理器
|
||||
uvicorn_access_logger = logging.getLogger("uvicorn.access")
|
||||
uvicorn_access_logger.addHandler(file_handler)
|
||||
uvicorn_access_logger.setLevel(getattr(logging, log_level.upper()))
|
||||
uvicorn_access_logger.setLevel(level)
|
||||
|
||||
# 只为uvicorn.error日志器添加文件处理器
|
||||
uvicorn_error_logger = logging.getLogger("uvicorn.error")
|
||||
uvicorn_error_logger.addHandler(file_handler)
|
||||
uvicorn_error_logger.setLevel(getattr(logging, log_level.upper()))
|
||||
uvicorn_error_logger.setLevel(level)
|
||||
|
||||
# 配置FastAPI日志器
|
||||
fastapi_logger = logging.getLogger("fastapi")
|
||||
fastapi_logger.handlers = []
|
||||
fastapi_logger.addHandler(file_handler)
|
||||
fastapi_logger.addHandler(console_handler)
|
||||
fastapi_logger.setLevel(getattr(logging, log_level.upper()))
|
||||
fastapi_logger.setLevel(level)
|
||||
|
||||
# 记录初始化信息
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("=" * 60)
|
||||
logger.info("微信解密工具日志系统初始化完成")
|
||||
logger.info(f"日志文件: {self.log_file}")
|
||||
logger.info(f"日志级别: {log_level}")
|
||||
logger.info(f"日志级别: {logging.getLevelName(level)}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
return self.log_file
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -39,6 +39,7 @@ from ..chat_helpers import (
|
||||
_make_snippet,
|
||||
_match_tokens,
|
||||
_load_contact_rows,
|
||||
_load_usernames_by_display_names,
|
||||
_load_latest_message_previews,
|
||||
_lookup_resource_md5,
|
||||
_normalize_xml_url,
|
||||
@@ -212,6 +213,13 @@ async def stream_chat_realtime_events(
|
||||
if not db_storage_dir.exists() or not db_storage_dir.is_dir():
|
||||
raise HTTPException(status_code=400, detail="db_storage directory not found for this account.")
|
||||
|
||||
logger.info(
|
||||
"[realtime] SSE stream open account=%s interval_ms=%s db_storage=%s",
|
||||
account_dir.name,
|
||||
int(interval_ms),
|
||||
str(db_storage_dir),
|
||||
)
|
||||
|
||||
async def gen():
|
||||
last_mtime_ns = 0
|
||||
last_heartbeat = 0.0
|
||||
@@ -225,27 +233,40 @@ async def stream_chat_realtime_events(
|
||||
}
|
||||
yield f"data: {json.dumps(initial, ensure_ascii=False)}\n\n"
|
||||
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
mtime_ns = _scan_db_storage_mtime_ns(db_storage_dir)
|
||||
if mtime_ns and mtime_ns != last_mtime_ns:
|
||||
last_mtime_ns = mtime_ns
|
||||
payload = {
|
||||
"type": "change",
|
||||
"account": account_dir.name,
|
||||
"mtimeNs": int(mtime_ns),
|
||||
"ts": int(time.time() * 1000),
|
||||
}
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
# Avoid blocking the event loop on a potentially large directory walk.
|
||||
scan_t0 = time.perf_counter()
|
||||
try:
|
||||
mtime_ns = await asyncio.to_thread(_scan_db_storage_mtime_ns, db_storage_dir)
|
||||
except Exception:
|
||||
mtime_ns = 0
|
||||
scan_ms = (time.perf_counter() - scan_t0) * 1000.0
|
||||
if scan_ms > 1000:
|
||||
logger.warning("[realtime] SSE scan slow account=%s ms=%.1f", account_dir.name, scan_ms)
|
||||
|
||||
now = time.time()
|
||||
if now - last_heartbeat > 15:
|
||||
last_heartbeat = now
|
||||
yield ": ping\n\n"
|
||||
if mtime_ns and mtime_ns != last_mtime_ns:
|
||||
last_mtime_ns = mtime_ns
|
||||
payload = {
|
||||
"type": "change",
|
||||
"account": account_dir.name,
|
||||
"mtimeNs": int(mtime_ns),
|
||||
"ts": int(time.time() * 1000),
|
||||
}
|
||||
logger.info("[realtime] SSE change account=%s mtime_ns=%s", account_dir.name, int(mtime_ns))
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
await asyncio.sleep(interval_ms / 1000.0)
|
||||
now = time.time()
|
||||
if now - last_heartbeat > 15:
|
||||
last_heartbeat = now
|
||||
yield ": ping\n\n"
|
||||
|
||||
await asyncio.sleep(interval_ms / 1000.0)
|
||||
finally:
|
||||
logger.info("[realtime] SSE stream closed account=%s", account_dir.name)
|
||||
|
||||
headers = {"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}
|
||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
|
||||
@@ -336,7 +357,7 @@ def _ensure_session_last_message_table(conn: sqlite3.Connection) -> None:
|
||||
|
||||
|
||||
@router.post("/api/chat/realtime/sync", summary="实时消息同步到解密库(按会话增量)")
|
||||
async def sync_chat_realtime_messages(
|
||||
def sync_chat_realtime_messages(
|
||||
request: Request,
|
||||
username: str,
|
||||
account: Optional[str] = None,
|
||||
@@ -356,11 +377,23 @@ async def sync_chat_realtime_messages(
|
||||
max_scan = 5000
|
||||
|
||||
account_dir = _resolve_account_dir(account)
|
||||
trace_id = f"rt-sync-{int(time.time() * 1000)}-{threading.get_ident()}"
|
||||
logger.info(
|
||||
"[%s] realtime sync start account=%s username=%s max_scan=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
int(max_scan),
|
||||
)
|
||||
|
||||
# Lock per (account, username) to avoid concurrent writes to the same sqlite tables.
|
||||
logger.info("[%s] acquiring per-session lock account=%s username=%s", trace_id, account_dir.name, username)
|
||||
with _realtime_sync_lock(account_dir.name, username):
|
||||
logger.info("[%s] per-session lock acquired account=%s username=%s", trace_id, account_dir.name, username)
|
||||
try:
|
||||
logger.info("[%s] ensure wcdb connected account=%s", trace_id, account_dir.name)
|
||||
rt_conn = WCDB_REALTIME.ensure_connected(account_dir)
|
||||
logger.info("[%s] wcdb connected account=%s handle=%s", trace_id, account_dir.name, int(rt_conn.handle))
|
||||
except WCDBRealtimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -368,6 +401,14 @@ async def sync_chat_realtime_messages(
|
||||
if not resolved:
|
||||
raise HTTPException(status_code=404, detail="Conversation table not found in decrypted databases.")
|
||||
msg_db_path, table_name = resolved
|
||||
logger.info(
|
||||
"[%s] resolved decrypted table account=%s username=%s db=%s table=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
str(msg_db_path),
|
||||
table_name,
|
||||
)
|
||||
|
||||
msg_conn = sqlite3.connect(str(msg_db_path))
|
||||
msg_conn.row_factory = sqlite3.Row
|
||||
@@ -456,8 +497,34 @@ async def sync_chat_realtime_messages(
|
||||
|
||||
while scanned < int(max_scan):
|
||||
take = min(batch_size, int(max_scan) - scanned)
|
||||
logger.info(
|
||||
"[%s] wcdb_get_messages account=%s username=%s take=%s offset=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
int(take),
|
||||
int(offset),
|
||||
)
|
||||
wcdb_t0 = time.perf_counter()
|
||||
with rt_conn.lock:
|
||||
raw_rows = _wcdb_get_messages(rt_conn.handle, username, limit=take, offset=offset)
|
||||
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
|
||||
logger.info(
|
||||
"[%s] wcdb_get_messages done account=%s username=%s rows=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
len(raw_rows or []),
|
||||
wcdb_ms,
|
||||
)
|
||||
if wcdb_ms > 2000:
|
||||
logger.warning(
|
||||
"[%s] wcdb_get_messages slow account=%s username=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
wcdb_ms,
|
||||
)
|
||||
if not raw_rows:
|
||||
break
|
||||
|
||||
@@ -525,9 +592,27 @@ async def sync_chat_realtime_messages(
|
||||
|
||||
# Insert older -> newer to keep sqlite btree locality similar to existing data.
|
||||
values = [tuple(r.get(c) for c in insert_cols) for r in reversed(new_rows)]
|
||||
insert_t0 = time.perf_counter()
|
||||
msg_conn.executemany(insert_sql, values)
|
||||
msg_conn.commit()
|
||||
insert_ms = (time.perf_counter() - insert_t0) * 1000.0
|
||||
inserted = len(new_rows)
|
||||
logger.info(
|
||||
"[%s] sqlite insert done account=%s username=%s inserted=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
int(inserted),
|
||||
insert_ms,
|
||||
)
|
||||
if insert_ms > 1000:
|
||||
logger.warning(
|
||||
"[%s] sqlite insert slow account=%s username=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
insert_ms,
|
||||
)
|
||||
|
||||
if ("packed_info_data" in insert_cols) and backfill_rows:
|
||||
update_values = []
|
||||
@@ -538,12 +623,30 @@ async def sync_chat_realtime_messages(
|
||||
update_values.append((pdata, int(r.get("local_id") or 0)))
|
||||
if update_values:
|
||||
before_changes = msg_conn.total_changes
|
||||
update_t0 = time.perf_counter()
|
||||
msg_conn.executemany(
|
||||
f"UPDATE {quoted_table} SET packed_info_data = ? WHERE local_id = ? AND (packed_info_data IS NULL OR length(packed_info_data) = 0)",
|
||||
update_values,
|
||||
)
|
||||
msg_conn.commit()
|
||||
update_ms = (time.perf_counter() - update_t0) * 1000.0
|
||||
backfilled = int(msg_conn.total_changes - before_changes)
|
||||
logger.info(
|
||||
"[%s] sqlite backfill done account=%s username=%s rows=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
int(backfilled),
|
||||
update_ms,
|
||||
)
|
||||
if update_ms > 1000:
|
||||
logger.warning(
|
||||
"[%s] sqlite backfill slow account=%s username=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
update_ms,
|
||||
)
|
||||
|
||||
# Update session.db so left sidebar ordering/time can follow new messages.
|
||||
newest = new_rows[0] if new_rows else None
|
||||
@@ -635,6 +738,16 @@ async def sync_chat_realtime_messages(
|
||||
finally:
|
||||
sconn.close()
|
||||
|
||||
logger.info(
|
||||
"[%s] realtime sync done account=%s username=%s scanned=%s inserted=%s backfilled=%s maxLocalIdBefore=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
username,
|
||||
int(scanned),
|
||||
int(inserted),
|
||||
int(backfilled),
|
||||
int(max_local_id),
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
@@ -749,8 +862,31 @@ def _sync_chat_realtime_messages_for_table(
|
||||
|
||||
while scanned < int(max_scan):
|
||||
take = min(batch_size, int(max_scan) - scanned)
|
||||
logger.info(
|
||||
"[realtime] wcdb_get_messages account=%s username=%s take=%s offset=%s",
|
||||
account_dir.name,
|
||||
username,
|
||||
int(take),
|
||||
int(offset),
|
||||
)
|
||||
wcdb_t0 = time.perf_counter()
|
||||
with rt_conn.lock:
|
||||
raw_rows = _wcdb_get_messages(rt_conn.handle, username, limit=take, offset=offset)
|
||||
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
|
||||
logger.info(
|
||||
"[realtime] wcdb_get_messages done account=%s username=%s rows=%s ms=%.1f",
|
||||
account_dir.name,
|
||||
username,
|
||||
len(raw_rows or []),
|
||||
wcdb_ms,
|
||||
)
|
||||
if wcdb_ms > 2000:
|
||||
logger.warning(
|
||||
"[realtime] wcdb_get_messages slow account=%s username=%s ms=%.1f",
|
||||
account_dir.name,
|
||||
username,
|
||||
wcdb_ms,
|
||||
)
|
||||
if not raw_rows:
|
||||
break
|
||||
|
||||
@@ -815,9 +951,25 @@ def _sync_chat_realtime_messages_for_table(
|
||||
continue
|
||||
|
||||
values = [tuple(r.get(c) for c in insert_cols) for r in reversed(new_rows)]
|
||||
insert_t0 = time.perf_counter()
|
||||
msg_conn.executemany(insert_sql, values)
|
||||
msg_conn.commit()
|
||||
insert_ms = (time.perf_counter() - insert_t0) * 1000.0
|
||||
inserted = len(new_rows)
|
||||
logger.info(
|
||||
"[realtime] sqlite insert done account=%s username=%s inserted=%s ms=%.1f",
|
||||
account_dir.name,
|
||||
username,
|
||||
int(inserted),
|
||||
insert_ms,
|
||||
)
|
||||
if insert_ms > 1000:
|
||||
logger.warning(
|
||||
"[realtime] sqlite insert slow account=%s username=%s ms=%.1f",
|
||||
account_dir.name,
|
||||
username,
|
||||
insert_ms,
|
||||
)
|
||||
|
||||
if ("packed_info_data" in insert_cols) and backfill_rows:
|
||||
update_values = []
|
||||
@@ -828,12 +980,28 @@ def _sync_chat_realtime_messages_for_table(
|
||||
update_values.append((pdata, int(r.get("local_id") or 0)))
|
||||
if update_values:
|
||||
before_changes = msg_conn.total_changes
|
||||
update_t0 = time.perf_counter()
|
||||
msg_conn.executemany(
|
||||
f"UPDATE {quoted_table} SET packed_info_data = ? WHERE local_id = ? AND (packed_info_data IS NULL OR length(packed_info_data) = 0)",
|
||||
update_values,
|
||||
)
|
||||
msg_conn.commit()
|
||||
update_ms = (time.perf_counter() - update_t0) * 1000.0
|
||||
backfilled = int(msg_conn.total_changes - before_changes)
|
||||
logger.info(
|
||||
"[realtime] sqlite backfill done account=%s username=%s rows=%s ms=%.1f",
|
||||
account_dir.name,
|
||||
username,
|
||||
int(backfilled),
|
||||
update_ms,
|
||||
)
|
||||
if update_ms > 1000:
|
||||
logger.warning(
|
||||
"[realtime] sqlite backfill slow account=%s username=%s ms=%.1f",
|
||||
account_dir.name,
|
||||
username,
|
||||
update_ms,
|
||||
)
|
||||
|
||||
newest = new_rows[0] if new_rows else None
|
||||
preview = ""
|
||||
@@ -937,7 +1105,7 @@ def _sync_chat_realtime_messages_for_table(
|
||||
|
||||
|
||||
@router.post("/api/chat/realtime/sync_all", summary="实时消息同步到解密库(全会话增量)")
|
||||
async def sync_chat_realtime_messages_all(
|
||||
def sync_chat_realtime_messages_all(
|
||||
request: Request,
|
||||
account: Optional[str] = None,
|
||||
max_scan: int = 200,
|
||||
@@ -952,6 +1120,16 @@ async def sync_chat_realtime_messages_all(
|
||||
说明:这是增量同步,不会每次全表扫描;priority_username 会优先同步并可设置更大的 priority_max_scan。
|
||||
"""
|
||||
account_dir = _resolve_account_dir(account)
|
||||
trace_id = f"rt-syncall-{int(time.time() * 1000)}-{threading.get_ident()}"
|
||||
logger.info(
|
||||
"[%s] realtime sync_all start account=%s max_scan=%s priority=%s include_hidden=%s include_official=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
int(max_scan),
|
||||
str(priority_username or "").strip(),
|
||||
bool(include_hidden),
|
||||
bool(include_official),
|
||||
)
|
||||
|
||||
if max_scan < 20:
|
||||
max_scan = 20
|
||||
@@ -965,15 +1143,29 @@ async def sync_chat_realtime_messages_all(
|
||||
priority = str(priority_username or "").strip()
|
||||
started = time.time()
|
||||
|
||||
logger.info("[%s] acquiring global sync lock account=%s", trace_id, account_dir.name)
|
||||
with _realtime_sync_all_lock(account_dir.name):
|
||||
logger.info("[%s] global sync lock acquired account=%s", trace_id, account_dir.name)
|
||||
try:
|
||||
logger.info("[%s] ensure wcdb connected account=%s", trace_id, account_dir.name)
|
||||
rt_conn = WCDB_REALTIME.ensure_connected(account_dir)
|
||||
logger.info("[%s] wcdb connected account=%s handle=%s", trace_id, account_dir.name, int(rt_conn.handle))
|
||||
except WCDBRealtimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
try:
|
||||
logger.info("[%s] wcdb_get_sessions account=%s", trace_id, account_dir.name)
|
||||
wcdb_t0 = time.perf_counter()
|
||||
with rt_conn.lock:
|
||||
raw_sessions = _wcdb_get_sessions(rt_conn.handle)
|
||||
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
|
||||
logger.info(
|
||||
"[%s] wcdb_get_sessions done account=%s sessions=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
len(raw_sessions or []),
|
||||
wcdb_ms,
|
||||
)
|
||||
except Exception:
|
||||
raw_sessions = []
|
||||
|
||||
@@ -1017,6 +1209,13 @@ async def sync_chat_realtime_messages_all(
|
||||
sessions = _dedupe(sessions)
|
||||
sessions.sort(key=lambda x: int(x[0] or 0), reverse=True)
|
||||
all_usernames = [u for _, u in sessions if u]
|
||||
logger.info(
|
||||
"[%s] sessions prepared account=%s raw=%s filtered=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
len(raw_sessions or []),
|
||||
len(all_usernames),
|
||||
)
|
||||
|
||||
# Skip sessions whose decrypted session.db already has a newer/equal sort_timestamp.
|
||||
decrypted_ts_by_user: dict[str, int] = {}
|
||||
@@ -1079,10 +1278,25 @@ async def sync_chat_realtime_messages_all(
|
||||
continue
|
||||
sync_usernames.append(u)
|
||||
|
||||
logger.info(
|
||||
"[%s] sessions need_sync account=%s need_sync=%s skipped_up_to_date=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
len(sync_usernames),
|
||||
int(skipped_up_to_date),
|
||||
)
|
||||
|
||||
if priority and priority in sync_usernames:
|
||||
sync_usernames = [priority] + [u for u in sync_usernames if u != priority]
|
||||
|
||||
table_map = _resolve_decrypted_message_tables(account_dir, sync_usernames)
|
||||
logger.info(
|
||||
"[%s] resolved decrypted tables account=%s resolved=%s need_sync=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
len(table_map),
|
||||
len(sync_usernames),
|
||||
)
|
||||
|
||||
scanned_total = 0
|
||||
inserted_total = 0
|
||||
@@ -1115,17 +1329,50 @@ async def sync_chat_realtime_messages_all(
|
||||
inserted_total += ins
|
||||
if ins:
|
||||
updated_sessions += 1
|
||||
logger.info(
|
||||
"[%s] synced session account=%s username=%s inserted=%s scanned=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
uname,
|
||||
ins,
|
||||
int(result.get("scanned") or 0),
|
||||
)
|
||||
except HTTPException as e:
|
||||
errors.append(f"{uname}: {str(e.detail or '')}".strip())
|
||||
logger.warning(
|
||||
"[%s] sync session failed account=%s username=%s err=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
uname,
|
||||
str(e.detail or "").strip(),
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
errors.append(f"{uname}: {str(e)}".strip())
|
||||
logger.exception(
|
||||
"[%s] sync session crashed account=%s username=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
uname,
|
||||
)
|
||||
continue
|
||||
|
||||
elapsed_ms = int((time.time() - started) * 1000)
|
||||
if len(errors) > 20:
|
||||
errors = errors[:20] + [f"... and {len(errors) - 20} more"]
|
||||
|
||||
logger.info(
|
||||
"[%s] realtime sync_all done account=%s sessions_total=%s need_sync=%s synced=%s updated=%s inserted_total=%s elapsed_ms=%s errors=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
len(all_usernames),
|
||||
len(sync_usernames),
|
||||
int(synced),
|
||||
int(updated_sessions),
|
||||
int(inserted_total),
|
||||
int(elapsed_ms),
|
||||
len(errors),
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
@@ -1519,6 +1766,8 @@ def _append_full_messages_from_rows(
|
||||
content_text = raw_text
|
||||
title = ""
|
||||
url = ""
|
||||
from_name = ""
|
||||
from_username = ""
|
||||
record_item = ""
|
||||
image_md5 = ""
|
||||
emoji_md5 = ""
|
||||
@@ -1561,6 +1810,8 @@ def _append_full_messages_from_rows(
|
||||
content_text = str(parsed.get("content") or "")
|
||||
title = str(parsed.get("title") or "")
|
||||
url = str(parsed.get("url") or "")
|
||||
from_name = str(parsed.get("from") or "")
|
||||
from_username = str(parsed.get("fromUsername") or "")
|
||||
record_item = str(parsed.get("recordItem") or "")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
@@ -1781,6 +2032,7 @@ def _append_full_messages_from_rows(
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
from_name = str(parsed.get("from") or from_name)
|
||||
file_size = str(parsed.get("size") or file_size)
|
||||
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
|
||||
file_md5 = str(parsed.get("fileMd5") or file_md5)
|
||||
@@ -1828,6 +2080,8 @@ def _append_full_messages_from_rows(
|
||||
"content": content_text,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
"imageMd5": image_md5,
|
||||
"imageFileId": image_file_id,
|
||||
@@ -1949,13 +2203,42 @@ def _postprocess_full_messages(
|
||||
is_sent = m.get("isSent", False)
|
||||
m["transferStatus"] = "已收款" if is_sent else "已被接收"
|
||||
|
||||
# Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername).
|
||||
# Recover `fromUsername` via contact.db so the frontend can render the publisher avatar.
|
||||
missing_from_names = [
|
||||
str(m.get("from") or "").strip()
|
||||
for m in merged
|
||||
if str(m.get("renderType") or "").strip() == "link"
|
||||
and str(m.get("from") or "").strip()
|
||||
and not str(m.get("fromUsername") or "").strip()
|
||||
]
|
||||
if missing_from_names:
|
||||
name_to_username = _load_usernames_by_display_names(contact_db_path, missing_from_names)
|
||||
if name_to_username:
|
||||
for m in merged:
|
||||
if str(m.get("fromUsername") or "").strip():
|
||||
continue
|
||||
if str(m.get("renderType") or "").strip() != "link":
|
||||
continue
|
||||
fn = str(m.get("from") or "").strip()
|
||||
if fn and fn in name_to_username:
|
||||
m["fromUsername"] = name_to_username[fn]
|
||||
|
||||
from_usernames = [str(m.get("fromUsername") or "").strip() for m in merged]
|
||||
uniq_senders = list(
|
||||
dict.fromkeys([u for u in (sender_usernames + list(pat_usernames) + quote_usernames) if u])
|
||||
dict.fromkeys([u for u in (sender_usernames + list(pat_usernames) + quote_usernames + from_usernames) if u])
|
||||
)
|
||||
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
|
||||
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
|
||||
|
||||
for m in merged:
|
||||
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
|
||||
if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
|
||||
fu = str(m.get("fromUsername") or "").strip()
|
||||
frow = sender_contact_rows.get(fu)
|
||||
if frow is not None:
|
||||
m["from"] = _pick_display_name(frow, fu)
|
||||
|
||||
su = str(m.get("senderUsername") or "")
|
||||
if not su:
|
||||
continue
|
||||
@@ -2097,7 +2380,7 @@ async def list_chat_accounts():
|
||||
|
||||
|
||||
@router.get("/api/chat/sessions", summary="获取会话列表(聊天左侧列表)")
|
||||
async def list_chat_sessions(
|
||||
def list_chat_sessions(
|
||||
request: Request,
|
||||
account: Optional[str] = None,
|
||||
limit: int = 400,
|
||||
@@ -2120,10 +2403,32 @@ async def list_chat_sessions(
|
||||
|
||||
rows: list[Any]
|
||||
if source_norm == "realtime":
|
||||
trace_id = f"rt-sessions-{int(time.time() * 1000)}-{threading.get_ident()}"
|
||||
logger.info(
|
||||
"[%s] list_sessions realtime start account=%s limit=%s include_hidden=%s include_official=%s preview=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
int(limit),
|
||||
bool(include_hidden),
|
||||
bool(include_official),
|
||||
str(preview or ""),
|
||||
)
|
||||
try:
|
||||
logger.info("[%s] ensure wcdb connected account=%s", trace_id, account_dir.name)
|
||||
conn = WCDB_REALTIME.ensure_connected(account_dir)
|
||||
logger.info("[%s] wcdb connected account=%s handle=%s", trace_id, account_dir.name, int(conn.handle))
|
||||
logger.info("[%s] wcdb_get_sessions account=%s", trace_id, account_dir.name)
|
||||
wcdb_t0 = time.perf_counter()
|
||||
with conn.lock:
|
||||
raw = _wcdb_get_sessions(conn.handle)
|
||||
wcdb_ms = (time.perf_counter() - wcdb_t0) * 1000.0
|
||||
logger.info(
|
||||
"[%s] wcdb_get_sessions done account=%s sessions=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
len(raw or []),
|
||||
wcdb_ms,
|
||||
)
|
||||
except WCDBRealtimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -2156,6 +2461,7 @@ async def list_chat_sessions(
|
||||
|
||||
norm.sort(key=lambda r: _ts(r.get("sort_timestamp")), reverse=True)
|
||||
rows = norm
|
||||
logger.info("[%s] list_sessions realtime normalized account=%s rows=%s", trace_id, account_dir.name, len(rows))
|
||||
else:
|
||||
session_db_path = account_dir / "session.db"
|
||||
sconn = sqlite3.connect(str(session_db_path))
|
||||
@@ -2479,6 +2785,8 @@ def _collect_chat_messages(
|
||||
content_text = raw_text
|
||||
title = ""
|
||||
url = ""
|
||||
from_name = ""
|
||||
from_username = ""
|
||||
record_item = ""
|
||||
image_md5 = ""
|
||||
emoji_md5 = ""
|
||||
@@ -2523,6 +2831,8 @@ def _collect_chat_messages(
|
||||
content_text = str(parsed.get("content") or "")
|
||||
title = str(parsed.get("title") or "")
|
||||
url = str(parsed.get("url") or "")
|
||||
from_name = str(parsed.get("from") or "")
|
||||
from_username = str(parsed.get("fromUsername") or "")
|
||||
record_item = str(parsed.get("recordItem") or "")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
@@ -2725,6 +3035,7 @@ def _collect_chat_messages(
|
||||
content_text = str(parsed.get("content") or content_text)
|
||||
title = str(parsed.get("title") or title)
|
||||
url = str(parsed.get("url") or url)
|
||||
from_name = str(parsed.get("from") or from_name)
|
||||
record_item = str(parsed.get("recordItem") or record_item)
|
||||
quote_title = str(parsed.get("quoteTitle") or quote_title)
|
||||
quote_content = str(parsed.get("quoteContent") or quote_content)
|
||||
@@ -2785,6 +3096,8 @@ def _collect_chat_messages(
|
||||
"content": content_text,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
"imageMd5": image_md5,
|
||||
"imageFileId": image_file_id,
|
||||
@@ -2829,7 +3142,7 @@ def _collect_chat_messages(
|
||||
|
||||
|
||||
@router.get("/api/chat/messages", summary="获取会话消息列表")
|
||||
async def list_chat_messages(
|
||||
def list_chat_messages(
|
||||
request: Request,
|
||||
username: str,
|
||||
account: Optional[str] = None,
|
||||
@@ -3124,6 +3437,8 @@ async def list_chat_messages(
|
||||
content_text = raw_text
|
||||
title = ""
|
||||
url = ""
|
||||
from_name = ""
|
||||
from_username = ""
|
||||
record_item = ""
|
||||
image_md5 = ""
|
||||
emoji_md5 = ""
|
||||
@@ -3168,6 +3483,8 @@ async def list_chat_messages(
|
||||
content_text = str(parsed.get("content") or "")
|
||||
title = str(parsed.get("title") or "")
|
||||
url = str(parsed.get("url") or "")
|
||||
from_name = str(parsed.get("from") or "")
|
||||
from_username = str(parsed.get("fromUsername") or "")
|
||||
record_item = str(parsed.get("recordItem") or "")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
@@ -3366,6 +3683,7 @@ async def list_chat_messages(
|
||||
content_text = str(parsed.get("content") or content_text)
|
||||
title = str(parsed.get("title") or title)
|
||||
url = str(parsed.get("url") or url)
|
||||
from_name = str(parsed.get("from") or from_name)
|
||||
record_item = str(parsed.get("recordItem") or record_item)
|
||||
quote_title = str(parsed.get("quoteTitle") or quote_title)
|
||||
quote_content = str(parsed.get("quoteContent") or quote_content)
|
||||
@@ -3419,6 +3737,8 @@ async def list_chat_messages(
|
||||
"content": content_text,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
"imageMd5": image_md5,
|
||||
"imageFileId": image_file_id,
|
||||
@@ -3546,15 +3866,44 @@ async def list_chat_messages(
|
||||
is_sent = m.get("isSent", False)
|
||||
m["transferStatus"] = "已收款" if is_sent else "已被接收"
|
||||
|
||||
# Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername).
|
||||
# Recover `fromUsername` via contact.db so the frontend can render the publisher avatar.
|
||||
missing_from_names = [
|
||||
str(m.get("from") or "").strip()
|
||||
for m in merged
|
||||
if str(m.get("renderType") or "").strip() == "link"
|
||||
and str(m.get("from") or "").strip()
|
||||
and not str(m.get("fromUsername") or "").strip()
|
||||
]
|
||||
if missing_from_names:
|
||||
name_to_username = _load_usernames_by_display_names(contact_db_path, missing_from_names)
|
||||
if name_to_username:
|
||||
for m in merged:
|
||||
if str(m.get("fromUsername") or "").strip():
|
||||
continue
|
||||
if str(m.get("renderType") or "").strip() != "link":
|
||||
continue
|
||||
fn = str(m.get("from") or "").strip()
|
||||
if fn and fn in name_to_username:
|
||||
m["fromUsername"] = name_to_username[fn]
|
||||
|
||||
from_usernames = [str(m.get("fromUsername") or "").strip() for m in merged]
|
||||
uniq_senders = list(
|
||||
dict.fromkeys(
|
||||
[u for u in (sender_usernames + list(pat_usernames) + quote_usernames) if u]
|
||||
[u for u in (sender_usernames + list(pat_usernames) + quote_usernames + from_usernames) if u]
|
||||
)
|
||||
)
|
||||
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
|
||||
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
|
||||
|
||||
for m in merged:
|
||||
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
|
||||
if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
|
||||
fu = str(m.get("fromUsername") or "").strip()
|
||||
frow = sender_contact_rows.get(fu)
|
||||
if frow is not None:
|
||||
m["from"] = _pick_display_name(frow, fu)
|
||||
|
||||
su = str(m.get("senderUsername") or "")
|
||||
if not su:
|
||||
continue
|
||||
|
||||
@@ -408,6 +408,110 @@ def _detect_media_type_and_ext(data: bytes) -> tuple[bytes, str, str]:
|
||||
return payload, media_type, ext
|
||||
|
||||
|
||||
def _is_allowed_proxy_image_host(host: str) -> bool:
|
||||
"""Allowlist hosts for proxying images to avoid turning this into a general SSRF gadget."""
|
||||
h = str(host or "").strip().lower()
|
||||
if not h:
|
||||
return False
|
||||
# WeChat public account/article thumbnails and avatars commonly live on these CDNs.
|
||||
return h.endswith(".qpic.cn") or h.endswith(".qlogo.cn") or h.endswith(".tc.qq.com")
|
||||
|
||||
|
||||
@router.get("/api/chat/media/proxy_image", summary="代理获取远程图片(解决微信公众号图片防盗链)")
|
||||
async def proxy_image(url: str):
|
||||
u = html.unescape(str(url or "")).strip()
|
||||
if not u:
|
||||
raise HTTPException(status_code=400, detail="Missing url.")
|
||||
if not _is_safe_http_url(u):
|
||||
raise HTTPException(status_code=400, detail="Invalid url (only public http/https allowed).")
|
||||
|
||||
try:
|
||||
p = urlparse(u)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid url.")
|
||||
|
||||
host = (p.hostname or "").strip().lower()
|
||||
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]:
|
||||
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",
|
||||
}
|
||||
|
||||
# Different Tencent CDNs enforce different anti-hotlink rules.
|
||||
# Try a couple of safe referers so Moments(qpic) and MP(qpic) both work.
|
||||
header_variants = [
|
||||
{"Referer": "https://wx.qq.com/", "Origin": "https://wx.qq.com"},
|
||||
{"Referer": "https://mp.weixin.qq.com/", "Origin": "https://mp.weixin.qq.com"},
|
||||
{"Referer": "https://www.baidu.com/", "Origin": "https://www.baidu.com"},
|
||||
{},
|
||||
]
|
||||
|
||||
last_err: Exception | None = None
|
||||
for extra in header_variants:
|
||||
headers = dict(base_headers)
|
||||
headers.update(extra)
|
||||
r = requests.get(u, headers=headers, timeout=20, stream=True)
|
||||
try:
|
||||
r.raise_for_status()
|
||||
content_type = str(r.headers.get("Content-Type") or "").strip()
|
||||
max_bytes = 10 * 1024 * 1024
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
for ch in r.iter_content(chunk_size=64 * 1024):
|
||||
if not ch:
|
||||
continue
|
||||
chunks.append(ch)
|
||||
total += len(ch)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=400, detail="Proxy image too large (>10MB).")
|
||||
return b"".join(chunks), content_type
|
||||
except HTTPException:
|
||||
# Hard failure, don't retry with another referer.
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
finally:
|
||||
try:
|
||||
r.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# All variants failed.
|
||||
raise last_err or RuntimeError("proxy_image download failed")
|
||||
|
||||
try:
|
||||
data, ct = await asyncio.to_thread(_download_bytes)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"proxy_image failed: url={u} err={e}")
|
||||
raise HTTPException(status_code=502, detail=f"Proxy image failed: {e}")
|
||||
|
||||
if not data:
|
||||
raise HTTPException(status_code=502, detail="Proxy returned empty body.")
|
||||
|
||||
payload, media_type, _ext = _detect_media_type_and_ext(data)
|
||||
|
||||
# Prefer upstream Content-Type when it looks like an image (sniffing may fail for some formats).
|
||||
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 not str(media_type or "").startswith("image/"):
|
||||
raise HTTPException(status_code=502, detail="Proxy did not return an image.")
|
||||
|
||||
resp = Response(content=payload, media_type=media_type)
|
||||
resp.headers["Cache-Control"] = "public, max-age=86400"
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/api/chat/media/emoji/download", summary="下载表情消息资源到本地 resource")
|
||||
async def download_chat_emoji(req: EmojiDownloadRequest):
|
||||
md5 = str(req.md5 or "").strip().lower()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,13 @@ def _load_wcdb_lib() -> ctypes.CDLL:
|
||||
lib.wcdb_close_account.argtypes = [ctypes.c_int64]
|
||||
lib.wcdb_close_account.restype = ctypes.c_int
|
||||
|
||||
# Optional: wcdb_set_my_wxid(handle, wxid)
|
||||
try:
|
||||
lib.wcdb_set_my_wxid.argtypes = [ctypes.c_int64, ctypes.c_char_p]
|
||||
lib.wcdb_set_my_wxid.restype = ctypes.c_int
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lib.wcdb_get_sessions.argtypes = [ctypes.c_int64, ctypes.POINTER(ctypes.c_char_p)]
|
||||
lib.wcdb_get_sessions.restype = ctypes.c_int
|
||||
|
||||
@@ -95,6 +102,37 @@ def _load_wcdb_lib() -> ctypes.CDLL:
|
||||
lib.wcdb_get_group_members.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p)]
|
||||
lib.wcdb_get_group_members.restype = ctypes.c_int
|
||||
|
||||
# Optional: execute arbitrary SQL on a selected database kind/path.
|
||||
# Signature: wcdb_exec_query(handle, kind, path, sql, out_json)
|
||||
try:
|
||||
lib.wcdb_exec_query.argtypes = [
|
||||
ctypes.c_int64,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.POINTER(ctypes.c_char_p),
|
||||
]
|
||||
lib.wcdb_exec_query.restype = ctypes.c_int
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Optional (newer DLLs): wcdb_get_sns_timeline(handle, limit, offset, usernames_json, keyword, start_time, end_time, out_json)
|
||||
try:
|
||||
lib.wcdb_get_sns_timeline.argtypes = [
|
||||
ctypes.c_int64,
|
||||
ctypes.c_int32,
|
||||
ctypes.c_int32,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_int32,
|
||||
ctypes.c_int32,
|
||||
ctypes.POINTER(ctypes.c_char_p),
|
||||
]
|
||||
lib.wcdb_get_sns_timeline.restype = ctypes.c_int
|
||||
except Exception:
|
||||
# Older wcdb_api.dll may not expose this export.
|
||||
pass
|
||||
|
||||
lib.wcdb_get_logs.argtypes = [ctypes.POINTER(ctypes.c_char_p)]
|
||||
lib.wcdb_get_logs.restype = ctypes.c_int
|
||||
|
||||
@@ -195,6 +233,30 @@ def open_account(session_db_path: Path, key_hex: str) -> int:
|
||||
return int(out_handle.value)
|
||||
|
||||
|
||||
def set_my_wxid(handle: int, wxid: str) -> bool:
|
||||
"""Best-effort set the "my wxid" context for some WCDB APIs."""
|
||||
try:
|
||||
_ensure_initialized()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
lib = _load_wcdb_lib()
|
||||
fn = getattr(lib, "wcdb_set_my_wxid", None)
|
||||
if not fn:
|
||||
return False
|
||||
|
||||
w = str(wxid or "").strip()
|
||||
if not w:
|
||||
return False
|
||||
|
||||
try:
|
||||
rc = int(fn(ctypes.c_int64(int(handle)), w.encode("utf-8")))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return rc == 0
|
||||
|
||||
|
||||
def close_account(handle: int) -> None:
|
||||
try:
|
||||
h = int(handle)
|
||||
@@ -293,6 +355,93 @@ def get_avatar_urls(handle: int, usernames: list[str]) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
def exec_query(handle: int, *, kind: str, path: Optional[str], sql: str) -> list[dict[str, Any]]:
|
||||
"""Execute raw SQL on a specific db kind/path via WCDB.
|
||||
|
||||
This is primarily used for SNS/other dbs that are not directly exposed by dedicated APIs.
|
||||
"""
|
||||
_ensure_initialized()
|
||||
lib = _load_wcdb_lib()
|
||||
fn = getattr(lib, "wcdb_exec_query", None)
|
||||
if not fn:
|
||||
raise WCDBRealtimeError("Current wcdb_api.dll does not support exec_query.")
|
||||
|
||||
k = str(kind or "").strip()
|
||||
if not k:
|
||||
raise WCDBRealtimeError("Missing kind for exec_query.")
|
||||
|
||||
s = str(sql or "").strip()
|
||||
if not s:
|
||||
return []
|
||||
|
||||
p = None if path is None else str(path or "").strip()
|
||||
|
||||
out_json = _call_out_json(
|
||||
fn,
|
||||
ctypes.c_int64(int(handle)),
|
||||
k.encode("utf-8"),
|
||||
None if p is None else p.encode("utf-8"),
|
||||
s.encode("utf-8"),
|
||||
)
|
||||
decoded = _safe_load_json(out_json)
|
||||
if isinstance(decoded, list):
|
||||
out: list[dict[str, Any]] = []
|
||||
for x in decoded:
|
||||
if isinstance(x, dict):
|
||||
out.append(x)
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def get_sns_timeline(
|
||||
handle: int,
|
||||
*,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
usernames: Optional[list[str]] = None,
|
||||
keyword: str | None = None,
|
||||
start_time: int = 0,
|
||||
end_time: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Read Moments (SnsTimeLine) from the live encrypted db_storage via WCDB.
|
||||
|
||||
Requires a newer wcdb_api.dll export: wcdb_get_sns_timeline.
|
||||
"""
|
||||
_ensure_initialized()
|
||||
lib = _load_wcdb_lib()
|
||||
fn = getattr(lib, "wcdb_get_sns_timeline", None)
|
||||
if not fn:
|
||||
raise WCDBRealtimeError("Current wcdb_api.dll does not support sns timeline.")
|
||||
|
||||
lim = max(0, int(limit or 0))
|
||||
off = max(0, int(offset or 0))
|
||||
|
||||
users = [str(u or "").strip() for u in (usernames or []) if str(u or "").strip()]
|
||||
users = list(dict.fromkeys(users))
|
||||
users_json = json.dumps(users, ensure_ascii=False) if users else ""
|
||||
|
||||
kw = str(keyword or "").strip()
|
||||
|
||||
payload = _call_out_json(
|
||||
fn,
|
||||
ctypes.c_int64(int(handle)),
|
||||
ctypes.c_int32(lim),
|
||||
ctypes.c_int32(off),
|
||||
users_json.encode("utf-8"),
|
||||
kw.encode("utf-8"),
|
||||
ctypes.c_int32(int(start_time or 0)),
|
||||
ctypes.c_int32(int(end_time or 0)),
|
||||
)
|
||||
decoded = _safe_load_json(payload)
|
||||
if isinstance(decoded, list):
|
||||
out: list[dict[str, Any]] = []
|
||||
for x in decoded:
|
||||
if isinstance(x, dict):
|
||||
out.append(x)
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
global _initialized
|
||||
lib = _load_wcdb_lib()
|
||||
@@ -427,6 +576,11 @@ class WCDBRealtimeManager:
|
||||
|
||||
session_db_path = _resolve_session_db_path(db_storage_dir)
|
||||
handle = open_account(session_db_path, key)
|
||||
# Some WCDB APIs (e.g. exec_query on non-session DBs) may require this context.
|
||||
try:
|
||||
set_my_wxid(handle, account)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn = WCDBRealtimeConnection(
|
||||
account=account,
|
||||
|
||||
Reference in New Issue
Block a user