mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
13 Commits
@@ -159,6 +159,40 @@ npm run dev
|
||||
- API文档(默认): http://localhost:10392/docs
|
||||
- 也可在应用内“设置 -> 后端端口”修改(支持“恢复默认”一键回到 10392):网页端会尝试重启本机后端到新端口并刷新(并写入 `output/runtime_settings.json`,开发模式下也会写入项目根目录 `.env` 供 `uv run` 下次启动使用);桌面端会重启内置后端并刷新
|
||||
|
||||
## MCP 服务
|
||||
|
||||
后端提供 MCP JSON-RPC over HTTP 服务,默认只监听 `127.0.0.1`。手机接入局域网时,在应用内打开 **设置 -> MCP 接入 -> 允许手机局域网接入 MCP**,后端会切换为监听 `0.0.0.0` 并重启;设置页展示和复制的接入地址会使用电脑实际局域网 IP,例如 `http://192.168.x.x:10392/mcp`,而不是不可被其他设备访问的 `127.0.0.1`。
|
||||
|
||||
MCP 入口需要 token 鉴权。设置页提供 **MCP Token**、**AI 接入提示词** 和 **Skill Markdown** 三个独立复制区;token 可一键复制或重置,重置后旧 token 立即失效。手机端或外部 AI 客户端访问 `/mcp`、`/mcp/skill/bundle`、`/mcp/skill` 时,都应带上 `Authorization: Bearer <MCP_TOKEN>`。兼容客户端也可以使用 `X-MCP-Token` 请求头或 `?token=` 查询参数,但推荐使用 Bearer token。
|
||||
|
||||
通用客户端可以通过 `GET /mcp/skill/bundle` 读取同一份 bundle,通过 `GET /mcp/skill` 读取 markdown 版本,这两个 skill 入口同样需要 MCP token。
|
||||
|
||||
工具调用成功时,客户端优先读取 `result.structuredContent`,`content[0].text` 只是给通用 MCP 客户端展示的 JSON 文本副本。业务未就绪时仍可能返回 JSON-RPC success,但 `result.isError=true`;协议错误或参数错误则返回 JSON-RPC `error`。
|
||||
|
||||
MCP 仅暴露读取数据与获取媒体资源 URL/参数的能力;系统设置、索引与缓存构建、数据准备、导出、实时同步、本地修订、数据删除等操作类能力不通过 MCP 暴露,请在桌面/网页应用内使用。
|
||||
|
||||
工具按包分层:
|
||||
|
||||
- `wechat.core`: 状态、工具目录、账号列表、账号信息
|
||||
- `wechat.mobile`: 面向手机和外部代理的聚合入口,默认返回小结果和下一步建议
|
||||
- `wechat.contacts`: 联系人列表、模糊解析
|
||||
- `wechat.chat`: 会话、消息、搜索、发送者筛选、上下文、锚点、合并转发/AppMsg 解析、统计
|
||||
- `wechat.moments`: 朋友圈时间线、用户、图片/视频/文章封面 URL
|
||||
- `wechat.media`: 聊天/朋友圈图片、视频、表情、头像、语音文件 URL、远程图片代理与资源辅助;只返回 URL 或资源参数,不提供下载缓存或打开本机目录操作
|
||||
- `wechat.biz`: 公众号/服务号与微信支付记录
|
||||
- `wechat.analytics`: 年度总结与聚合分析读取;年度总结只读取应用内已生成的缓存,未生成时请先在应用内打开年度总结
|
||||
|
||||
会话列表、联系人和头像相关接口均采用 best-effort 读取策略。即使 `contact.db` 中某些头像字段损坏或无法按 UTF-8 解码,也会继续返回昵称、会话摘要和其他可用内容,头像则自动降级为空或占位,不会阻塞整页数据加载。
|
||||
|
||||
媒体和视频不会直接塞进 MCP JSON 响应;相关工具返回可访问 URL 或资源参数。
|
||||
|
||||
配套 skill 可通过 HTTP 加载,访问时需要带 MCP token:
|
||||
|
||||
- JSON bundle: `http://<电脑局域网IP>:10392/mcp/skill/bundle`
|
||||
- Markdown bundle: `http://<电脑局域网IP>:10392/mcp/skill`
|
||||
|
||||
手机端或外部 AI 客户端应先拉取 skill bundle,将 `bundleText` 注入模型上下文,再按 `initialize`、`tools/list`、`tools/call` 使用 MCP。设置页中的“AI 接入提示词”会包含 endpoint 和 Bearer token,可直接复制给客户端作为接入指令。
|
||||
|
||||
## 打包为 EXE(Windows 桌面端)
|
||||
|
||||
本项目提供基于 Electron 的桌面端安装包(NSIS `Setup.exe`)。
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"version": "1.8.1",
|
||||
"version": "1.9.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"version": "1.8.1",
|
||||
"version": "1.9.2",
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.7.3"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"private": true,
|
||||
"version": "1.8.1",
|
||||
"version": "1.9.2",
|
||||
"main": "src/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.cjs",
|
||||
|
||||
+183
-7
@@ -21,6 +21,7 @@ const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
const net = require("net");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { Worker } = require("worker_threads");
|
||||
const {
|
||||
@@ -30,7 +31,8 @@ const {
|
||||
normalizeDirectoryPath,
|
||||
} = require("./output-dir.cjs");
|
||||
|
||||
const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1").trim() || "127.0.0.1";
|
||||
const DEFAULT_BACKEND_HOST = "127.0.0.1";
|
||||
const LAN_BACKEND_HOST = "0.0.0.0";
|
||||
const DEFAULT_BACKEND_PORT = parsePort(process.env.WECHAT_TOOL_PORT) ?? 10392;
|
||||
|
||||
let backendProc = null;
|
||||
@@ -86,7 +88,11 @@ function formatHostForUrl(host) {
|
||||
}
|
||||
|
||||
function getBackendBindHost() {
|
||||
return DEFAULT_BACKEND_HOST;
|
||||
const envHost = String(process.env.WECHAT_TOOL_HOST || "").trim();
|
||||
if (envHost === LAN_BACKEND_HOST || envHost === "::") return LAN_BACKEND_HOST;
|
||||
if (envHost === DEFAULT_BACKEND_HOST || envHost === "localhost" || envHost === "::1") return DEFAULT_BACKEND_HOST;
|
||||
if (!app.isPackaged) return DEFAULT_BACKEND_HOST;
|
||||
return loadDesktopSettings()?.mcpLanAccessEnabled ? LAN_BACKEND_HOST : DEFAULT_BACKEND_HOST;
|
||||
}
|
||||
|
||||
function getBackendAccessHost() {
|
||||
@@ -96,6 +102,84 @@ function getBackendAccessHost() {
|
||||
return host || "127.0.0.1";
|
||||
}
|
||||
|
||||
function getInterfacePenalty(name) {
|
||||
const lower = String(name || "").toLowerCase();
|
||||
if (/(docker|hyper-v|loopback|npcap|tailscale|virtual|virtualbox|vmware|vethernet|wsl|zerotier)/i.test(lower)) {
|
||||
return 30;
|
||||
}
|
||||
if (/(ethernet|wi-fi|wifi|wireless|wlan|以太|无线)/i.test(lower)) {
|
||||
return 0;
|
||||
}
|
||||
return 10;
|
||||
}
|
||||
|
||||
function isReachableClientIpv4(address) {
|
||||
const text = String(address || "").trim();
|
||||
const parts = text.split(".");
|
||||
if (parts.length !== 4) return false;
|
||||
const nums = parts.map((part) => Number(part));
|
||||
if (!nums.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)) return false;
|
||||
if (nums[0] === 0 || nums[0] === 127 || nums[0] >= 224) return false;
|
||||
if (nums[0] === 169 && nums[1] === 254) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isPrivateIpv4(address) {
|
||||
const nums = String(address || "").trim().split(".").map((part) => Number(part));
|
||||
if (nums.length !== 4 || !nums.every((n) => Number.isInteger(n))) return false;
|
||||
return (
|
||||
nums[0] === 10 ||
|
||||
(nums[0] === 172 && nums[1] >= 16 && nums[1] <= 31) ||
|
||||
(nums[0] === 192 && nums[1] === 168)
|
||||
);
|
||||
}
|
||||
|
||||
function getLanAccessHost(defaultHost = DEFAULT_BACKEND_HOST) {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const addCandidate = (address, interfaceName = "", sourceOrder = 0) => {
|
||||
const value = String(address || "").trim();
|
||||
if (!isReachableClientIpv4(value) || seen.has(value)) return;
|
||||
seen.add(value);
|
||||
candidates.push([
|
||||
isPrivateIpv4(value) ? 0 : 1,
|
||||
getInterfacePenalty(interfaceName),
|
||||
sourceOrder,
|
||||
value,
|
||||
]);
|
||||
};
|
||||
|
||||
try {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const [name, addresses] of Object.entries(interfaces || {})) {
|
||||
for (const item of addresses || []) {
|
||||
if (!item || (item.family !== "IPv4" && item.family !== 4) || item.internal) continue;
|
||||
addCandidate(item.address, name, 0);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
candidates.sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]);
|
||||
return candidates[0]?.[3] || defaultHost;
|
||||
}
|
||||
|
||||
function getMcpAccessHost(bindHost = getBackendBindHost()) {
|
||||
const host = String(bindHost || "").trim();
|
||||
if (host === LAN_BACKEND_HOST || host === "::") return getLanAccessHost(DEFAULT_BACKEND_HOST);
|
||||
return host || DEFAULT_BACKEND_HOST;
|
||||
}
|
||||
|
||||
function getMcpAccessInfo(bindHost = getBackendBindHost(), port = getBackendPort()) {
|
||||
const accessHost = getMcpAccessHost(bindHost);
|
||||
const origin = `http://${formatHostForUrl(accessHost)}:${port}`;
|
||||
return {
|
||||
accessHost,
|
||||
mcpEndpoint: `${origin}/mcp`,
|
||||
skillBundleUrl: `${origin}/mcp/skill/bundle`,
|
||||
skillMarkdownUrl: `${origin}/mcp/skill`,
|
||||
};
|
||||
}
|
||||
|
||||
function getBackendPort() {
|
||||
const envPort = parsePort(process.env.WECHAT_TOOL_PORT);
|
||||
if (envPort != null) return envPort;
|
||||
@@ -116,6 +200,18 @@ function setBackendPortSetting(nextPort) {
|
||||
return p;
|
||||
}
|
||||
|
||||
function getMcpLanAccessEnabled() {
|
||||
return getBackendBindHost() === LAN_BACKEND_HOST;
|
||||
}
|
||||
|
||||
function setMcpLanAccessSetting(enabled) {
|
||||
loadDesktopSettings();
|
||||
desktopSettings.mcpLanAccessEnabled = !!enabled;
|
||||
persistDesktopSettings();
|
||||
process.env.WECHAT_TOOL_HOST = desktopSettings.mcpLanAccessEnabled ? LAN_BACKEND_HOST : DEFAULT_BACKEND_HOST;
|
||||
return desktopSettings.mcpLanAccessEnabled;
|
||||
}
|
||||
|
||||
function getBackendHealthUrl() {
|
||||
const host = formatHostForUrl(getBackendAccessHost());
|
||||
const port = getBackendPort();
|
||||
@@ -128,6 +224,12 @@ function getBackendUiUrl() {
|
||||
return `http://${host}:${port}/`;
|
||||
}
|
||||
|
||||
function getDesktopUiUrl() {
|
||||
const explicit = String(process.env.ELECTRON_START_URL || "").trim();
|
||||
if (explicit) return explicit;
|
||||
return app.isPackaged ? getBackendUiUrl() : "http://localhost:3000";
|
||||
}
|
||||
|
||||
function isPortAvailable(port, host) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
@@ -597,6 +699,8 @@ function loadDesktopSettings() {
|
||||
ignoredUpdateVersion: "",
|
||||
// Backend (FastAPI) listens on this port. Used in packaged builds.
|
||||
backendPort: DEFAULT_BACKEND_PORT,
|
||||
// When enabled, the backend binds to 0.0.0.0 so phone clients can reach /mcp.
|
||||
mcpLanAccessEnabled: false,
|
||||
// Custom output dir; empty string means use the default dataDir/output.
|
||||
outputDir: "",
|
||||
// Pending output dir written by the installer before the next app startup.
|
||||
@@ -623,6 +727,7 @@ function loadDesktopSettings() {
|
||||
const parsed = JSON.parse(raw || "{}");
|
||||
desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
|
||||
desktopSettings.backendPort = parsePort(desktopSettings.backendPort) ?? defaults.backendPort;
|
||||
desktopSettings.mcpLanAccessEnabled = !!desktopSettings.mcpLanAccessEnabled;
|
||||
desktopSettings.outputDir = safeNormalizeDirectory(desktopSettings.outputDir || "");
|
||||
desktopSettings.pendingOutputDir =
|
||||
parsed && typeof parsed === "object" && Object.prototype.hasOwnProperty.call(parsed, "pendingOutputDir")
|
||||
@@ -2275,7 +2380,7 @@ function registerWindowIpc() {
|
||||
|
||||
const prevPort = getBackendPort();
|
||||
if (nextPort === prevPort) {
|
||||
return { success: true, changed: false, port: prevPort, uiUrl: getBackendUiUrl() };
|
||||
return { success: true, changed: false, port: prevPort, uiUrl: getDesktopUiUrl() };
|
||||
}
|
||||
|
||||
const bindHost = getBackendBindHost();
|
||||
@@ -2296,7 +2401,7 @@ function registerWindowIpc() {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const uiUrl = getBackendUiUrl();
|
||||
const uiUrl = getDesktopUiUrl();
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
@@ -2312,6 +2417,79 @@ function registerWindowIpc() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("backend:getMcpLanAccess", () => {
|
||||
try {
|
||||
const host = getBackendBindHost();
|
||||
const port = getBackendPort();
|
||||
return {
|
||||
enabled: getMcpLanAccessEnabled(),
|
||||
host,
|
||||
port,
|
||||
uiUrl: getDesktopUiUrl(),
|
||||
...getMcpAccessInfo(host, port),
|
||||
};
|
||||
} catch (err) {
|
||||
logMain(`[main] backend:getMcpLanAccess failed: ${err?.message || err}`);
|
||||
const port = DEFAULT_BACKEND_PORT;
|
||||
return {
|
||||
enabled: false,
|
||||
host: DEFAULT_BACKEND_HOST,
|
||||
port,
|
||||
uiUrl: getDesktopUiUrl(),
|
||||
...getMcpAccessInfo(DEFAULT_BACKEND_HOST, port),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("backend:setMcpLanAccess", async (_event, enabled) => {
|
||||
if (backendPortChangeInProgress) throw new Error("后端切换中,请稍后重试");
|
||||
|
||||
const nextEnabled = !!enabled;
|
||||
const prevEnabled = getMcpLanAccessEnabled();
|
||||
if (nextEnabled === prevEnabled) {
|
||||
const host = getBackendBindHost();
|
||||
const port = getBackendPort();
|
||||
return {
|
||||
success: true,
|
||||
changed: false,
|
||||
enabled: prevEnabled,
|
||||
host,
|
||||
port,
|
||||
uiUrl: getDesktopUiUrl(),
|
||||
...getMcpAccessInfo(host, port),
|
||||
};
|
||||
}
|
||||
|
||||
backendPortChangeInProgress = true;
|
||||
try {
|
||||
setMcpLanAccessSetting(nextEnabled);
|
||||
try {
|
||||
await restartBackend({ timeoutMs: 30_000 });
|
||||
} catch (err) {
|
||||
setMcpLanAccessSetting(prevEnabled);
|
||||
try {
|
||||
await restartBackend({ timeoutMs: 30_000 });
|
||||
} catch {}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const uiUrl = getDesktopUiUrl();
|
||||
logMain(`[main] MCP access changed enabled=${nextEnabled}; backend restarted without UI reload`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
changed: true,
|
||||
enabled: nextEnabled,
|
||||
host: getBackendBindHost(),
|
||||
port: getBackendPort(),
|
||||
uiUrl,
|
||||
...getMcpAccessInfo(),
|
||||
};
|
||||
} finally {
|
||||
backendPortChangeInProgress = false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getVersion", () => {
|
||||
try {
|
||||
return app.getVersion();
|
||||
@@ -2510,9 +2688,7 @@ async function main() {
|
||||
mainWindow = win;
|
||||
ensureTrayForCloseBehavior();
|
||||
|
||||
const startUrl =
|
||||
process.env.ELECTRON_START_URL ||
|
||||
(app.isPackaged ? getBackendUiUrl() : "http://localhost:3000");
|
||||
const startUrl = getDesktopUiUrl();
|
||||
|
||||
logMain(`[main] debugEnabled=${debugEnabled()} startUrl=${startUrl}`);
|
||||
await loadWithRetry(win, startUrl);
|
||||
|
||||
@@ -78,6 +78,8 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
|
||||
getBackendPort: () => ipcRenderer.invoke("backend:getPort"),
|
||||
setBackendPort: (port) => ipcRenderer.invoke("backend:setPort", Number(port)),
|
||||
getMcpLanAccess: () => ipcRenderer.invoke("backend:getMcpLanAccess"),
|
||||
setMcpLanAccess: (enabled) => ipcRenderer.invoke("backend:setMcpLanAccess", !!enabled),
|
||||
|
||||
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
|
||||
|
||||
|
||||
@@ -261,6 +261,105 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section ref="mcpSectionRef">
|
||||
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">MCP 接入</div>
|
||||
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">允许手机局域网接入 MCP</div>
|
||||
<div class="mt-0.5 text-[11px] leading-relaxed text-[#909090]">开启后后端监听 0.0.0.0,手机可通过接入提示词中的地址接入。</div>
|
||||
<div class="mt-0.5 text-[11px] leading-relaxed text-[#909090] break-all">当前地址:{{ mcpEndpoint }}</div>
|
||||
<div v-if="mcpLanAccessMessage" class="mt-1 text-[11px] leading-relaxed text-[#1b6b43]">{{ mcpLanAccessMessage }}</div>
|
||||
<div v-if="mcpLanAccessError" class="mt-1 text-[11px] leading-relaxed text-red-600">{{ mcpLanAccessError }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="mcpLanAccessEnabled"
|
||||
class="settings-switch shrink-0"
|
||||
:class="switchTrackClass(mcpLanAccessEnabled, mcpLanAccessLoading)"
|
||||
:disabled="mcpLanAccessLoading"
|
||||
@click="toggleMcpLanAccess"
|
||||
>
|
||||
<span class="settings-switch-thumb" :class="mcpLanAccessEnabled ? 'translate-x-[20px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">MCP Token</div>
|
||||
<div class="mt-0.5 text-[11px] leading-relaxed text-[#909090]">手机端请求 MCP 时使用 Bearer token。</div>
|
||||
<div v-if="mcpTokenError" class="mt-1 text-[11px] leading-relaxed text-red-600">{{ mcpTokenError }}</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9]"
|
||||
:disabled="mcpTokenLoading || !mcpToken"
|
||||
@click="copyMcpText('token', mcpToken)"
|
||||
>
|
||||
{{ mcpCopiedKey === 'token' ? '已复制' : (mcpTokenLoading ? '加载中...' : '复制 Token') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9]"
|
||||
:disabled="mcpTokenLoading"
|
||||
@click="resetMcpToken"
|
||||
>
|
||||
{{ mcpCopiedKey === 'token-reset' ? '已重置' : '重置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="max-h-[92px] overflow-auto rounded-[6px] bg-[#f7f7f7] px-2.5 py-2 text-[11px] leading-relaxed text-[#333] scrollbar-custom whitespace-pre-wrap">{{ mcpTokenText }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">AI 接入提示词</div>
|
||||
<div class="mt-0.5 text-[11px] leading-relaxed text-[#909090]">复制到手机端 AI 的系统提示词或连接说明里。</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9]"
|
||||
@click="copyMcpText('ai-prompt', mcpAiPrompt)"
|
||||
>
|
||||
{{ mcpCopiedKey === 'ai-prompt' ? '已复制' : '复制提示词' }}
|
||||
</button>
|
||||
</div>
|
||||
<pre class="max-h-[220px] overflow-auto rounded-[6px] bg-[#f7f7f7] px-2.5 py-2 text-[11px] leading-relaxed text-[#333] scrollbar-custom whitespace-pre-wrap">{{ mcpAiPrompt }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">Skill Markdown</div>
|
||||
<div class="mt-0.5 text-[11px] leading-relaxed text-[#909090]">单独复制到手机端 AI 的 skill 或知识配置。</div>
|
||||
<div v-if="mcpSkillBundleError" class="mt-1 text-[11px] leading-relaxed text-red-600">{{ mcpSkillBundleError }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9]"
|
||||
:disabled="mcpSkillBundleLoading"
|
||||
@click="copyMcpText('skill', mcpSkillText)"
|
||||
>
|
||||
{{ mcpCopiedKey === 'skill' ? '已复制' : (mcpSkillBundleLoading ? '加载中...' : '复制 Skill') }}
|
||||
</button>
|
||||
</div>
|
||||
<pre class="max-h-[420px] overflow-auto rounded-[6px] bg-[#f7f7f7] px-2.5 py-2 text-[11px] leading-relaxed text-[#333] scrollbar-custom whitespace-pre-wrap">{{ mcpSkillText }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section ref="startupSectionRef">
|
||||
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">启动偏好</div>
|
||||
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
|
||||
@@ -376,6 +475,7 @@ const emit = defineEmits(['close'])
|
||||
|
||||
const settingNavItems = [
|
||||
{ key: 'desktop', label: '桌面行为', hint: '启动 / 关闭 / 端口' },
|
||||
{ key: 'mcp', label: 'MCP 接入', hint: '手机 / Skill / 工具' },
|
||||
{ key: 'startup', label: '启动偏好', hint: '自动实时 / 默认页面' },
|
||||
{ key: 'updates', label: '更新', hint: '版本信息 / 检查更新' },
|
||||
{ key: 'sns', label: '朋友圈', hint: '图片缓存策略' },
|
||||
@@ -384,6 +484,7 @@ const settingNavItems = [
|
||||
const activeSection = ref(settingNavItems[0].key)
|
||||
const contentScrollRef = ref(null)
|
||||
const desktopSectionRef = ref(null)
|
||||
const mcpSectionRef = ref(null)
|
||||
const startupSectionRef = ref(null)
|
||||
const updatesSectionRef = ref(null)
|
||||
const snsSectionRef = ref(null)
|
||||
@@ -497,6 +598,79 @@ const desktopLogFileText = computed(() => {
|
||||
return v || '—'
|
||||
})
|
||||
|
||||
const mcpLanAccessEnabled = ref(false)
|
||||
const mcpLanAccessLoading = ref(false)
|
||||
const mcpLanAccessError = ref('')
|
||||
const mcpLanAccessMessage = ref('')
|
||||
const mcpToken = ref('')
|
||||
const mcpTokenLoading = ref(false)
|
||||
const mcpTokenError = ref('')
|
||||
const mcpSkillBundleText = ref('')
|
||||
const mcpSkillBundleLoading = ref(false)
|
||||
const mcpSkillBundleError = ref('')
|
||||
const mcpCopiedKey = ref('')
|
||||
const mcpAccessHost = ref('')
|
||||
const mcpAccessEndpoint = ref('')
|
||||
let mcpCopiedTimer = null
|
||||
|
||||
const mcpPortText = computed(() => {
|
||||
const n = Number(String(desktopBackendPortInput.value || '').trim())
|
||||
if (Number.isInteger(n) && n >= 1 && n <= 65535) return String(n)
|
||||
return '10392'
|
||||
})
|
||||
|
||||
const mcpEndpoint = computed(() => {
|
||||
const reported = String(mcpAccessEndpoint.value || '').trim()
|
||||
if (/^https?:\/\//i.test(reported)) return reported
|
||||
const reportedHost = String(mcpAccessHost.value || '').trim()
|
||||
if (reportedHost) return `http://${reportedHost}:${mcpPortText.value}/mcp`
|
||||
if (!process.client || typeof window === 'undefined') return `http://127.0.0.1:${mcpPortText.value}/mcp`
|
||||
const apiBase = useApiBase()
|
||||
if (/^https?:\/\//i.test(apiBase)) {
|
||||
try {
|
||||
const u = new URL(apiBase)
|
||||
return `${u.origin}/mcp`
|
||||
} catch {}
|
||||
}
|
||||
const protocol = window.location?.protocol === 'https:' ? 'https:' : 'http:'
|
||||
const host = String(window.location?.hostname || '127.0.0.1').trim() || '127.0.0.1'
|
||||
return `${protocol}//${host}:${mcpPortText.value}/mcp`
|
||||
})
|
||||
|
||||
const applyMcpAccessInfo = (resp) => {
|
||||
if (!resp || typeof resp !== 'object') return
|
||||
const accessHost = String(resp.accessHost || resp.access_host || '').trim()
|
||||
const endpoint = String(resp.mcpEndpoint || resp.mcp_endpoint || '').trim()
|
||||
if (accessHost) mcpAccessHost.value = accessHost
|
||||
if (/^https?:\/\//i.test(endpoint)) mcpAccessEndpoint.value = endpoint
|
||||
}
|
||||
|
||||
const mcpSkillFallback = [
|
||||
'# WeChat MCP Copilot',
|
||||
'',
|
||||
'Use WeChatDataAnalysis MCP like an investigator: start broad, resolve fuzzy targets, then fetch only the context needed to answer.',
|
||||
'',
|
||||
'Core rules:',
|
||||
'1. Start with initialize and tools/list.',
|
||||
'2. Prefer compact mobile facade tools before low-level tools.',
|
||||
'3. Keep limits small, page results, and expand only when needed.',
|
||||
'4. Use returned URLs for media and exports instead of inlining binary content.',
|
||||
].join('\n')
|
||||
const mcpAiPrompt = computed(() => [
|
||||
'你现在可以通过 WeChatDataAnalysis MCP 访问本机微信数据。',
|
||||
`MCP endpoint: ${mcpEndpoint.value}`,
|
||||
`Authorization: Bearer ${mcpToken.value || '<MCP_TOKEN>'}`,
|
||||
'',
|
||||
'接入要求:',
|
||||
'1. 使用 JSON-RPC 2.0 POST 到 MCP endpoint,Content-Type 为 application/json,并带上 Authorization Bearer token。',
|
||||
'2. 先调用 initialize,再用 tools/list 分页读取工具 schema。',
|
||||
'3. 工具调用使用 tools/call,优先读取 result.structuredContent。',
|
||||
'4. 不要一次性请求大结果;按下方 skill 的分页和上下文预算逐步扩展。',
|
||||
'5. 媒体、导出和 SSE 进度按返回 URL 在 App 侧加载,不要让模型内联二进制内容。',
|
||||
].join('\n'))
|
||||
const mcpSkillText = computed(() => mcpSkillBundleText.value || mcpSkillFallback)
|
||||
const mcpTokenText = computed(() => mcpToken.value || '加载中...')
|
||||
|
||||
const switchTrackClass = (enabled, disabled = false) => {
|
||||
if (disabled) return enabled ? 'bg-[#07b75b] opacity-50 cursor-not-allowed' : 'bg-[#d0d0d0] opacity-50 cursor-not-allowed'
|
||||
return enabled ? 'bg-[#07b75b] hover:brightness-95' : 'bg-[#d0d0d0] hover:brightness-95'
|
||||
@@ -535,6 +709,7 @@ const refreshDesktopOutputDirProgress = async () => {
|
||||
|
||||
const sectionElements = computed(() => [
|
||||
{ key: 'desktop', el: desktopSectionRef.value },
|
||||
{ key: 'mcp', el: mcpSectionRef.value },
|
||||
{ key: 'startup', el: startupSectionRef.value },
|
||||
{ key: 'updates', el: updatesSectionRef.value },
|
||||
{ key: 'sns', el: snsSectionRef.value },
|
||||
@@ -591,6 +766,171 @@ const fetchAdminEndpoint = async (url, options = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
const waitForBackendHealth = async (timeoutMs = 30_000) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
const apiBase = useApiBase()
|
||||
const healthUrl = `${String(apiBase || '').replace(/\/api\/?$/, '')}/api/health`
|
||||
const startedAt = Date.now()
|
||||
while (true) {
|
||||
try {
|
||||
const r = await fetch(healthUrl, { method: 'GET' })
|
||||
if (r && r.status < 500) return
|
||||
} catch {}
|
||||
if (Date.now() - startedAt > timeoutMs) throw new Error(`后端启动超时:${healthUrl}`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
}
|
||||
}
|
||||
|
||||
const copyMcpText = async (key, text) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
const value = String(text || '').trim()
|
||||
if (!value) return
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} else {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = value
|
||||
el.setAttribute('readonly', '')
|
||||
el.style.position = 'fixed'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
}
|
||||
mcpCopiedKey.value = key
|
||||
if (mcpCopiedTimer) clearTimeout(mcpCopiedTimer)
|
||||
mcpCopiedTimer = setTimeout(() => {
|
||||
if (mcpCopiedKey.value === key) mcpCopiedKey.value = ''
|
||||
}, 1600)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const refreshMcpLanAccess = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
mcpLanAccessLoading.value = true
|
||||
mcpLanAccessError.value = ''
|
||||
try {
|
||||
if (window.wechatDesktop?.getMcpLanAccess) {
|
||||
const resp = await window.wechatDesktop.getMcpLanAccess()
|
||||
mcpLanAccessEnabled.value = !!resp?.enabled
|
||||
applyMcpAccessInfo(resp)
|
||||
return
|
||||
}
|
||||
const resp = await fetchAdminEndpoint('/admin/mcp-access')
|
||||
mcpLanAccessEnabled.value = !!resp?.enabled
|
||||
applyMcpAccessInfo(resp)
|
||||
} catch (e) {
|
||||
mcpLanAccessError.value = e?.message || '读取 MCP 接入状态失败'
|
||||
} finally {
|
||||
mcpLanAccessLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshMcpToken = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
mcpTokenLoading.value = true
|
||||
mcpTokenError.value = ''
|
||||
try {
|
||||
const resp = await fetchAdminEndpoint('/admin/mcp-token')
|
||||
const token = String(resp?.token || '').trim()
|
||||
if (!token) throw new Error('MCP token is empty')
|
||||
mcpToken.value = token
|
||||
} catch (e) {
|
||||
mcpTokenError.value = e?.message || '读取 MCP Token 失败'
|
||||
} finally {
|
||||
mcpTokenLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetMcpToken = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
mcpTokenLoading.value = true
|
||||
mcpTokenError.value = ''
|
||||
try {
|
||||
const resp = await fetchAdminEndpoint('/admin/mcp-token/reset', { method: 'POST' })
|
||||
const token = String(resp?.token || '').trim()
|
||||
if (!token) throw new Error('MCP token is empty')
|
||||
mcpToken.value = token
|
||||
mcpCopiedKey.value = 'token-reset'
|
||||
if (mcpCopiedTimer) clearTimeout(mcpCopiedTimer)
|
||||
mcpCopiedTimer = setTimeout(() => {
|
||||
if (mcpCopiedKey.value === 'token-reset') mcpCopiedKey.value = ''
|
||||
}, 1600)
|
||||
await refreshMcpSkillBundle()
|
||||
} catch (e) {
|
||||
mcpTokenError.value = e?.message || '重置 MCP Token 失败'
|
||||
} finally {
|
||||
mcpTokenLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshMcpSkillBundle = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
mcpSkillBundleLoading.value = true
|
||||
mcpSkillBundleError.value = ''
|
||||
try {
|
||||
if (!mcpToken.value) await refreshMcpToken()
|
||||
const resp = await $fetch('/mcp/skill/bundle', {
|
||||
baseURL: mcpEndpoint.value.replace(/\/mcp$/, ''),
|
||||
headers: mcpToken.value ? { Authorization: `Bearer ${mcpToken.value}` } : {},
|
||||
})
|
||||
const bundleText = String(resp?.bundleText || '').trim()
|
||||
if (!bundleText) throw new Error('Skill bundle is empty')
|
||||
mcpSkillBundleText.value = bundleText
|
||||
} catch (e) {
|
||||
mcpSkillBundleError.value = e?.message || '读取 skill 失败,当前显示内置精简版'
|
||||
if (!mcpSkillBundleText.value) mcpSkillBundleText.value = ''
|
||||
} finally {
|
||||
mcpSkillBundleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setMcpLanAccess = async (enabled) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
mcpLanAccessLoading.value = true
|
||||
mcpLanAccessError.value = ''
|
||||
mcpLanAccessMessage.value = ''
|
||||
const previous = mcpLanAccessEnabled.value
|
||||
mcpLanAccessEnabled.value = !!enabled
|
||||
try {
|
||||
if (window.wechatDesktop?.setMcpLanAccess) {
|
||||
const resp = await window.wechatDesktop.setMcpLanAccess(!!enabled)
|
||||
mcpLanAccessEnabled.value = !!resp?.enabled
|
||||
applyMcpAccessInfo(resp)
|
||||
mcpLanAccessMessage.value = resp?.changed ? 'MCP 局域网接入已更新,后端已重启。' : 'MCP 局域网接入状态未变化。'
|
||||
await refreshMcpSkillBundle()
|
||||
return
|
||||
}
|
||||
|
||||
const resp = await fetchAdminEndpoint('/admin/mcp-access', {
|
||||
method: 'POST',
|
||||
body: { enabled: !!enabled },
|
||||
})
|
||||
mcpLanAccessEnabled.value = !!resp?.enabled
|
||||
applyMcpAccessInfo(resp)
|
||||
mcpLanAccessMessage.value = resp?.changed ? 'MCP 局域网接入已更新,正在等待后端重启。' : 'MCP 局域网接入状态未变化。'
|
||||
if (resp?.changed) {
|
||||
await waitForBackendHealth(30_000)
|
||||
await refreshMcpLanAccess()
|
||||
mcpLanAccessMessage.value = 'MCP 局域网接入已更新,后端已恢复。'
|
||||
}
|
||||
await refreshMcpSkillBundle()
|
||||
} catch (e) {
|
||||
mcpLanAccessEnabled.value = previous
|
||||
mcpLanAccessError.value = e?.message || '设置 MCP 接入状态失败'
|
||||
await refreshMcpLanAccess()
|
||||
} finally {
|
||||
mcpLanAccessLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMcpLanAccess = async () => {
|
||||
if (mcpLanAccessLoading.value) return
|
||||
await setMcpLanAccess(!mcpLanAccessEnabled.value)
|
||||
}
|
||||
|
||||
const refreshDesktopAutoLaunch = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getAutoLaunch) return
|
||||
@@ -945,6 +1285,9 @@ const onDesktopCheckUpdates = async () => {
|
||||
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
await refreshMcpLanAccess()
|
||||
await refreshMcpToken()
|
||||
await refreshMcpSkillBundle()
|
||||
await refreshBackendLogFileInfo()
|
||||
if (isDesktopEnv.value) {
|
||||
await refreshDesktopOutputDir()
|
||||
@@ -969,6 +1312,9 @@ onMounted(async () => {
|
||||
snsUseCache.value = readLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, true)
|
||||
|
||||
await refreshDesktopBackendPort()
|
||||
await refreshMcpLanAccess()
|
||||
await refreshMcpToken()
|
||||
await refreshMcpSkillBundle()
|
||||
if (isDesktopEnv.value) {
|
||||
void desktopUpdate.initListeners()
|
||||
await refreshDesktopAutoLaunch()
|
||||
@@ -984,6 +1330,10 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
window.removeEventListener('keydown', onEscKeydown)
|
||||
if (mcpCopiedTimer) {
|
||||
clearTimeout(mcpCopiedTimer)
|
||||
mcpCopiedTimer = null
|
||||
}
|
||||
if (typeof removeDesktopOutputDirProgressListener === 'function') {
|
||||
removeDesktopOutputDirProgressListener()
|
||||
removeDesktopOutputDirProgressListener = null
|
||||
|
||||
@@ -1308,28 +1308,38 @@
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs rounded-md border transition-colors"
|
||||
:class="exportScope === 'selected' && exportListTab === 'all' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
:class="exportScope === 'all' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
@click="onExportBatchScopeClick('all')"
|
||||
>
|
||||
全部 {{ exportContactCounts.total }}
|
||||
全部 {{ exportTargetsLoading ? '...' : exportContactCounts.total }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs rounded-md border transition-colors"
|
||||
:class="exportScope === 'selected' && exportListTab === 'groups' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
:class="exportScope === 'groups' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
@click="onExportBatchScopeClick('groups')"
|
||||
>
|
||||
群聊 {{ exportContactCounts.groups }}
|
||||
群聊 {{ exportTargetsLoading ? '...' : exportContactCounts.groups }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs rounded-md border transition-colors"
|
||||
:class="exportScope === 'selected' && exportListTab === 'singles' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
:class="exportScope === 'singles' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
@click="onExportBatchScopeClick('singles')"
|
||||
>
|
||||
单聊 {{ exportContactCounts.singles }}
|
||||
单聊 {{ exportTargetsLoading ? '...' : exportContactCounts.singles }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs rounded-md border transition-colors"
|
||||
:class="exportScope === 'selected' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
@click="onExportCustomScopeClick"
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="exportTargetsLoading" class="mt-1 text-[11px] text-gray-400">正在同步导出范围...</div>
|
||||
<div v-else-if="exportTargetsError" class="mt-1 text-[11px] text-red-500">{{ exportTargetsError }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1395,7 +1405,7 @@
|
||||
|
||||
<div v-if="exportScope === 'selected'" class="mt-3">
|
||||
<div class="mb-2 text-xs text-gray-500">
|
||||
点击上方范围可筛选并默认全选当前结果,再次点击可取消全选;下方整行可点选会话
|
||||
下方整行可点选会话;搜索只影响当前自定义列表
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
@@ -1424,6 +1434,7 @@
|
||||
<div class="text-sm text-gray-800 truncate">
|
||||
{{ c.name }}
|
||||
<span class="text-xs text-gray-500">{{ c.isGroup ? '(群)' : '' }}</span>
|
||||
<span v-if="!c.inSessionList" class="ml-1 text-[10px] text-[#03C160]">补充</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 truncate">{{ c.username }}</div>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,10 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
const exportSearchQuery = ref('')
|
||||
const exportListTab = ref('all')
|
||||
const exportSelectedUsernames = ref([])
|
||||
const exportTargetContacts = ref([])
|
||||
const exportTargetsLoading = ref(false)
|
||||
const exportTargetsLoaded = ref(false)
|
||||
const exportTargetsError = ref('')
|
||||
|
||||
const exportJob = ref(null)
|
||||
let exportPollTimer = null
|
||||
@@ -121,9 +125,35 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
}, [])
|
||||
}
|
||||
|
||||
const normalizeExportTargetContact = (item) => {
|
||||
const username = String(item?.username || '').trim()
|
||||
const name = String(item?.name || item?.displayName || username).trim() || username
|
||||
return {
|
||||
...item,
|
||||
username,
|
||||
name,
|
||||
displayName: name,
|
||||
avatar: String(item?.avatar || '').trim(),
|
||||
isGroup: item?.isGroup != null ? !!item.isGroup : username.endsWith('@chatroom'),
|
||||
isHidden: !!item?.isHidden,
|
||||
inSessionList: item?.inSessionList == null ? true : !!item.inSessionList
|
||||
}
|
||||
}
|
||||
|
||||
const getLocalExportContacts = () => {
|
||||
return (Array.isArray(contacts.value) ? contacts.value : [])
|
||||
.map(normalizeExportTargetContact)
|
||||
.filter((contact) => !!contact.username)
|
||||
}
|
||||
|
||||
const getExportBaseContacts = () => {
|
||||
if (exportTargetsLoaded.value) return exportTargetContacts.value
|
||||
return getLocalExportContacts()
|
||||
}
|
||||
|
||||
const getExportFilteredContacts = ({ tab = exportListTab.value, query = exportSearchQuery.value } = {}) => {
|
||||
const normalizedQuery = String(query || '').trim().toLowerCase()
|
||||
let list = Array.isArray(contacts.value) ? contacts.value : []
|
||||
let list = getExportBaseContacts()
|
||||
|
||||
const normalizedTab = String(tab || 'all')
|
||||
if (normalizedTab === 'groups') list = list.filter((contact) => !!contact?.isGroup)
|
||||
@@ -142,7 +172,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
})
|
||||
|
||||
const exportContactCounts = computed(() => {
|
||||
const list = Array.isArray(contacts.value) ? contacts.value : []
|
||||
const list = getExportBaseContacts()
|
||||
const total = list.length
|
||||
const groups = list.filter((contact) => !!contact?.isGroup).length
|
||||
return { total, groups, singles: total - groups }
|
||||
@@ -198,8 +228,63 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
}
|
||||
|
||||
const onExportBatchScopeClick = (tab) => {
|
||||
const nextTab = String(tab || 'all')
|
||||
exportListTab.value = nextTab
|
||||
exportScope.value = nextTab === 'groups' || nextTab === 'singles' ? nextTab : 'all'
|
||||
selectExportFilteredContacts(nextTab)
|
||||
}
|
||||
|
||||
const onExportCustomScopeClick = () => {
|
||||
exportScope.value = 'selected'
|
||||
onExportListTabClick(tab)
|
||||
if (exportSelectedUsernames.value.length === 0) {
|
||||
selectExportFilteredContacts(exportListTab.value)
|
||||
}
|
||||
}
|
||||
|
||||
const loadExportTargets = async ({ selectFiltered = false } = {}) => {
|
||||
if (!selectedAccount.value) return
|
||||
const selectedBeforeLoad = normalizeExportSelectedUsernames(exportSelectedUsernames.value)
|
||||
const filteredBeforeLoad = getExportFilteredUsernames(exportListTab.value)
|
||||
const selectedWasCurrentFiltered =
|
||||
filteredBeforeLoad.length > 0 &&
|
||||
filteredBeforeLoad.length === selectedBeforeLoad.length &&
|
||||
filteredBeforeLoad.every((username) => selectedBeforeLoad.includes(username))
|
||||
exportTargetsLoading.value = true
|
||||
exportTargetsError.value = ''
|
||||
try {
|
||||
const response = await api.getChatExportTargets({
|
||||
account: selectedAccount.value,
|
||||
include_hidden: true,
|
||||
include_official: false
|
||||
})
|
||||
const selectedNow = normalizeExportSelectedUsernames(exportSelectedUsernames.value)
|
||||
const filteredNowBeforeLoad = getExportFilteredUsernames(exportListTab.value)
|
||||
const selectedNowMatchesPreloadFilter =
|
||||
filteredBeforeLoad.length > 0 &&
|
||||
filteredBeforeLoad.length === selectedNow.length &&
|
||||
filteredBeforeLoad.every((username) => selectedNow.includes(username))
|
||||
const selectedNowMatchesCurrentFilter =
|
||||
filteredNowBeforeLoad.length > 0 &&
|
||||
filteredNowBeforeLoad.length === selectedNow.length &&
|
||||
filteredNowBeforeLoad.every((username) => selectedNow.includes(username))
|
||||
const targets = Array.isArray(response?.targets) ? response.targets : []
|
||||
exportTargetContacts.value = targets
|
||||
.map(normalizeExportTargetContact)
|
||||
.filter((contact) => !!contact.username)
|
||||
exportTargetsLoaded.value = true
|
||||
if (selectFiltered || selectedWasCurrentFiltered || selectedNowMatchesPreloadFilter || selectedNowMatchesCurrentFilter) {
|
||||
selectExportFilteredContacts(exportListTab.value)
|
||||
}
|
||||
} catch (error) {
|
||||
exportTargetsLoaded.value = false
|
||||
exportTargetContacts.value = []
|
||||
exportTargetsError.value = error?.message || '加载导出范围失败'
|
||||
if (!exportError.value) {
|
||||
exportError.value = `加载导出范围失败:${exportTargetsError.value}`
|
||||
}
|
||||
} finally {
|
||||
exportTargetsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isDesktopExportRuntime = () => {
|
||||
@@ -407,6 +492,9 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
const openExportModal = () => {
|
||||
exportModalOpen.value = true
|
||||
exportError.value = ''
|
||||
exportTargetsError.value = ''
|
||||
exportTargetsLoaded.value = false
|
||||
exportTargetContacts.value = []
|
||||
resetExportSaveFeedback({ resetAutoSavedFor: true })
|
||||
exportCancelRequested.value = false
|
||||
exportSearchQuery.value = ''
|
||||
@@ -417,9 +505,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
exportMessageTypes.value = exportMessageTypeOptions.map((item) => item.value)
|
||||
exportAutoSavedFor.value = ''
|
||||
exportScope.value = selectedContact.value?.username ? 'current' : 'selected'
|
||||
if (!selectedContact.value?.username) {
|
||||
selectExportFilteredContacts('all')
|
||||
}
|
||||
loadExportTargets({ selectFiltered: !selectedContact.value?.username })
|
||||
}
|
||||
|
||||
const closeExportModal = () => {
|
||||
@@ -488,6 +574,9 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
}
|
||||
} else if (scope === 'selected') {
|
||||
usernames = Array.isArray(exportSelectedUsernames.value) ? exportSelectedUsernames.value.filter(Boolean) : []
|
||||
} else if (scope !== 'all' && scope !== 'groups' && scope !== 'singles') {
|
||||
scope = 'selected'
|
||||
usernames = Array.isArray(exportSelectedUsernames.value) ? exportSelectedUsernames.value.filter(Boolean) : []
|
||||
}
|
||||
|
||||
if (scope === 'selected' && (!usernames || usernames.length === 0)) {
|
||||
@@ -547,7 +636,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
format: exportFormat.value,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
include_hidden: false,
|
||||
include_hidden: scope === 'all' || scope === 'groups' || scope === 'singles',
|
||||
include_official: false,
|
||||
message_types: messageTypes,
|
||||
include_media: includeMedia,
|
||||
@@ -615,9 +704,13 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
exportJob,
|
||||
exportOverallPercent,
|
||||
exportCurrentPercent,
|
||||
exportTargetsLoading,
|
||||
exportTargetsLoaded,
|
||||
exportTargetsError,
|
||||
exportFilteredContacts,
|
||||
exportContactCounts,
|
||||
onExportBatchScopeClick,
|
||||
onExportCustomScopeClick,
|
||||
onExportListTabClick,
|
||||
isExportContactSelected,
|
||||
hasWebExportFolder,
|
||||
|
||||
@@ -449,6 +449,15 @@ export const useApi = () => {
|
||||
return await request('/chat/exports')
|
||||
}
|
||||
|
||||
const getChatExportTargets = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.include_hidden != null) query.set('include_hidden', String(!!params.include_hidden))
|
||||
if (params && params.include_official != null) query.set('include_official', String(!!params.include_official))
|
||||
const url = '/chat/exports/targets' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
const cancelChatExport = async (exportId) => {
|
||||
if (!exportId) throw new Error('Missing exportId')
|
||||
return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
|
||||
@@ -693,6 +702,7 @@ export const useApi = () => {
|
||||
createChatExport,
|
||||
getChatExport,
|
||||
listChatExports,
|
||||
getChatExportTargets,
|
||||
cancelChatExport,
|
||||
createSnsExport,
|
||||
getSnsExport,
|
||||
|
||||
@@ -11,13 +11,15 @@
|
||||
import uvicorn
|
||||
import os
|
||||
from pathlib import Path
|
||||
from wechat_decrypt_tool.runtime_settings import read_effective_backend_port
|
||||
from wechat_decrypt_tool.network_access import get_lan_access_host
|
||||
from wechat_decrypt_tool.runtime_settings import read_effective_backend_host, read_effective_backend_port
|
||||
|
||||
def main():
|
||||
"""启动微信解密工具API服务"""
|
||||
host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1")
|
||||
host, host_source = read_effective_backend_host(default="127.0.0.1")
|
||||
port, port_source = read_effective_backend_port(default=10392)
|
||||
access_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
|
||||
lan_access_host = get_lan_access_host(default="127.0.0.1") if host in {"0.0.0.0", "::"} else access_host
|
||||
|
||||
print("=" * 60)
|
||||
print("微信解密工具 API 服务")
|
||||
@@ -29,8 +31,17 @@ def main():
|
||||
print("端口来源: 配置文件 output/runtime_settings.json(由网页/桌面设置写入)")
|
||||
else:
|
||||
print("端口来源: 默认值")
|
||||
if host_source == "env":
|
||||
print("监听地址来源: 环境变量 WECHAT_TOOL_HOST")
|
||||
elif host_source == "settings":
|
||||
print("监听地址来源: 配置文件 output/runtime_settings.json(由网页/桌面设置写入)")
|
||||
else:
|
||||
print("监听地址来源: 默认值")
|
||||
print(f"监听地址: {host}")
|
||||
print(f"API文档: http://{access_host}:{port}/docs")
|
||||
print(f"健康检查: http://{access_host}:{port}/api/health")
|
||||
if lan_access_host != access_host:
|
||||
print(f"局域网 MCP: http://{lan_access_host}:{port}/mcp")
|
||||
print("按 Ctrl+C 停止服务")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "wechat-decrypt-tool"
|
||||
version = "1.8.1"
|
||||
version = "1.9.2"
|
||||
description = "Modern WeChat database decryption tool with React frontend"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: wechat-mcp-copilot
|
||||
version: "1.0.0"
|
||||
description: Use WeChatDataAnalysis MCP to inspect local WeChat accounts, contacts, sessions, messages, Moments, media, and analytics through a small routed playbook. Trigger when the user asks to search, summarize, diagnose, or reason over local WeChat data.
|
||||
---
|
||||
|
||||
# WeChat MCP Copilot
|
||||
|
||||
Use WeChatDataAnalysis MCP like an investigator: start broad, resolve fuzzy targets, then fetch only the context needed to answer.
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. Start with `references/routing.md`.
|
||||
2. Load only one domain reference after routing.
|
||||
3. Load `references/pagination-budget.md` before broad searches or multi-page scans.
|
||||
4. Use `references/failure-recovery.md` when MCP, database readiness, or empty results are unclear.
|
||||
5. For phone, ScreenMemo, or external MCP clients, prefer `wechat.mobile.*` facade tools before low-level tools.
|
||||
6. Do not load the full tool catalog unless the user asks about available tools.
|
||||
|
||||
## References
|
||||
|
||||
- `references/routing.md`: first-hop intent routing.
|
||||
- `references/mobile.md`: phone-friendly facade tools and compact response rules.
|
||||
- `references/target-resolution.md`: fuzzy contact/session resolution.
|
||||
- `references/chats.md`: chat sessions, messages, search, and context.
|
||||
- `references/moments.md`: Moments timeline, posters, likes, comments, media.
|
||||
- `references/media.md`: images, videos, emoji, files, voice resources without transcription.
|
||||
- `references/analytics.md`: wrapped cards, counts, rankings, and aggregate analysis.
|
||||
- `references/pagination-budget.md`: limits, cursors, result clipping, stopping rules.
|
||||
- `references/failure-recovery.md`: empty result, not-ready database, ambiguous targets, retries.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Analytics
|
||||
|
||||
Use this for annual summaries, rankings, counts, and aggregate questions.
|
||||
|
||||
## Tools
|
||||
|
||||
- `wechat.analytics.get_wrapped_meta`
|
||||
- `wechat.analytics.get_wrapped_card`
|
||||
- `wechat.analytics.get_wrapped_annual`
|
||||
- `wechat.chat.get_daily_message_counts`
|
||||
- `wechat.biz.get_pay_records`
|
||||
|
||||
## Rules
|
||||
|
||||
- Prefer `get_wrapped_meta` then `get_wrapped_card` for mobile or constrained contexts.
|
||||
- Wrapped annual tools read existing generated cache only; if a cache is missing, ask the user to open Wrapped in the desktop/web app first.
|
||||
- Use `get_wrapped_annual` only when the user needs the whole annual dataset.
|
||||
- For broad statistics, prefer aggregate tools or targeted searches over full message pagination.
|
||||
- Always state the account, time range, and metric basis when answering.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Chats
|
||||
|
||||
Use this for chat sessions, message search, and message context.
|
||||
|
||||
## Flow
|
||||
|
||||
1. Resolve fuzzy target with `wechat.chat.resolve_session`.
|
||||
2. For recent messages, call `wechat.chat.get_messages` with a small `limit`.
|
||||
3. For keywords, call `wechat.chat.search_messages`.
|
||||
4. Use `wechat.chat.list_search_senders` when the user needs sender facets for a broad search.
|
||||
5. For a hit that needs context, call `wechat.chat.get_message_around`.
|
||||
6. For merged-forward chat history or AppMsg cards that only expose `server_id`, call `wechat.chat.resolve_chat_history` or `wechat.chat.resolve_app_message`.
|
||||
7. Use `wechat.chat.get_message_raw` only for debugging or missing structured fields.
|
||||
|
||||
## Useful Tools
|
||||
|
||||
- `wechat.chat.list_sessions`
|
||||
- `wechat.chat.resolve_session`
|
||||
- `wechat.chat.get_messages`
|
||||
- `wechat.chat.search_messages`
|
||||
- `wechat.chat.list_search_senders`
|
||||
- `wechat.chat.get_message_around`
|
||||
- `wechat.chat.get_message_anchor`
|
||||
- `wechat.chat.get_daily_message_counts`
|
||||
- `wechat.chat.resolve_chat_history`
|
||||
- `wechat.chat.resolve_app_message`
|
||||
|
||||
Do not scan full histories by pagination when an aggregate or search tool can answer.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Failure Recovery
|
||||
|
||||
Use this when MCP status, DB readiness, or results are suspicious.
|
||||
|
||||
## Checks
|
||||
|
||||
1. Phone clients: `wechat.mobile.get_overview`
|
||||
2. `wechat.core.get_status`
|
||||
3. `wechat.core.list_accounts`
|
||||
4. `wechat.core.get_account_info`
|
||||
5. Moments availability by checking account info and `wechat.moments.list_users`.
|
||||
6. For backend diagnostics, MCP LAN access, data preparation, index/cache build, export, realtime sync, local editing, or system settings, direct the user to the desktop/web app.
|
||||
|
||||
## Empty Results
|
||||
|
||||
- Do not conclude "no data" after one failed query.
|
||||
- Try contact/session resolution with a simpler keyword.
|
||||
- Try session search before global message search when a target is known.
|
||||
- For Moments, resolve poster identity before timeline filtering.
|
||||
- If data is not ready, stop content tools and explain that data preparation is handled in the desktop app, not through MCP.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Media
|
||||
|
||||
Use this for image, video, emoji, file, link, and voice resources.
|
||||
|
||||
## Tools
|
||||
|
||||
- `wechat.media.get_avatar_url`
|
||||
- `wechat.media.get_chat_image_url`
|
||||
- `wechat.media.get_chat_emoji_url`
|
||||
- `wechat.media.get_chat_video_thumb_url`
|
||||
- `wechat.media.get_chat_video_url`
|
||||
- `wechat.media.get_chat_voice_url`
|
||||
- `wechat.media.get_decrypted_resource_url`
|
||||
- `wechat.media.get_proxy_image_url`
|
||||
- `wechat.media.get_favicon_url`
|
||||
- `wechat.biz.get_proxy_image_url`
|
||||
- `wechat.moments.get_media_url`
|
||||
- `wechat.moments.get_article_thumb_url`
|
||||
- `wechat.moments.get_remote_video_url`
|
||||
- `wechat.moments.get_local_video_url`
|
||||
|
||||
## Rules
|
||||
|
||||
- Media tools return URLs or resource metadata; they do not inline large binary payloads.
|
||||
- Voice resources are files only. Do not transcribe voice messages.
|
||||
- For phone clients, prefer `wechat.mobile.get_media_links` first.
|
||||
- MCP does not open local folders or download media into cache; use returned URLs in the client.
|
||||
- Locate the message first, then fetch media URL by message fields such as `server_id`, `username`, `md5`, or returned media references.
|
||||
- For Moments, prefer local media URL fields from timeline records. Use remote video/article helpers only when the timeline record has a remote URL or article URL.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Mobile Facade
|
||||
|
||||
Use this for phone, ScreenMemo, and external MCP clients unless the user needs a low-level operation.
|
||||
|
||||
## Default Tools
|
||||
|
||||
- `wechat.mobile.get_overview`: first call after initialize. Returns readiness, accounts, health, and suggested next tools.
|
||||
- `wechat.mobile.get_home_snapshot`: small account/session/Moments snapshot for a home screen.
|
||||
- `wechat.mobile.resolve_target`: resolve fuzzy people, groups, sessions, Moments users, or official accounts.
|
||||
- `wechat.mobile.search_chat`: message search with optional tiny context windows.
|
||||
- `wechat.mobile.get_chat_context`: recent, day, or around-anchor chat context.
|
||||
- `wechat.mobile.get_session_bundle`: session metadata plus a page of messages.
|
||||
- `wechat.mobile.search_moments`: compact Moments search.
|
||||
- `wechat.mobile.get_media_links`: URL-only media lookup.
|
||||
- `wechat.mobile.get_analytics`: compact analytics by metric.
|
||||
|
||||
## Budget Rules
|
||||
|
||||
- Keep `limit` at 10-20 for first calls.
|
||||
- Use `offset` or returned cursor fields for paging.
|
||||
- Do not call full annual analytics by default; use `metric=digest` or a single card. Wrapped annual data is cache-only through MCP.
|
||||
- Do not fetch binary media through MCP. Use returned URLs in the app.
|
||||
- Use low-level tools only for debugging, raw fields, or unusual media URL construction.
|
||||
- Data preparation, index/cache build, export, realtime sync, local editing, system settings, and data deletion tools are not exposed through MCP.
|
||||
|
||||
## Recovery
|
||||
|
||||
- If `ready=false`, stop content tools and direct the user to the desktop/web app for data preparation or backend diagnostics.
|
||||
- If target resolution is ambiguous, ask for one clarifying clue or show top candidates.
|
||||
- If search returns nothing, try `resolve_target` and then `get_chat_context` before declaring no data.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Moments
|
||||
|
||||
Use this for 朋友圈, posts, likes, comments, shared links, and Moments media.
|
||||
|
||||
## Flow
|
||||
|
||||
1. If the clue is a person, resolve with `wechat.contacts.resolve_contact`.
|
||||
2. Use `wechat.moments.list_users` when you need poster candidates.
|
||||
3. Use `wechat.moments.list_timeline` or `wechat.moments.search_moments`.
|
||||
4. Use `wechat.moments.get_media_url` only when the user needs a media resource.
|
||||
|
||||
## Rules
|
||||
|
||||
- Person names must be resolved to username before filtering timeline by `usernames`.
|
||||
- Keyword search is for post content/topic, not poster identity.
|
||||
- Do not request raw XML by default.
|
||||
- Realtime/local sync tools are not exposed through MCP; ask the user to refresh data in the app when Moments data looks stale.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Pagination And Budget
|
||||
|
||||
Default limits:
|
||||
|
||||
- Contact/session candidates: 10
|
||||
- Recent messages: 20
|
||||
- Message search: 20
|
||||
- Moments timeline: 10
|
||||
- Media references: 20
|
||||
- Ranking/analytics rows: 20
|
||||
|
||||
Hard rules:
|
||||
|
||||
- Keep single message/Moments pages at or below 50 unless user asks for more.
|
||||
- Stop paging when enough evidence exists.
|
||||
- Stop after two empty or low-value pages.
|
||||
- Do not cross 500 raw messages without user confirmation.
|
||||
- List responses should use ids, names, timestamps, preview, and evidence.
|
||||
- Fetch full details only after a candidate or hit is selected.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Routing
|
||||
|
||||
Use this first for every WeChatDataAnalysis MCP task.
|
||||
|
||||
## First Calls
|
||||
|
||||
- Phone, ScreenMemo, mobile app, or compact external client: load `mobile.md` and start with `wechat.mobile.get_overview`.
|
||||
- Status, readiness, "why can't I find anything": `wechat.core.get_status`, or `wechat.mobile.get_overview` for phone clients.
|
||||
- Available tools or packages: `wechat.core.list_tools`.
|
||||
- Account selection: `wechat.core.list_accounts`, then `wechat.core.get_account_info`.
|
||||
- Backend health, logs, MCP LAN access, port, system settings, key/decrypt/import/data preparation, index/cache build, export, realtime sync, local editing, or data deletion requests: explain that these operations are not exposed through MCP and should be handled in the desktop/web app.
|
||||
- Fuzzy person/group/official account: load `target-resolution.md`.
|
||||
- Chat content, recent messages, keyword search: load `chats.md`.
|
||||
- Moments / 朋友圈 / likes / comments / post media: load `moments.md`.
|
||||
- Images, videos, emoji, files, voice resources: load `media.md`.
|
||||
- Rankings, yearly summary, activity stats: load `analytics.md`; if Wrapped cache is missing, ask the user to generate it in the app.
|
||||
- Empty results or readiness errors: load `failure-recovery.md`.
|
||||
|
||||
## Mixed Intent
|
||||
|
||||
Resolve the target first, then load only the main domain reference. Do not load chats, moments, media, and analytics together unless the user explicitly asks for a broad audit.
|
||||
|
||||
For phone clients, keep using `mobile.md` until the user needs a low-level fallback such as raw fields or special media URL construction.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Target Resolution
|
||||
|
||||
Use target resolution whenever the user gives a loose name, nickname, group name, remark, official account name, or "the person/group who...".
|
||||
|
||||
## Tools
|
||||
|
||||
- Contacts: `wechat.contacts.resolve_contact`
|
||||
- Sessions: `wechat.chat.resolve_session`
|
||||
- Fallback list: `wechat.contacts.list_contacts`, `wechat.chat.list_sessions`
|
||||
|
||||
## Rules
|
||||
|
||||
- Prefer exact username/wxid match, then remark, nickname, display name, alias, recent session evidence.
|
||||
- If a chat task mentions a person, resolve both contact and session when needed.
|
||||
- If several candidates remain plausible, inspect recent session previews before choosing.
|
||||
- If ambiguity survives after reasonable evidence, ask a short clarification.
|
||||
|
||||
## Evidence Fields
|
||||
|
||||
Use username/session id, display name, remark/nickname/alias, session type, recent timestamp, recent preview, and confidence.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""微信数据库解密工具
|
||||
"""
|
||||
|
||||
__version__ = "1.8.1"
|
||||
__version__ = "1.9.2"
|
||||
__author__ = "WeChat Decrypt Tool"
|
||||
|
||||
@@ -31,6 +31,7 @@ from .routers.admin import router as _admin_router
|
||||
from .routers.account_archive_export import router as _account_archive_export_router
|
||||
from .routers.keys import router as _keys_router
|
||||
from .routers.media import router as _media_router
|
||||
from .routers.mcp import router as _mcp_router
|
||||
from .routers.sns import router as _sns_router
|
||||
from .routers.sns_export import router as _sns_export_router
|
||||
from .routers.wechat_detection import router as _wechat_detection_router
|
||||
@@ -73,6 +74,7 @@ app.include_router(_import_decrypted_router)
|
||||
app.include_router(_decrypt_router)
|
||||
app.include_router(_keys_router)
|
||||
app.include_router(_media_router)
|
||||
app.include_router(_mcp_router)
|
||||
app.include_router(_chat_router)
|
||||
app.include_router(_chat_contacts_router)
|
||||
app.include_router(_chat_export_router)
|
||||
|
||||
@@ -9,11 +9,11 @@ import os
|
||||
import uvicorn
|
||||
|
||||
from wechat_decrypt_tool.api import app
|
||||
from wechat_decrypt_tool.runtime_settings import read_effective_backend_port
|
||||
from wechat_decrypt_tool.runtime_settings import read_effective_backend_host, read_effective_backend_port
|
||||
|
||||
|
||||
def main() -> None:
|
||||
host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1")
|
||||
host, _ = read_effective_backend_host(default="127.0.0.1")
|
||||
port, _ = read_effective_backend_port(default=10392)
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ from .chat_helpers import (
|
||||
_parse_location_message,
|
||||
_parse_system_message_content,
|
||||
_parse_pat_message,
|
||||
_build_avatar_url,
|
||||
_pick_display_name,
|
||||
_quote_ident,
|
||||
_resolve_account_dir,
|
||||
@@ -50,6 +51,7 @@ from .chat_helpers import (
|
||||
_resource_lookup_chat_id,
|
||||
_should_keep_session,
|
||||
_split_group_sender_prefix,
|
||||
_resolve_msg_table_name_by_map,
|
||||
)
|
||||
from .chat_realtime_autosync import CHAT_REALTIME_AUTOSYNC
|
||||
from .logging_config import get_logger
|
||||
@@ -3527,13 +3529,66 @@ def _resolve_export_targets(
|
||||
uniq = list(dict.fromkeys([str(u or "").strip() for u in usernames if str(u or "").strip()]))
|
||||
return uniq
|
||||
|
||||
session_rows, session_hidden_by_username = _load_export_session_targets(account_dir)
|
||||
contact_usernames = _load_export_contact_usernames(account_dir)
|
||||
discovered_message_targets = _load_message_backed_export_targets(
|
||||
account_dir=account_dir,
|
||||
seed_usernames=contact_usernames,
|
||||
)
|
||||
|
||||
def should_include(u: str) -> bool:
|
||||
if not u or u == account_dir.name:
|
||||
return False
|
||||
if not include_hidden and int(session_hidden_by_username.get(u) or 0) == 1:
|
||||
return False
|
||||
if not _should_keep_session(u, include_official=include_official):
|
||||
return False
|
||||
if scope == "groups" and (not u.endswith("@chatroom")):
|
||||
return False
|
||||
if scope == "singles" and u.endswith("@chatroom"):
|
||||
return False
|
||||
return True
|
||||
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for u, _sort_ts in session_rows:
|
||||
if u in seen or (not should_include(u)):
|
||||
continue
|
||||
seen.add(u)
|
||||
out.append(u)
|
||||
|
||||
for u, _sort_ts in sorted(discovered_message_targets.items(), key=lambda item: (-int(item[1] or 0), item[0])):
|
||||
if u in seen or (not should_include(u)):
|
||||
continue
|
||||
seen.add(u)
|
||||
out.append(u)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _load_export_session_targets(account_dir: Path) -> tuple[list[tuple[str, int]], dict[str, int]]:
|
||||
session_db_path = account_dir / "session.db"
|
||||
if not session_db_path.exists():
|
||||
return [], {}
|
||||
|
||||
conn = sqlite3.connect(str(session_db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
columns = _sqlite_table_columns(conn, "SessionTable")
|
||||
if "username" not in columns:
|
||||
return [], {}
|
||||
|
||||
hidden_expr = "is_hidden" if "is_hidden" in columns else "0"
|
||||
if "sort_timestamp" in columns:
|
||||
sort_expr = "sort_timestamp"
|
||||
elif "last_timestamp" in columns:
|
||||
sort_expr = "last_timestamp"
|
||||
else:
|
||||
sort_expr = "0"
|
||||
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT username, is_hidden
|
||||
f"""
|
||||
SELECT username, {hidden_expr} AS is_hidden, {sort_expr} AS sort_timestamp
|
||||
FROM SessionTable
|
||||
ORDER BY sort_timestamp DESC
|
||||
""",
|
||||
@@ -3541,23 +3596,223 @@ def _resolve_export_targets(
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out: list[str] = []
|
||||
out: list[tuple[str, int]] = []
|
||||
hidden_by_username: dict[str, int] = {}
|
||||
seen: set[str] = set()
|
||||
for r in rows:
|
||||
u = str(r["username"] or "").strip()
|
||||
if not u:
|
||||
continue
|
||||
if not include_hidden and int(r["is_hidden"] or 0) == 1:
|
||||
try:
|
||||
hidden = int(r["is_hidden"] or 0)
|
||||
except Exception:
|
||||
hidden = 0
|
||||
if hidden:
|
||||
hidden_by_username[u] = 1
|
||||
else:
|
||||
hidden_by_username.setdefault(u, 0)
|
||||
if u in seen:
|
||||
continue
|
||||
if not _should_keep_session(u, include_official=include_official):
|
||||
continue
|
||||
if scope == "groups" and (not u.endswith("@chatroom")):
|
||||
continue
|
||||
if scope == "singles" and u.endswith("@chatroom"):
|
||||
continue
|
||||
out.append(u)
|
||||
seen.add(u)
|
||||
try:
|
||||
sort_ts = int(r["sort_timestamp"] or 0)
|
||||
except Exception:
|
||||
sort_ts = 0
|
||||
out.append((u, sort_ts))
|
||||
return out, hidden_by_username
|
||||
|
||||
|
||||
def _sqlite_table_columns(conn: sqlite3.Connection, table_name: str) -> set[str]:
|
||||
try:
|
||||
rows = conn.execute(f"PRAGMA table_info({_quote_ident(table_name)})").fetchall()
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
columns: set[str] = set()
|
||||
for row in rows:
|
||||
try:
|
||||
name = str(row["name"] if isinstance(row, sqlite3.Row) else row[1] or "").strip().lower()
|
||||
except Exception:
|
||||
name = ""
|
||||
if name:
|
||||
columns.add(name)
|
||||
return columns
|
||||
|
||||
|
||||
def _load_export_contact_usernames(account_dir: Path) -> set[str]:
|
||||
contact_db_path = account_dir / "contact.db"
|
||||
if not contact_db_path.exists():
|
||||
return set()
|
||||
|
||||
out: set[str] = set()
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
for table in ("contact", "stranger"):
|
||||
columns = _sqlite_table_columns(conn, table)
|
||||
if "username" not in columns:
|
||||
continue
|
||||
try:
|
||||
rows = conn.execute(f"SELECT username FROM {_quote_ident(table)}").fetchall()
|
||||
except Exception:
|
||||
continue
|
||||
for row in rows:
|
||||
try:
|
||||
username = str(row["username"] or "").strip()
|
||||
except Exception:
|
||||
username = ""
|
||||
if username:
|
||||
out.add(username)
|
||||
finally:
|
||||
conn.close()
|
||||
return out
|
||||
|
||||
|
||||
def _load_name2id_usernames(conn: sqlite3.Connection) -> set[str]:
|
||||
columns = _sqlite_table_columns(conn, "Name2Id")
|
||||
username_col = "user_name" if "user_name" in columns else ("username" if "username" in columns else "")
|
||||
if not username_col:
|
||||
return set()
|
||||
|
||||
out: set[str] = set()
|
||||
try:
|
||||
rows = conn.execute(f"SELECT {_quote_ident(username_col)} AS username FROM Name2Id").fetchall()
|
||||
except Exception:
|
||||
return out
|
||||
for row in rows:
|
||||
try:
|
||||
username = str(row["username"] if isinstance(row, sqlite3.Row) else row[0] or "").strip()
|
||||
except Exception:
|
||||
username = ""
|
||||
if username:
|
||||
out.add(username)
|
||||
return out
|
||||
|
||||
|
||||
def _message_table_latest_timestamp(conn: sqlite3.Connection, table_name: str) -> Optional[int]:
|
||||
quoted = _quote_ident(table_name)
|
||||
try:
|
||||
row = conn.execute(f"SELECT MAX(create_time) FROM {quoted}").fetchone()
|
||||
if row is not None and row[0] is not None:
|
||||
return int(row[0] or 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
row = conn.execute(f"SELECT 1 FROM {quoted} LIMIT 1").fetchone()
|
||||
if row is not None:
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _load_message_backed_export_targets(*, account_dir: Path, seed_usernames: set[str]) -> dict[str, int]:
|
||||
out: dict[str, int] = {}
|
||||
for db_path in _iter_message_db_paths(account_dir):
|
||||
conn: Optional[sqlite3.Connection] = None
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
table_names = [str(r["name"] if isinstance(r, sqlite3.Row) else r[0] or "") for r in rows]
|
||||
lower_to_actual = {name.lower(): name for name in table_names if name}
|
||||
if not lower_to_actual:
|
||||
continue
|
||||
|
||||
candidates = set(seed_usernames)
|
||||
candidates.update(_load_name2id_usernames(conn))
|
||||
for username in candidates:
|
||||
u = str(username or "").strip()
|
||||
if not u or u == account_dir.name:
|
||||
continue
|
||||
table_name = _resolve_msg_table_name_by_map(lower_to_actual, u)
|
||||
if not table_name:
|
||||
continue
|
||||
latest_ts = _message_table_latest_timestamp(conn, table_name)
|
||||
if latest_ts is None:
|
||||
continue
|
||||
previous_ts = out.get(u)
|
||||
if previous_ts is None or int(latest_ts or 0) > int(previous_ts or 0):
|
||||
out[u] = int(latest_ts or 0)
|
||||
except Exception:
|
||||
continue
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def build_chat_export_targets_preview(
|
||||
*,
|
||||
account_dir: Path,
|
||||
include_hidden: bool = True,
|
||||
include_official: bool = False,
|
||||
base_url: str = "",
|
||||
) -> dict[str, Any]:
|
||||
targets = _resolve_export_targets(
|
||||
account_dir=account_dir,
|
||||
scope="all",
|
||||
usernames=[],
|
||||
include_hidden=bool(include_hidden),
|
||||
include_official=bool(include_official),
|
||||
)
|
||||
session_rows, session_hidden_by_username = _load_export_session_targets(account_dir)
|
||||
session_usernames = {u for u, _sort_ts in session_rows}
|
||||
contact_rows = _load_contact_rows(account_dir / "contact.db", targets)
|
||||
base = str(base_url or "").rstrip("/")
|
||||
|
||||
conversations: list[dict[str, Any]] = []
|
||||
for u in targets:
|
||||
row = contact_rows.get(u)
|
||||
display_name = _pick_display_name(row, u) if row is not None else u
|
||||
avatar_path = _build_avatar_url(account_dir.name, u)
|
||||
conversations.append(
|
||||
{
|
||||
"username": u,
|
||||
"name": display_name,
|
||||
"displayName": display_name,
|
||||
"isGroup": bool(u.endswith("@chatroom")),
|
||||
"isHidden": bool(int(session_hidden_by_username.get(u) or 0) == 1),
|
||||
"inSessionList": bool(u in session_usernames),
|
||||
"avatar": f"{base}{avatar_path}" if base else avatar_path,
|
||||
}
|
||||
)
|
||||
|
||||
group_count = sum(1 for item in conversations if bool(item.get("isGroup")))
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
"includeHidden": bool(include_hidden),
|
||||
"includeOfficial": bool(include_official),
|
||||
"targets": conversations,
|
||||
"counts": {
|
||||
"total": len(conversations),
|
||||
"groups": group_count,
|
||||
"singles": len(conversations) - group_count,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_chat_export_targets_preview(
|
||||
*,
|
||||
account: Optional[str],
|
||||
include_hidden: bool = True,
|
||||
include_official: bool = False,
|
||||
base_url: str = "",
|
||||
) -> dict[str, Any]:
|
||||
account_dir = _resolve_account_dir(account)
|
||||
return build_chat_export_targets_preview(
|
||||
account_dir=account_dir,
|
||||
include_hidden=include_hidden,
|
||||
include_official=include_official,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
|
||||
def _conversation_dir_name(
|
||||
idx: int,
|
||||
display_name: str,
|
||||
|
||||
@@ -1992,45 +1992,93 @@ def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> di
|
||||
return previews
|
||||
|
||||
|
||||
def _pick_display_name(contact_row: Optional[sqlite3.Row], fallback_username: str) -> str:
|
||||
def _row_get_value(row: Any, key: str, default: Any = None) -> Any:
|
||||
if row is None:
|
||||
return default
|
||||
try:
|
||||
return row[key]
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(row, dict):
|
||||
return row.get(key, default)
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_contact_text(value: Any) -> str:
|
||||
return _decode_sqlite_text(value).strip()
|
||||
|
||||
|
||||
def _normalize_avatar_url(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, memoryview):
|
||||
value = value.tobytes()
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
raw = bytes(value)
|
||||
if not raw:
|
||||
return ""
|
||||
# Avatar URLs should be ASCII/UTF-8 HTTP(S) URLs. If invalid bytes were
|
||||
# stored in the TEXT column, ignore that avatar instead of failing the
|
||||
# surrounding chat/contact response.
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return ""
|
||||
else:
|
||||
text = str(value or "")
|
||||
|
||||
text = text.strip()
|
||||
if text.lower().startswith(("http://", "https://")):
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _contact_row_to_dict(row: Any) -> dict[str, Any]:
|
||||
username = _normalize_contact_text(_row_get_value(row, "username", ""))
|
||||
return {
|
||||
"username": username,
|
||||
"remark": _normalize_contact_text(_row_get_value(row, "remark", "")),
|
||||
"nick_name": _normalize_contact_text(_row_get_value(row, "nick_name", "")),
|
||||
"alias": _normalize_contact_text(_row_get_value(row, "alias", "")),
|
||||
"big_head_url": _normalize_avatar_url(_row_get_value(row, "big_head_url", "")),
|
||||
"small_head_url": _normalize_avatar_url(_row_get_value(row, "small_head_url", "")),
|
||||
}
|
||||
|
||||
|
||||
def _pick_display_name(contact_row: Optional[Any], fallback_username: str) -> str:
|
||||
if contact_row is None:
|
||||
return fallback_username
|
||||
|
||||
for key in ("remark", "nick_name", "alias"):
|
||||
try:
|
||||
v = contact_row[key]
|
||||
except Exception:
|
||||
v = None
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
v = _normalize_contact_text(_row_get_value(contact_row, key, ""))
|
||||
if v:
|
||||
return v
|
||||
|
||||
return fallback_username
|
||||
|
||||
|
||||
def _pick_avatar_url(contact_row: Optional[sqlite3.Row]) -> Optional[str]:
|
||||
def _pick_avatar_url(contact_row: Optional[Any]) -> Optional[str]:
|
||||
if contact_row is None:
|
||||
return None
|
||||
|
||||
for key in ("big_head_url", "small_head_url"):
|
||||
try:
|
||||
v = contact_row[key]
|
||||
except Exception:
|
||||
v = None
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
v = _normalize_avatar_url(_row_get_value(contact_row, key, ""))
|
||||
if v:
|
||||
return v
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str, sqlite3.Row]:
|
||||
def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str, dict[str, Any]]:
|
||||
uniq = list(dict.fromkeys([u for u in usernames if u]))
|
||||
if not uniq:
|
||||
return {}
|
||||
|
||||
result: dict[str, sqlite3.Row] = {}
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.text_factory = bytes
|
||||
try:
|
||||
def query_table(table: str, targets: list[str]) -> None:
|
||||
if not targets:
|
||||
@@ -2043,7 +2091,10 @@ def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str,
|
||||
"""
|
||||
rows = conn.execute(sql, targets).fetchall()
|
||||
for r in rows:
|
||||
result[r["username"]] = r
|
||||
item = _contact_row_to_dict(r)
|
||||
username = str(item.get("username") or "").strip()
|
||||
if username:
|
||||
result[username] = item
|
||||
|
||||
query_table("contact", uniq)
|
||||
missing = [u for u in uniq if u not in result]
|
||||
|
||||
@@ -18,7 +18,7 @@ from .logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
_SCHEMA_VERSION = 2
|
||||
_INDEX_DB_NAME = "chat_search_index.db"
|
||||
_INDEX_DB_TMP_NAME = "chat_search_index.tmp.db"
|
||||
_LEGACY_INDEX_DB_NAME = "message_fts.db"
|
||||
@@ -188,7 +188,24 @@ def _update_build_state(account_key: str, **kwargs: Any) -> None:
|
||||
st.update(kwargs)
|
||||
|
||||
|
||||
def _load_sessions_for_index(account_dir: Path) -> dict[str, dict[str, Any]]:
|
||||
def _sqlite_table_columns(conn: sqlite3.Connection, table_name: str) -> set[str]:
|
||||
try:
|
||||
rows = conn.execute(f"PRAGMA table_info({_quote_ident(table_name)})").fetchall()
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
columns: set[str] = set()
|
||||
for row in rows:
|
||||
try:
|
||||
name = str(row["name"] if isinstance(row, sqlite3.Row) else row[1] or "").strip().lower()
|
||||
except Exception:
|
||||
name = ""
|
||||
if name:
|
||||
columns.add(name)
|
||||
return columns
|
||||
|
||||
|
||||
def _load_session_table_targets(account_dir: Path) -> dict[str, dict[str, Any]]:
|
||||
session_db_path = account_dir / "session.db"
|
||||
if not session_db_path.exists():
|
||||
return {}
|
||||
@@ -196,7 +213,11 @@ def _load_sessions_for_index(account_dir: Path) -> dict[str, dict[str, Any]]:
|
||||
conn = sqlite3.connect(str(session_db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = conn.execute("SELECT username, is_hidden FROM SessionTable").fetchall()
|
||||
columns = _sqlite_table_columns(conn, "SessionTable")
|
||||
if "username" not in columns:
|
||||
return {}
|
||||
hidden_expr = "is_hidden" if "is_hidden" in columns else "0"
|
||||
rows = conn.execute(f"SELECT username, {hidden_expr} AS is_hidden FROM SessionTable").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -214,6 +235,108 @@ def _load_sessions_for_index(account_dir: Path) -> dict[str, dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def _load_contact_usernames_for_index(account_dir: Path) -> set[str]:
|
||||
contact_db_path = account_dir / "contact.db"
|
||||
if not contact_db_path.exists():
|
||||
return set()
|
||||
|
||||
out: set[str] = set()
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
for table in ("contact", "stranger"):
|
||||
columns = _sqlite_table_columns(conn, table)
|
||||
if "username" not in columns:
|
||||
continue
|
||||
try:
|
||||
rows = conn.execute(f"SELECT username FROM {_quote_ident(table)}").fetchall()
|
||||
except Exception:
|
||||
continue
|
||||
for row in rows:
|
||||
username = _decode_sqlite_text(row["username"]).strip()
|
||||
if username:
|
||||
out.add(username)
|
||||
finally:
|
||||
conn.close()
|
||||
return out
|
||||
|
||||
|
||||
def _load_name2id_usernames_for_index(conn: sqlite3.Connection) -> set[str]:
|
||||
columns = _sqlite_table_columns(conn, "Name2Id")
|
||||
username_col = "user_name" if "user_name" in columns else ("username" if "username" in columns else "")
|
||||
if not username_col:
|
||||
return set()
|
||||
|
||||
out: set[str] = set()
|
||||
try:
|
||||
rows = conn.execute(f"SELECT {_quote_ident(username_col)} AS username FROM Name2Id").fetchall()
|
||||
except Exception:
|
||||
return out
|
||||
|
||||
for row in rows:
|
||||
try:
|
||||
raw = row["username"] if isinstance(row, sqlite3.Row) else row[0]
|
||||
except Exception:
|
||||
raw = ""
|
||||
username = _decode_sqlite_text(raw).strip()
|
||||
if username:
|
||||
out.add(username)
|
||||
return out
|
||||
|
||||
|
||||
def _load_message_backed_index_targets(*, account_dir: Path, seed_usernames: set[str]) -> set[str]:
|
||||
out: set[str] = set()
|
||||
for db_path in _iter_message_db_paths(account_dir):
|
||||
conn: Optional[sqlite3.Connection] = None
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
table_names = [_decode_sqlite_text(r["name"] if isinstance(r, sqlite3.Row) else r[0]).strip() for r in rows]
|
||||
lower_to_actual = {name.lower(): name for name in table_names if name}
|
||||
if not lower_to_actual:
|
||||
continue
|
||||
|
||||
candidates = set(seed_usernames)
|
||||
candidates.update(_load_name2id_usernames_for_index(conn))
|
||||
for username in candidates:
|
||||
u = str(username or "").strip()
|
||||
if not u or u == account_dir.name:
|
||||
continue
|
||||
if not _should_keep_session(u, include_official=True):
|
||||
continue
|
||||
if _resolve_msg_table_name_by_map(lower_to_actual, u):
|
||||
out.add(u)
|
||||
except Exception:
|
||||
continue
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _load_sessions_for_index(account_dir: Path) -> dict[str, dict[str, Any]]:
|
||||
sessions = _load_session_table_targets(account_dir)
|
||||
contact_usernames = _load_contact_usernames_for_index(account_dir)
|
||||
message_backed_usernames = _load_message_backed_index_targets(
|
||||
account_dir=account_dir,
|
||||
seed_usernames=contact_usernames,
|
||||
)
|
||||
|
||||
for u in sorted(message_backed_usernames):
|
||||
if u in sessions:
|
||||
continue
|
||||
sessions[u] = {
|
||||
"is_hidden": 0,
|
||||
"is_official": 1 if u.startswith("gh_") else 0,
|
||||
}
|
||||
|
||||
return sessions
|
||||
|
||||
|
||||
def _init_index_db(conn: sqlite3.Connection) -> None:
|
||||
# NOTE: This index DB is built as a temporary file and then atomically swapped in.
|
||||
# Using WAL here would create `-wal/-shm` side files that are *not* swapped together,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""MCP integration for WeChatDataAnalysis."""
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
JSONRPC_PARSE_ERROR = -32700
|
||||
JSONRPC_INVALID_REQUEST = -32600
|
||||
JSONRPC_METHOD_NOT_FOUND = -32601
|
||||
JSONRPC_INVALID_PARAMS = -32602
|
||||
JSONRPC_INTERNAL_ERROR = -32603
|
||||
|
||||
|
||||
@dataclass
|
||||
class McpError(Exception):
|
||||
code: int
|
||||
message: str
|
||||
data: Any = None
|
||||
|
||||
|
||||
def error_from_exception(exc: Exception) -> McpError:
|
||||
if isinstance(exc, McpError):
|
||||
return exc
|
||||
if isinstance(exc, HTTPException):
|
||||
return McpError(
|
||||
JSONRPC_INVALID_PARAMS if int(exc.status_code or 500) < 500 else JSONRPC_INTERNAL_ERROR,
|
||||
str(exc.detail or "HTTP error"),
|
||||
{"httpStatus": int(exc.status_code or 500)},
|
||||
)
|
||||
if isinstance(exc, (ValueError, TypeError, KeyError)):
|
||||
return McpError(JSONRPC_INVALID_PARAMS, str(exc) or "Invalid params")
|
||||
return McpError(JSONRPC_INTERNAL_ERROR, "Internal error", {"detail": str(exc)})
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .errors import (
|
||||
JSONRPC_INVALID_REQUEST,
|
||||
JSONRPC_METHOD_NOT_FOUND,
|
||||
JSONRPC_PARSE_ERROR,
|
||||
McpError,
|
||||
error_from_exception,
|
||||
)
|
||||
from .registry import McpToolContext, McpToolRegistry
|
||||
|
||||
PROTOCOL_VERSION = "2025-06-18"
|
||||
|
||||
|
||||
def jsonrpc_result(request_id: Any, result: Any) -> dict[str, Any]:
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
|
||||
|
||||
def jsonrpc_error(request_id: Any, error: McpError) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"code": error.code, "message": error.message}
|
||||
if error.data is not None:
|
||||
payload["data"] = error.data
|
||||
return {"jsonrpc": "2.0", "id": request_id, "error": payload}
|
||||
|
||||
|
||||
def initialize_result() -> dict[str, Any]:
|
||||
return {
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
"serverInfo": {"name": "wechat-data-analysis-mcp", "version": "1.0.0"},
|
||||
"instructions": (
|
||||
"Use this MCP server to inspect local WeChatDataAnalysis data. "
|
||||
"Prefer resolve tools before broad message queries. Keep list limits small and expand details only when needed."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def handle_jsonrpc_payload(payload: Any, registry: McpToolRegistry, context: McpToolContext) -> Any:
|
||||
if isinstance(payload, list):
|
||||
if not payload:
|
||||
return jsonrpc_error(None, McpError(JSONRPC_INVALID_REQUEST, "Invalid Request"))
|
||||
responses = []
|
||||
for item in payload:
|
||||
response = await handle_jsonrpc_request(item, registry, context)
|
||||
if response is not None:
|
||||
responses.append(response)
|
||||
return responses or None
|
||||
return await handle_jsonrpc_request(payload, registry, context)
|
||||
|
||||
|
||||
async def handle_jsonrpc_request(request_obj: Any, registry: McpToolRegistry, context: McpToolContext) -> dict[str, Any] | None:
|
||||
if not isinstance(request_obj, dict):
|
||||
return jsonrpc_error(None, McpError(JSONRPC_INVALID_REQUEST, "Invalid Request"))
|
||||
|
||||
if "method" not in request_obj:
|
||||
if request_obj.get("jsonrpc") == "2.0" and ("result" in request_obj or "error" in request_obj):
|
||||
return None
|
||||
return jsonrpc_error(request_obj.get("id"), McpError(JSONRPC_INVALID_REQUEST, "Invalid Request"))
|
||||
|
||||
request_id = request_obj.get("id")
|
||||
is_notification = "id" not in request_obj
|
||||
method_value = request_obj.get("method")
|
||||
method = method_value.strip() if isinstance(method_value, str) else ""
|
||||
if request_obj.get("jsonrpc") != "2.0" or not method:
|
||||
return None if is_notification else jsonrpc_error(request_id, McpError(JSONRPC_INVALID_REQUEST, "Invalid Request"))
|
||||
|
||||
if is_notification and method == "notifications/initialized":
|
||||
return None
|
||||
|
||||
try:
|
||||
params = request_obj.get("params")
|
||||
if method == "initialize":
|
||||
result = initialize_result()
|
||||
elif method == "ping":
|
||||
result = {}
|
||||
elif method == "tools/list":
|
||||
params_dict = params if isinstance(params, dict) else {}
|
||||
result = registry.list_tools(
|
||||
cursor=params_dict.get("cursor"),
|
||||
limit=params_dict.get("limit"),
|
||||
)
|
||||
elif method == "tools/call":
|
||||
if not isinstance(params, dict):
|
||||
raise ValueError("params is required.")
|
||||
name = str(params.get("name") or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Tool name is required.")
|
||||
arguments = params["arguments"] if "arguments" in params else {}
|
||||
result = await registry.call_tool(name, arguments, context)
|
||||
elif registry.has_tool(method):
|
||||
result = await registry.call_tool(method, {} if params is None else params, context)
|
||||
else:
|
||||
raise McpError(JSONRPC_METHOD_NOT_FOUND, "Method not found")
|
||||
return None if is_notification else jsonrpc_result(request_id, result)
|
||||
except Exception as exc:
|
||||
return None if is_notification else jsonrpc_error(request_id, error_from_exception(exc))
|
||||
|
||||
|
||||
def parse_error_response() -> dict[str, Any]:
|
||||
return jsonrpc_error(None, McpError(JSONRPC_PARSE_ERROR, "Parse error"))
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
from .errors import JSONRPC_METHOD_NOT_FOUND, McpError
|
||||
|
||||
|
||||
ToolHandler = Callable[[dict[str, Any], "McpToolContext"], Any | Awaitable[Any]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class McpToolContext:
|
||||
request: Any
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
try:
|
||||
return str(self.request.base_url).rstrip("/")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class McpTool:
|
||||
name: str
|
||||
description: str
|
||||
input_schema: dict[str, Any]
|
||||
handler: ToolHandler
|
||||
package: str = "wechat"
|
||||
annotations: dict[str, Any] | None = None
|
||||
|
||||
def to_public_dict(self) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"inputSchema": self.input_schema,
|
||||
}
|
||||
if self.annotations:
|
||||
payload["annotations"] = self.annotations
|
||||
return payload
|
||||
|
||||
|
||||
class McpToolRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._tools: dict[str, McpTool] = {}
|
||||
|
||||
def register(self, tool: McpTool) -> None:
|
||||
if not tool.name:
|
||||
raise ValueError("Tool name is required.")
|
||||
if tool.name in self._tools:
|
||||
raise ValueError(f"Duplicate MCP tool: {tool.name}")
|
||||
self._tools[tool.name] = tool
|
||||
|
||||
def tool_names(self) -> list[str]:
|
||||
return sorted(self._tools)
|
||||
|
||||
def list_tools(self, *, cursor: str | None = None, limit: int | None = None) -> dict[str, Any]:
|
||||
names = sorted(self._tools)
|
||||
start = 0
|
||||
if cursor:
|
||||
try:
|
||||
start = int(cursor)
|
||||
except Exception as exc:
|
||||
raise ValueError("Invalid cursor.") from exc
|
||||
if start < 0 or start > len(names):
|
||||
raise ValueError("Invalid cursor.")
|
||||
|
||||
if limit is None:
|
||||
page_names = names[start:]
|
||||
next_cursor = None
|
||||
else:
|
||||
page_size = max(1, min(100, int(limit)))
|
||||
page_names = names[start : start + page_size]
|
||||
next_index = start + page_size
|
||||
next_cursor = str(next_index) if next_index < len(names) else None
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"tools": [self._tools[name].to_public_dict() for name in page_names],
|
||||
"count": len(page_names),
|
||||
"total": len(names),
|
||||
}
|
||||
if next_cursor is not None:
|
||||
payload["nextCursor"] = next_cursor
|
||||
return payload
|
||||
|
||||
def has_tool(self, name: str) -> bool:
|
||||
return name in self._tools
|
||||
|
||||
async def call_tool(self, name: str, arguments: Any, context: McpToolContext) -> dict[str, Any]:
|
||||
tool = self._tools.get(str(name or "").strip())
|
||||
if tool is None:
|
||||
raise McpError(JSONRPC_METHOD_NOT_FOUND, f"Unknown tool: {name}")
|
||||
if arguments is None:
|
||||
args: dict[str, Any] = {}
|
||||
elif isinstance(arguments, dict):
|
||||
args = dict(arguments)
|
||||
else:
|
||||
raise ValueError("Tool arguments must be an object.")
|
||||
|
||||
result = tool.handler(args, context)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
encoded = jsonable_encoder(result)
|
||||
text = json.dumps(encoded, ensure_ascii=False, indent=2)
|
||||
is_error = isinstance(encoded, dict) and str(encoded.get("status") or "").lower() == "error"
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"structuredContent": encoded,
|
||||
"isError": is_error,
|
||||
}
|
||||
|
||||
|
||||
def object_schema(
|
||||
properties: dict[str, Any] | None = None,
|
||||
*,
|
||||
required: list[str] | None = None,
|
||||
additional_properties: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
schema: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": properties or {},
|
||||
"additionalProperties": additional_properties,
|
||||
}
|
||||
if required:
|
||||
schema["required"] = required
|
||||
return schema
|
||||
|
||||
|
||||
def string_schema(description: str, *, enum: list[str] | None = None) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"type": "string", "description": description}
|
||||
if enum:
|
||||
out["enum"] = enum
|
||||
return out
|
||||
|
||||
|
||||
def int_schema(description: str, *, minimum: int | None = None, maximum: int | None = None) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"type": "integer", "description": description}
|
||||
if minimum is not None:
|
||||
out["minimum"] = minimum
|
||||
if maximum is not None:
|
||||
out["maximum"] = maximum
|
||||
return out
|
||||
|
||||
|
||||
def bool_schema(description: str, *, default: bool | None = None) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"type": "boolean", "description": description}
|
||||
if default is not None:
|
||||
out["default"] = default
|
||||
return out
|
||||
|
||||
|
||||
def array_schema(description: str, items: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"type": "array", "description": description, "items": items}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
|
||||
|
||||
_VIRTUAL_INTERFACE_MARKERS = (
|
||||
"docker",
|
||||
"hyper-v",
|
||||
"loopback",
|
||||
"npcap",
|
||||
"tailscale",
|
||||
"virtual",
|
||||
"virtualbox",
|
||||
"vmware",
|
||||
"vethernet",
|
||||
"wsl",
|
||||
"zerotier",
|
||||
)
|
||||
|
||||
_PREFERRED_INTERFACE_MARKERS = (
|
||||
"ethernet",
|
||||
"wi-fi",
|
||||
"wifi",
|
||||
"wireless",
|
||||
"wlan",
|
||||
"以太",
|
||||
"无线",
|
||||
)
|
||||
|
||||
|
||||
def _parse_ipv4(value: object) -> ipaddress.IPv4Address | None:
|
||||
try:
|
||||
ip = ipaddress.ip_address(str(value or "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return ip if isinstance(ip, ipaddress.IPv4Address) else None
|
||||
|
||||
|
||||
def _is_reachable_client_ipv4(ip: ipaddress.IPv4Address) -> bool:
|
||||
return not (
|
||||
ip.is_loopback
|
||||
or ip.is_unspecified
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_reserved
|
||||
)
|
||||
|
||||
|
||||
def _interface_penalty(name: str) -> int:
|
||||
lower = str(name or "").lower()
|
||||
if any(marker in lower for marker in _VIRTUAL_INTERFACE_MARKERS):
|
||||
return 30
|
||||
if any(marker in lower for marker in _PREFERRED_INTERFACE_MARKERS):
|
||||
return 0
|
||||
return 10
|
||||
|
||||
|
||||
def _add_candidate(
|
||||
candidates: list[tuple[int, int, int, str]],
|
||||
seen: set[str],
|
||||
value: object,
|
||||
*,
|
||||
interface_name: str = "",
|
||||
source_order: int = 0,
|
||||
) -> None:
|
||||
ip = _parse_ipv4(value)
|
||||
if not ip or not _is_reachable_client_ipv4(ip):
|
||||
return
|
||||
|
||||
text = str(ip)
|
||||
if text in seen:
|
||||
return
|
||||
seen.add(text)
|
||||
|
||||
private_rank = 0 if ip.is_private else 1
|
||||
candidates.append((private_rank, _interface_penalty(interface_name), source_order, text))
|
||||
|
||||
|
||||
def _add_psutil_candidates(candidates: list[tuple[int, int, int, str]], seen: set[str]) -> None:
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
stats_by_name = psutil.net_if_stats()
|
||||
interfaces = psutil.net_if_addrs()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for interface_name, addresses in interfaces.items():
|
||||
try:
|
||||
stats = stats_by_name.get(interface_name)
|
||||
if stats is not None and not bool(getattr(stats, "isup", False)):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for addr in addresses:
|
||||
try:
|
||||
if getattr(addr, "family", None) != socket.AF_INET:
|
||||
continue
|
||||
_add_candidate(
|
||||
candidates,
|
||||
seen,
|
||||
getattr(addr, "address", ""),
|
||||
interface_name=interface_name,
|
||||
source_order=0,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _add_route_candidates(candidates: list[tuple[int, int, int, str]], seen: set[str]) -> None:
|
||||
# UDP connect 不会实际发包,只用于询问系统默认出站路由会使用哪个本机地址。
|
||||
for target in ("223.5.5.5", "8.8.8.8", "1.1.1.1"):
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.settimeout(0.2)
|
||||
sock.connect((target, 80))
|
||||
local_ip = sock.getsockname()[0]
|
||||
except Exception:
|
||||
continue
|
||||
_add_candidate(candidates, seen, local_ip, interface_name="", source_order=1)
|
||||
|
||||
|
||||
def _add_hostname_candidates(candidates: list[tuple[int, int, int, str]], seen: set[str]) -> None:
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
_, _, addresses = socket.gethostbyname_ex(hostname)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for address in addresses:
|
||||
_add_candidate(candidates, seen, address, interface_name="", source_order=2)
|
||||
|
||||
|
||||
def get_lan_access_host(default: str = "127.0.0.1") -> str:
|
||||
"""返回同网段设备可访问的本机 IPv4 地址。"""
|
||||
|
||||
candidates: list[tuple[int, int, int, str]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
_add_psutil_candidates(candidates, seen)
|
||||
_add_route_candidates(candidates, seen)
|
||||
_add_hostname_candidates(candidates, seen)
|
||||
|
||||
if not candidates:
|
||||
return default
|
||||
|
||||
candidates.sort()
|
||||
return candidates[0][3]
|
||||
@@ -14,8 +14,21 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from ..logging_config import get_log_file_path, get_logger
|
||||
from ..network_access import get_lan_access_host
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..runtime_settings import read_effective_backend_port, write_backend_port_env_file, write_backend_port_setting
|
||||
from ..runtime_settings import (
|
||||
LAN_BACKEND_HOST,
|
||||
LOOPBACK_BACKEND_HOST,
|
||||
read_effective_backend_host,
|
||||
read_effective_mcp_token,
|
||||
read_effective_backend_port,
|
||||
reset_mcp_token,
|
||||
write_backend_host_env_file,
|
||||
write_backend_host_setting,
|
||||
write_backend_port_env_file,
|
||||
write_backend_port_setting,
|
||||
write_mcp_token_env_file,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
@@ -33,7 +46,8 @@ def _format_host_for_url(host: str) -> str:
|
||||
|
||||
|
||||
def _get_backend_bind_host() -> str:
|
||||
return str(os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1") or "").strip() or "127.0.0.1"
|
||||
host, _ = read_effective_backend_host(default=LOOPBACK_BACKEND_HOST)
|
||||
return host
|
||||
|
||||
|
||||
def _get_backend_access_host() -> str:
|
||||
@@ -43,6 +57,28 @@ def _get_backend_access_host() -> str:
|
||||
return host
|
||||
|
||||
|
||||
def _get_mcp_access_host(bind_host: str | None = None) -> str:
|
||||
host = str(bind_host or _get_backend_bind_host() or "").strip()
|
||||
if host in {LAN_BACKEND_HOST, "::"}:
|
||||
return get_lan_access_host(default=LOOPBACK_BACKEND_HOST)
|
||||
return host or LOOPBACK_BACKEND_HOST
|
||||
|
||||
|
||||
def _get_mcp_access_urls(port: int, bind_host: str | None = None) -> dict:
|
||||
access_host = _get_mcp_access_host(bind_host)
|
||||
origin = f"http://{_format_host_for_url(access_host)}:{int(port)}"
|
||||
return {
|
||||
"access_host": access_host,
|
||||
"accessHost": access_host,
|
||||
"mcp_endpoint": f"{origin}/mcp",
|
||||
"mcpEndpoint": f"{origin}/mcp",
|
||||
"skill_bundle_url": f"{origin}/mcp/skill/bundle",
|
||||
"skillBundleUrl": f"{origin}/mcp/skill/bundle",
|
||||
"skill_markdown_url": f"{origin}/mcp/skill",
|
||||
"skillMarkdownUrl": f"{origin}/mcp/skill",
|
||||
}
|
||||
|
||||
|
||||
def _is_loopback_client(request: Request) -> bool:
|
||||
client = request.client
|
||||
host = str(getattr(client, "host", "") or "").strip()
|
||||
@@ -116,10 +152,10 @@ async def _wait_for_backend_ready(health_url: str, timeout_s: float = 30.0) -> b
|
||||
return False
|
||||
|
||||
|
||||
def _spawn_backend_process(next_port: int) -> subprocess.Popen:
|
||||
def _spawn_backend_process(next_port: int, next_host: str | None = None) -> subprocess.Popen:
|
||||
env = os.environ.copy()
|
||||
env["WECHAT_TOOL_PORT"] = str(int(next_port))
|
||||
env.setdefault("WECHAT_TOOL_HOST", _get_backend_bind_host())
|
||||
env["WECHAT_TOOL_HOST"] = str(next_host or _get_backend_bind_host())
|
||||
|
||||
# Keep the same working directory so output paths remain consistent.
|
||||
# (When `WECHAT_TOOL_DATA_DIR` is not set, the app uses `Path.cwd()`.)
|
||||
@@ -150,6 +186,40 @@ def _spawn_backend_process(next_port: int) -> subprocess.Popen:
|
||||
return subprocess.Popen(cmd, cwd=spawn_cwd, env=env)
|
||||
|
||||
|
||||
def _spawn_backend_process_after_delay(next_port: int, next_host: str, delay_s: float = 0.8) -> subprocess.Popen:
|
||||
env = os.environ.copy()
|
||||
env["WECHAT_TOOL_PORT"] = str(int(next_port))
|
||||
env["WECHAT_TOOL_HOST"] = str(next_host or _get_backend_bind_host())
|
||||
|
||||
cwd = os.getcwd()
|
||||
cwd_path = Path(cwd)
|
||||
src_dir = cwd_path / "src"
|
||||
try:
|
||||
existing_pp = str(env.get("PYTHONPATH", "") or "").strip()
|
||||
if src_dir.is_dir():
|
||||
env["PYTHONPATH"] = str(src_dir) if not existing_pp else f"{src_dir}{os.pathsep}{existing_pp}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
target = [sys.executable]
|
||||
else:
|
||||
main_py = cwd_path / "main.py"
|
||||
if main_py.is_file():
|
||||
target = [sys.executable, str(main_py)]
|
||||
else:
|
||||
target = [sys.executable, "-m", "wechat_decrypt_tool.backend_entry"]
|
||||
|
||||
# Keep the launcher independent from this process; it starts the backend after
|
||||
# the current process has released its listening socket.
|
||||
launcher_code = (
|
||||
"import os,subprocess,sys,time;"
|
||||
f"time.sleep({max(0.0, float(delay_s))!r});"
|
||||
"subprocess.Popen(sys.argv[1:], cwd=os.getcwd(), env=os.environ)"
|
||||
)
|
||||
return subprocess.Popen([sys.executable, "-c", launcher_code, *target], cwd=cwd, env=env)
|
||||
|
||||
|
||||
async def _exit_process_after(delay_s: float) -> None:
|
||||
try:
|
||||
await asyncio.sleep(max(0.0, float(delay_s)))
|
||||
@@ -212,6 +282,59 @@ async def get_backend_port() -> dict:
|
||||
return {"port": port, "source": source, "default_port": DEFAULT_BACKEND_PORT}
|
||||
|
||||
|
||||
@router.get("/api/admin/mcp-access", summary="获取 MCP 局域网接入状态")
|
||||
async def get_mcp_access() -> dict:
|
||||
host, source = read_effective_backend_host(default=LOOPBACK_BACKEND_HOST)
|
||||
port, port_source = read_effective_backend_port(default=DEFAULT_BACKEND_PORT)
|
||||
return {
|
||||
"enabled": host == LAN_BACKEND_HOST,
|
||||
"host": host,
|
||||
"source": source,
|
||||
"port": port,
|
||||
"port_source": port_source,
|
||||
"default_host": LOOPBACK_BACKEND_HOST,
|
||||
"lan_host": LAN_BACKEND_HOST,
|
||||
"restart_required": False,
|
||||
**_get_mcp_access_urls(port, host),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/admin/mcp-token", summary="获取 MCP token(仅允许本机访问)")
|
||||
async def get_mcp_token(request: Request) -> dict:
|
||||
if not _is_loopback_client(request):
|
||||
raise HTTPException(status_code=403, detail="仅允许本机访问该接口")
|
||||
|
||||
from ..runtime_settings import ensure_mcp_token
|
||||
|
||||
token, source = ensure_mcp_token()
|
||||
env_file = write_mcp_token_env_file(token)
|
||||
return {
|
||||
"success": True,
|
||||
"token": token,
|
||||
"source": source,
|
||||
"env_file": str(env_file) if env_file else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/admin/mcp-token/reset", summary="重置 MCP token(仅允许本机访问)")
|
||||
async def reset_mcp_token_endpoint(request: Request) -> dict:
|
||||
if not _is_loopback_client(request):
|
||||
raise HTTPException(status_code=403, detail="仅允许本机访问该接口")
|
||||
|
||||
previous, previous_source = read_effective_mcp_token()
|
||||
token = reset_mcp_token()
|
||||
os.environ["WECHAT_TOOL_MCP_TOKEN"] = token
|
||||
env_file = write_mcp_token_env_file(token)
|
||||
return {
|
||||
"success": True,
|
||||
"changed": token != previous,
|
||||
"token": token,
|
||||
"previous_source": previous_source,
|
||||
"source": "reset",
|
||||
"env_file": str(env_file) if env_file else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/admin/port", summary="修改后端端口并重启(仅允许本机访问)")
|
||||
async def set_backend_port(payload: dict, request: Request, background_tasks: BackgroundTasks) -> dict:
|
||||
if not _is_loopback_client(request):
|
||||
@@ -281,3 +404,56 @@ async def set_backend_port(payload: dict, request: Request, background_tasks: Ba
|
||||
}
|
||||
finally:
|
||||
_PORT_CHANGE_IN_PROGRESS = False
|
||||
|
||||
|
||||
@router.post("/api/admin/mcp-access", summary="开启或关闭 MCP 局域网接入并重启后端(仅允许本机访问)")
|
||||
async def set_mcp_access(payload: dict, request: Request, background_tasks: BackgroundTasks) -> dict:
|
||||
if not _is_loopback_client(request):
|
||||
raise HTTPException(status_code=403, detail="仅允许本机访问该接口")
|
||||
|
||||
global _PORT_CHANGE_IN_PROGRESS
|
||||
if _PORT_CHANGE_IN_PROGRESS:
|
||||
raise HTTPException(status_code=409, detail="后端切换中,请稍后重试")
|
||||
|
||||
enabled = bool(payload.get("enabled")) if isinstance(payload, dict) else False
|
||||
next_host = LAN_BACKEND_HOST if enabled else LOOPBACK_BACKEND_HOST
|
||||
current_host = _get_backend_bind_host()
|
||||
current_port, _ = read_effective_backend_port(default=DEFAULT_BACKEND_PORT)
|
||||
|
||||
if next_host == current_host:
|
||||
write_backend_host_setting(next_host)
|
||||
env_file = write_backend_host_env_file(next_host)
|
||||
return {
|
||||
"success": True,
|
||||
"changed": False,
|
||||
"enabled": enabled,
|
||||
"host": next_host,
|
||||
"port": int(current_port),
|
||||
"ui_url": f"http://{_format_host_for_url(_get_backend_access_host())}:{int(current_port)}/",
|
||||
"env_file": str(env_file) if env_file else None,
|
||||
**_get_mcp_access_urls(int(current_port), next_host),
|
||||
}
|
||||
|
||||
_PORT_CHANGE_IN_PROGRESS = True
|
||||
try:
|
||||
write_backend_host_setting(next_host)
|
||||
env_file = write_backend_host_env_file(next_host)
|
||||
|
||||
# Host changes keep the same port. The old socket must close before the
|
||||
# new process can bind, so start a detached launcher and then exit.
|
||||
background_tasks.add_task(_spawn_backend_process_after_delay, int(current_port), next_host, 0.8)
|
||||
background_tasks.add_task(_exit_process_after, 0.2)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"changed": True,
|
||||
"enabled": enabled,
|
||||
"host": next_host,
|
||||
"port": int(current_port),
|
||||
"ui_url": f"http://{_format_host_for_url(LOOPBACK_BACKEND_HOST)}:{int(current_port)}/",
|
||||
"env_file": str(env_file) if env_file else None,
|
||||
"restart_scheduled": True,
|
||||
**_get_mcp_access_urls(int(current_port), next_host),
|
||||
}
|
||||
finally:
|
||||
_PORT_CHANGE_IN_PROGRESS = False
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..chat_export_service import CHAT_EXPORT_MANAGER
|
||||
from ..chat_export_service import CHAT_EXPORT_MANAGER, get_chat_export_targets_preview
|
||||
from ..path_fix import PathFixRoute
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
@@ -101,6 +101,22 @@ async def list_chat_exports():
|
||||
return {"status": "success", "jobs": jobs}
|
||||
|
||||
|
||||
@router.get("/api/chat/exports/targets", summary="获取聊天记录导出目标预览")
|
||||
async def preview_chat_export_targets(
|
||||
request: Request,
|
||||
account: Optional[str] = None,
|
||||
include_hidden: bool = True,
|
||||
include_official: bool = False,
|
||||
):
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
return get_chat_export_targets_preview(
|
||||
account=account,
|
||||
include_hidden=bool(include_hidden),
|
||||
include_official=bool(include_official),
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/chat/exports/{export_id}", summary="获取导出任务状态")
|
||||
async def get_chat_export(export_id: str):
|
||||
job = CHAT_EXPORT_MANAGER.get_job(str(export_id or "").strip())
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
|
||||
from ..mcp.protocol import handle_jsonrpc_payload, parse_error_response
|
||||
from ..mcp.registry import McpToolContext
|
||||
from ..mcp.tools import MCP_REGISTRY
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..runtime_settings import ensure_mcp_token
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
SKILL_ROOT = PROJECT_ROOT / "skills" / "wechat-mcp-copilot"
|
||||
|
||||
|
||||
def _extract_mcp_token(request: Request) -> str:
|
||||
auth = str(request.headers.get("authorization") or "").strip()
|
||||
if auth.lower().startswith("bearer "):
|
||||
return auth[7:].strip()
|
||||
|
||||
header_token = str(request.headers.get("x-mcp-token") or "").strip()
|
||||
if header_token:
|
||||
return header_token
|
||||
|
||||
return str(request.query_params.get("token") or "").strip()
|
||||
|
||||
|
||||
def _mcp_unauthorized() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{"status": "error", "message": "Invalid or missing MCP token."},
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def _verify_mcp_token(request: Request) -> bool:
|
||||
expected, _ = ensure_mcp_token()
|
||||
provided = _extract_mcp_token(request)
|
||||
if not expected or not provided:
|
||||
return False
|
||||
return hmac.compare_digest(provided, expected)
|
||||
|
||||
|
||||
def _read_skill_bundle() -> dict[str, Any]:
|
||||
entry_path = SKILL_ROOT / "SKILL.md"
|
||||
if not entry_path.is_file():
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Skill not found.",
|
||||
"path": str(entry_path),
|
||||
}
|
||||
|
||||
references = []
|
||||
bundle_parts = [entry_path.read_text(encoding="utf-8")]
|
||||
references_dir = SKILL_ROOT / "references"
|
||||
if references_dir.is_dir():
|
||||
for ref_path in sorted(references_dir.glob("*.md")):
|
||||
content = ref_path.read_text(encoding="utf-8")
|
||||
rel_path = ref_path.relative_to(SKILL_ROOT).as_posix()
|
||||
references.append({"path": rel_path, "content": content})
|
||||
bundle_parts.append(f"\n\n---\n# {rel_path}\n\n{content}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"name": "wechat-mcp-copilot",
|
||||
"version": "1.0.0",
|
||||
"entry": "SKILL.md",
|
||||
"entryContent": bundle_parts[0],
|
||||
"references": references,
|
||||
"bundleText": "".join(bundle_parts),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/mcp", summary="MCP endpoint")
|
||||
async def mcp_get(request: Request):
|
||||
if not _verify_mcp_token(request):
|
||||
return _mcp_unauthorized()
|
||||
return PlainTextResponse("Use POST with JSON-RPC 2.0.", status_code=405, headers={"Allow": "POST"})
|
||||
|
||||
|
||||
@router.get("/mcp/skill/bundle", summary="MCP skill bundle")
|
||||
async def mcp_skill_bundle(request: Request):
|
||||
if not _verify_mcp_token(request):
|
||||
return _mcp_unauthorized()
|
||||
payload = _read_skill_bundle()
|
||||
status_code = 200 if payload.get("status") == "success" else 404
|
||||
return JSONResponse(payload, status_code=status_code)
|
||||
|
||||
|
||||
@router.get("/mcp/skill", summary="MCP skill text")
|
||||
async def mcp_skill_text(request: Request):
|
||||
if not _verify_mcp_token(request):
|
||||
return _mcp_unauthorized()
|
||||
payload = _read_skill_bundle()
|
||||
if payload.get("status") != "success":
|
||||
return PlainTextResponse(str(payload.get("message") or "Skill not found."), status_code=404)
|
||||
return PlainTextResponse(str(payload.get("bundleText") or ""), media_type="text/markdown; charset=utf-8")
|
||||
|
||||
|
||||
@router.post("/mcp", summary="MCP JSON-RPC endpoint")
|
||||
async def mcp_post(request: Request):
|
||||
if not _verify_mcp_token(request):
|
||||
return _mcp_unauthorized()
|
||||
|
||||
try:
|
||||
payload: Any = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(parse_error_response(), status_code=400)
|
||||
|
||||
result = await handle_jsonrpc_payload(payload, MCP_REGISTRY, McpToolContext(request=request))
|
||||
if result is None:
|
||||
return PlainTextResponse("", status_code=202)
|
||||
return JSONResponse(result)
|
||||
@@ -3,14 +3,21 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RUNTIME_SETTINGS_FILENAME = "runtime_settings.json"
|
||||
BACKEND_PORT_KEY = "backend_port"
|
||||
BACKEND_HOST_KEY = "backend_host"
|
||||
MCP_TOKEN_KEY = "mcp_token"
|
||||
ENV_PORT_KEY = "WECHAT_TOOL_PORT"
|
||||
ENV_HOST_KEY = "WECHAT_TOOL_HOST"
|
||||
ENV_MCP_TOKEN_KEY = "WECHAT_TOOL_MCP_TOKEN"
|
||||
ENV_FILE_KEY = "WECHAT_TOOL_ENV_FILE"
|
||||
DEFAULT_ENV_FILENAME = ".env"
|
||||
LOOPBACK_BACKEND_HOST = "127.0.0.1"
|
||||
LAN_BACKEND_HOST = "0.0.0.0"
|
||||
|
||||
|
||||
def _parse_port(value: object) -> int | None:
|
||||
@@ -31,6 +38,66 @@ def _parse_port(value: object) -> int | None:
|
||||
return port
|
||||
|
||||
|
||||
def _normalize_host(value: object) -> str | None:
|
||||
try:
|
||||
raw = str(value or "").strip()
|
||||
except Exception:
|
||||
return None
|
||||
if raw in {LOOPBACK_BACKEND_HOST, "localhost", "::1"}:
|
||||
return LOOPBACK_BACKEND_HOST
|
||||
if raw in {LAN_BACKEND_HOST, "::"}:
|
||||
return LAN_BACKEND_HOST
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_mcp_token(value: object) -> str | None:
|
||||
try:
|
||||
raw = str(value or "").strip()
|
||||
except Exception:
|
||||
return None
|
||||
if len(raw) < 16 or len(raw) > 512:
|
||||
return None
|
||||
if any(ch.isspace() for ch in raw):
|
||||
return None
|
||||
return raw
|
||||
|
||||
|
||||
def generate_mcp_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _read_runtime_settings() -> dict:
|
||||
path = get_runtime_settings_path()
|
||||
try:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
data = json.loads(path.read_text(encoding="utf-8") or "{}")
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _write_runtime_settings(data: dict) -> None:
|
||||
path = get_runtime_settings_path()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
cleaned = data if isinstance(data, dict) else {}
|
||||
if not cleaned:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
path.write_text(json.dumps(cleaned, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def get_runtime_settings_path() -> Path:
|
||||
from .app_paths import get_output_dir
|
||||
|
||||
@@ -38,50 +105,24 @@ def get_runtime_settings_path() -> Path:
|
||||
|
||||
|
||||
def read_backend_port_setting() -> int | None:
|
||||
path = get_runtime_settings_path()
|
||||
try:
|
||||
if not path.is_file():
|
||||
return None
|
||||
data = json.loads(path.read_text(encoding="utf-8") or "{}")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
data = _read_runtime_settings()
|
||||
return _parse_port(data.get(BACKEND_PORT_KEY))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def write_backend_port_setting(port: int | None) -> None:
|
||||
path = get_runtime_settings_path()
|
||||
safe_port = _parse_port(port)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
data: dict = {}
|
||||
if path.is_file():
|
||||
try:
|
||||
existing = json.loads(path.read_text(encoding="utf-8") or "{}")
|
||||
if isinstance(existing, dict):
|
||||
data = existing
|
||||
except Exception:
|
||||
data = {}
|
||||
data = _read_runtime_settings()
|
||||
|
||||
if safe_port is None:
|
||||
data.pop(BACKEND_PORT_KEY, None)
|
||||
else:
|
||||
data[BACKEND_PORT_KEY] = safe_port
|
||||
|
||||
# Keep the file small and stable; remove if empty.
|
||||
if not data:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
_write_runtime_settings(data)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
@@ -101,6 +142,92 @@ def read_effective_backend_port(default: int) -> tuple[int, str]:
|
||||
return int(default), "default"
|
||||
|
||||
|
||||
def read_backend_host_setting() -> str | None:
|
||||
try:
|
||||
data = _read_runtime_settings()
|
||||
return _normalize_host(data.get(BACKEND_HOST_KEY))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def write_backend_host_setting(host: str | None) -> None:
|
||||
safe_host = _normalize_host(host)
|
||||
try:
|
||||
data = _read_runtime_settings()
|
||||
if safe_host is None:
|
||||
data.pop(BACKEND_HOST_KEY, None)
|
||||
else:
|
||||
data[BACKEND_HOST_KEY] = safe_host
|
||||
_write_runtime_settings(data)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def read_effective_backend_host(default: str = LOOPBACK_BACKEND_HOST) -> tuple[str, str]:
|
||||
"""Return (host, source) where source is one of: env | settings | default."""
|
||||
|
||||
env_host = _normalize_host(os.environ.get(ENV_HOST_KEY, ""))
|
||||
if env_host is not None:
|
||||
return env_host, "env"
|
||||
|
||||
settings_host = read_backend_host_setting()
|
||||
if settings_host is not None:
|
||||
return settings_host, "settings"
|
||||
|
||||
return _normalize_host(default) or LOOPBACK_BACKEND_HOST, "default"
|
||||
|
||||
|
||||
def read_mcp_token_setting() -> str | None:
|
||||
try:
|
||||
data = _read_runtime_settings()
|
||||
return _normalize_mcp_token(data.get(MCP_TOKEN_KEY))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def write_mcp_token_setting(token: str | None) -> None:
|
||||
safe_token = _normalize_mcp_token(token)
|
||||
try:
|
||||
data = _read_runtime_settings()
|
||||
if safe_token is None:
|
||||
data.pop(MCP_TOKEN_KEY, None)
|
||||
else:
|
||||
data[MCP_TOKEN_KEY] = safe_token
|
||||
_write_runtime_settings(data)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def read_effective_mcp_token() -> tuple[str | None, str]:
|
||||
"""Return (token, source) where source is one of: env | settings | missing."""
|
||||
|
||||
env_token = _normalize_mcp_token(os.environ.get(ENV_MCP_TOKEN_KEY, ""))
|
||||
if env_token is not None:
|
||||
return env_token, "env"
|
||||
|
||||
settings_token = read_mcp_token_setting()
|
||||
if settings_token is not None:
|
||||
return settings_token, "settings"
|
||||
|
||||
return None, "missing"
|
||||
|
||||
|
||||
def ensure_mcp_token() -> tuple[str, str]:
|
||||
token, source = read_effective_mcp_token()
|
||||
if token:
|
||||
return token, source
|
||||
|
||||
token = generate_mcp_token()
|
||||
write_mcp_token_setting(token)
|
||||
return token, "generated"
|
||||
|
||||
|
||||
def reset_mcp_token() -> str:
|
||||
token = generate_mcp_token()
|
||||
write_mcp_token_setting(token)
|
||||
return token
|
||||
|
||||
|
||||
def get_env_file_path() -> Path | None:
|
||||
"""Best-effort env file path for `uv run` (defaults to repo root `.env`)."""
|
||||
|
||||
@@ -173,3 +300,27 @@ def write_backend_port_env_file(port: int | None) -> Path | None:
|
||||
safe_port = _parse_port(port)
|
||||
ok = _set_env_var_in_file(env_file, ENV_PORT_KEY, str(safe_port) if safe_port is not None else None)
|
||||
return env_file if ok else None
|
||||
|
||||
|
||||
def write_backend_host_env_file(host: str | None) -> Path | None:
|
||||
"""Write `WECHAT_TOOL_HOST` into a `.env` file so `uv run main.py` picks it up on restart."""
|
||||
|
||||
env_file = get_env_file_path()
|
||||
if not env_file:
|
||||
return None
|
||||
|
||||
safe_host = _normalize_host(host)
|
||||
ok = _set_env_var_in_file(env_file, ENV_HOST_KEY, safe_host)
|
||||
return env_file if ok else None
|
||||
|
||||
|
||||
def write_mcp_token_env_file(token: str | None) -> Path | None:
|
||||
"""Write `WECHAT_TOOL_MCP_TOKEN` into a `.env` file so `uv run main.py` picks it up."""
|
||||
|
||||
env_file = get_env_file_path()
|
||||
if not env_file:
|
||||
return None
|
||||
|
||||
safe_token = _normalize_mcp_token(token)
|
||||
ok = _set_env_var_in_file(env_file, ENV_MCP_TOKEN_KEY, safe_token)
|
||||
return env_file if ok else None
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestChatExportTargets(unittest.TestCase):
|
||||
def _seed_contact_db(self, path: Path, *, account: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
rows = [
|
||||
(account, "", "Me", "", 1, 0, "", ""),
|
||||
("wxid_visible", "", "Visible friend", "", 1, 0, "", ""),
|
||||
("wxid_no_session", "", "No session friend", "", 1, 0, "", ""),
|
||||
("wxid_session_hidden", "", "Hidden session friend", "", 1, 0, "", ""),
|
||||
("room_no_session@chatroom", "", "No session group", "", 1, 0, "", ""),
|
||||
("room_hidden@chatroom", "", "Hidden session group", "", 1, 0, "", ""),
|
||||
("gh_official_no_session", "", "Official account", "", 1, 24, "", ""),
|
||||
("wxid_no_messages", "", "No messages friend", "", 1, 0, "", ""),
|
||||
]
|
||||
conn.executemany("INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_session_db(self, path: Path) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT,
|
||||
is_hidden INTEGER,
|
||||
sort_timestamp INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("wxid_visible", 0, 100))
|
||||
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("wxid_session_hidden", 1, 200))
|
||||
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("room_hidden@chatroom", 1, 250))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_message_db(self, path: Path, *, account: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE Name2Id (rowid INTEGER PRIMARY KEY, user_name TEXT)")
|
||||
usernames = [
|
||||
account,
|
||||
"wxid_visible",
|
||||
"wxid_no_session",
|
||||
"wxid_session_hidden",
|
||||
"room_no_session@chatroom",
|
||||
"room_hidden@chatroom",
|
||||
"gh_official_no_session",
|
||||
"wxid_no_messages",
|
||||
]
|
||||
for idx, username in enumerate(usernames, start=1):
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (idx, username))
|
||||
|
||||
message_usernames = {
|
||||
"wxid_visible": 100,
|
||||
"wxid_no_session": 300,
|
||||
"wxid_session_hidden": 400,
|
||||
"room_no_session@chatroom": 350,
|
||||
"room_hidden@chatroom": 450,
|
||||
"gh_official_no_session": 360,
|
||||
}
|
||||
for username, create_time in message_usernames.items():
|
||||
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE {table_name} (
|
||||
local_id INTEGER,
|
||||
server_id INTEGER,
|
||||
local_type INTEGER,
|
||||
sort_seq INTEGER,
|
||||
real_sender_id INTEGER,
|
||||
create_time INTEGER,
|
||||
message_content TEXT,
|
||||
compress_content BLOB
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
f"INSERT INTO {table_name} VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(1, 1001, 1, 1, 2, create_time, f"message for {username}", None),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _prepare_account(self, root: Path) -> Path:
|
||||
account = "wxid_account"
|
||||
account_dir = root / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account)
|
||||
self._seed_session_db(account_dir / "session.db")
|
||||
self._seed_message_db(account_dir / "message_0.db", account=account)
|
||||
return account_dir
|
||||
|
||||
def test_all_scope_includes_contacts_with_messages_missing_from_session_list(self):
|
||||
import wechat_decrypt_tool.chat_export_service as svc
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = self._prepare_account(Path(td))
|
||||
|
||||
targets = svc._resolve_export_targets(
|
||||
account_dir=account_dir,
|
||||
scope="all",
|
||||
usernames=[],
|
||||
include_hidden=False,
|
||||
include_official=False,
|
||||
)
|
||||
|
||||
self.assertIn("wxid_visible", targets)
|
||||
self.assertIn("wxid_no_session", targets)
|
||||
self.assertIn("room_no_session@chatroom", targets)
|
||||
self.assertNotIn("wxid_session_hidden", targets)
|
||||
self.assertNotIn("room_hidden@chatroom", targets)
|
||||
self.assertNotIn("gh_official_no_session", targets)
|
||||
self.assertNotIn("wxid_no_messages", targets)
|
||||
|
||||
def test_group_single_and_official_filters_apply_to_message_discovered_targets(self):
|
||||
import wechat_decrypt_tool.chat_export_service as svc
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = self._prepare_account(Path(td))
|
||||
|
||||
groups = svc._resolve_export_targets(
|
||||
account_dir=account_dir,
|
||||
scope="groups",
|
||||
usernames=[],
|
||||
include_hidden=False,
|
||||
include_official=False,
|
||||
)
|
||||
singles = svc._resolve_export_targets(
|
||||
account_dir=account_dir,
|
||||
scope="singles",
|
||||
usernames=[],
|
||||
include_hidden=False,
|
||||
include_official=False,
|
||||
)
|
||||
with_official = svc._resolve_export_targets(
|
||||
account_dir=account_dir,
|
||||
scope="all",
|
||||
usernames=[],
|
||||
include_hidden=False,
|
||||
include_official=True,
|
||||
)
|
||||
|
||||
self.assertEqual(groups, ["room_no_session@chatroom"])
|
||||
self.assertIn("wxid_no_session", singles)
|
||||
self.assertNotIn("room_no_session@chatroom", singles)
|
||||
self.assertIn("gh_official_no_session", with_official)
|
||||
|
||||
def test_preview_counts_match_bulk_export_targets_including_hidden_sessions(self):
|
||||
import wechat_decrypt_tool.chat_export_service as svc
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = self._prepare_account(Path(td))
|
||||
|
||||
preview = svc.build_chat_export_targets_preview(
|
||||
account_dir=account_dir,
|
||||
include_hidden=True,
|
||||
include_official=False,
|
||||
base_url="http://example.test",
|
||||
)
|
||||
actual_targets = svc._resolve_export_targets(
|
||||
account_dir=account_dir,
|
||||
scope="all",
|
||||
usernames=[],
|
||||
include_hidden=True,
|
||||
include_official=False,
|
||||
)
|
||||
|
||||
preview_targets = preview["targets"]
|
||||
preview_usernames = [item["username"] for item in preview_targets]
|
||||
by_username = {item["username"]: item for item in preview_targets}
|
||||
|
||||
self.assertEqual(preview_usernames, actual_targets)
|
||||
self.assertEqual(preview["counts"], {"total": 5, "groups": 2, "singles": 3})
|
||||
self.assertTrue(by_username["room_hidden@chatroom"]["isHidden"])
|
||||
self.assertTrue(by_username["room_hidden@chatroom"]["inSessionList"])
|
||||
self.assertFalse(by_username["room_no_session@chatroom"]["inSessionList"])
|
||||
self.assertTrue(by_username["room_no_session@chatroom"]["avatar"].startswith("http://example.test/api/chat/avatar?"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,174 @@
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestChatSearchIndexTargets(unittest.TestCase):
|
||||
def _seed_contact_db(self, path: Path, *, account: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
rows = [
|
||||
(account, "", "Me", "", 1, 0, "", ""),
|
||||
("wxid_visible", "", "Visible friend", "", 1, 0, "", ""),
|
||||
("wxid_no_session", "", "No session friend", "", 1, 0, "", ""),
|
||||
("wxid_session_hidden", "", "Hidden session friend", "", 1, 0, "", ""),
|
||||
("gh_official_no_session", "", "Official account", "", 1, 24, "", ""),
|
||||
]
|
||||
conn.executemany("INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_session_db(self, path: Path) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT,
|
||||
is_hidden INTEGER,
|
||||
sort_timestamp INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("wxid_visible", 0, 100))
|
||||
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("wxid_session_hidden", 1, 200))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_message_db(self, path: Path, *, account: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE Name2Id (rowid INTEGER PRIMARY KEY, user_name TEXT)")
|
||||
usernames = [
|
||||
account,
|
||||
"wxid_visible",
|
||||
"wxid_no_session",
|
||||
"wxid_session_hidden",
|
||||
"gh_official_no_session",
|
||||
]
|
||||
for idx, username in enumerate(usernames, start=1):
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (idx, username))
|
||||
|
||||
message_usernames = {
|
||||
"wxid_visible": "visible searchable text",
|
||||
"wxid_no_session": "missing session searchable text",
|
||||
"wxid_session_hidden": "hidden searchable text",
|
||||
"gh_official_no_session": "official searchable text",
|
||||
}
|
||||
for username, content in message_usernames.items():
|
||||
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE {table_name} (
|
||||
local_id INTEGER,
|
||||
server_id INTEGER,
|
||||
local_type INTEGER,
|
||||
sort_seq INTEGER,
|
||||
real_sender_id INTEGER,
|
||||
create_time INTEGER,
|
||||
message_content TEXT,
|
||||
compress_content BLOB
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
f"INSERT INTO {table_name} VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(1, 1001, 1, 1, 2, 300, content, None),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _prepare_account(self, root: Path) -> Path:
|
||||
account = "wxid_account"
|
||||
account_dir = root / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account)
|
||||
self._seed_session_db(account_dir / "session.db")
|
||||
self._seed_message_db(account_dir / "message_0.db", account=account)
|
||||
return account_dir
|
||||
|
||||
def test_index_includes_message_backed_contacts_missing_from_session_list(self):
|
||||
import wechat_decrypt_tool.chat_search_index as idx
|
||||
from wechat_decrypt_tool.chat_helpers import _build_fts_query
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = self._prepare_account(Path(td))
|
||||
|
||||
idx._build_worker(account_dir, rebuild=True)
|
||||
|
||||
index_path = idx.get_chat_search_index_db_path(account_dir)
|
||||
conn = sqlite3.connect(str(index_path))
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT username, is_hidden, is_official
|
||||
FROM message_fts
|
||||
ORDER BY username
|
||||
"""
|
||||
).fetchall()
|
||||
fts_query = _build_fts_query("missing session")
|
||||
default_search_rows = conn.execute(
|
||||
"""
|
||||
SELECT username
|
||||
FROM message_fts
|
||||
WHERE message_fts MATCH ?
|
||||
AND CAST(is_hidden AS INTEGER) = 0
|
||||
AND CAST(is_official AS INTEGER) = 0
|
||||
""",
|
||||
(fts_query,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
by_username = {str(r[0]): (int(r[1] or 0), int(r[2] or 0)) for r in rows}
|
||||
default_search_usernames = [str(r[0]) for r in default_search_rows]
|
||||
self.assertIn("wxid_visible", by_username)
|
||||
self.assertIn("wxid_no_session", by_username)
|
||||
self.assertIn("wxid_session_hidden", by_username)
|
||||
self.assertIn("gh_official_no_session", by_username)
|
||||
self.assertEqual(by_username["wxid_no_session"], (0, 0))
|
||||
self.assertEqual(by_username["wxid_session_hidden"], (1, 0))
|
||||
self.assertEqual(by_username["gh_official_no_session"], (0, 1))
|
||||
self.assertEqual(default_search_usernames, ["wxid_no_session"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,5 +1,6 @@
|
||||
import sys
|
||||
import threading
|
||||
import sqlite3
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
@@ -97,7 +98,101 @@ class TestChatSessionsRealtimeSenderPreview(unittest.TestCase):
|
||||
self.assertEqual(len(sessions), 1)
|
||||
self.assertEqual(sessions[0].get("lastMessage"), "群名片B: https://example.com/x")
|
||||
|
||||
def test_sessions_ignore_invalid_utf8_avatar_url(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
session_conn = sqlite3.connect(str(account_dir / "session.db"))
|
||||
try:
|
||||
session_conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT,
|
||||
unread_count INTEGER,
|
||||
is_hidden INTEGER,
|
||||
summary TEXT,
|
||||
draft TEXT,
|
||||
last_timestamp INTEGER,
|
||||
sort_timestamp INTEGER,
|
||||
last_msg_locald_id INTEGER,
|
||||
last_msg_type INTEGER,
|
||||
last_msg_sub_type INTEGER,
|
||||
last_msg_sender TEXT,
|
||||
last_sender_display_name TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
session_conn.execute(
|
||||
"INSERT INTO SessionTable VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
("wxid_bad_avatar", 0, 0, "hello", "", 100, 100, 1, 1, 0, "", ""),
|
||||
)
|
||||
session_conn.commit()
|
||||
finally:
|
||||
session_conn.close()
|
||||
|
||||
contact_conn = sqlite3.connect(str(account_dir / "contact.db"))
|
||||
try:
|
||||
contact_conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
contact_conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
contact_conn.execute(
|
||||
"""
|
||||
INSERT INTO contact
|
||||
(username, remark, nick_name, alias, flag, big_head_url, small_head_url)
|
||||
VALUES (?, ?, ?, ?, ?, CAST(x'fffe687474703a2f2f6578616d706c652e746573742f612e706e67' AS TEXT), ?)
|
||||
""",
|
||||
("wxid_bad_avatar", "", "坏头像好友", "", 0, ""),
|
||||
)
|
||||
contact_conn.commit()
|
||||
finally:
|
||||
contact_conn.close()
|
||||
|
||||
with (
|
||||
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
|
||||
patch.object(chat_router.WCDB_REALTIME, "get_status", return_value={}),
|
||||
patch.object(chat_router, "load_session_last_messages", return_value={}),
|
||||
patch.object(chat_router, "_load_latest_message_previews", return_value={}),
|
||||
):
|
||||
resp = chat_router.list_chat_sessions(
|
||||
_DummyRequest(),
|
||||
account="acc",
|
||||
limit=50,
|
||||
include_hidden=True,
|
||||
include_official=True,
|
||||
preview="session",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
sessions = resp.get("sessions") or []
|
||||
self.assertEqual(len(sessions), 1)
|
||||
self.assertEqual(sessions[0].get("name"), "坏头像好友")
|
||||
self.assertEqual(sessions[0].get("lastMessage"), "hello")
|
||||
self.assertIn("/api/chat/avatar", sessions[0].get("avatar") or "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
def _close_logging_handlers() -> None:
|
||||
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
|
||||
lg = logging.getLogger(logger_name)
|
||||
for handler in lg.handlers[:]:
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
lg.removeHandler(handler)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class TestMcpAccessHost(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
self._prev_host = os.environ.get("WECHAT_TOOL_HOST")
|
||||
self._prev_port = os.environ.get("WECHAT_TOOL_PORT")
|
||||
self._td = TemporaryDirectory()
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = self._td.name
|
||||
os.environ.pop("WECHAT_TOOL_HOST", None)
|
||||
os.environ.pop("WECHAT_TOOL_PORT", None)
|
||||
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.runtime_settings as runtime_settings
|
||||
import wechat_decrypt_tool.routers.admin as admin_router
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(runtime_settings)
|
||||
importlib.reload(admin_router)
|
||||
|
||||
self.runtime_settings = runtime_settings
|
||||
self.admin_router = admin_router
|
||||
|
||||
def tearDown(self) -> None:
|
||||
_close_logging_handlers()
|
||||
|
||||
if self._prev_data_dir is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = self._prev_data_dir
|
||||
|
||||
if self._prev_host is None:
|
||||
os.environ.pop("WECHAT_TOOL_HOST", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_HOST"] = self._prev_host
|
||||
|
||||
if self._prev_port is None:
|
||||
os.environ.pop("WECHAT_TOOL_PORT", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_PORT"] = self._prev_port
|
||||
|
||||
self._td.cleanup()
|
||||
|
||||
def _client(self) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(self.admin_router.router)
|
||||
return TestClient(app, client=("127.0.0.1", 52010))
|
||||
|
||||
def test_mcp_access_reports_lan_endpoint_when_lan_enabled(self) -> None:
|
||||
self.runtime_settings.write_backend_host_setting(self.runtime_settings.LAN_BACKEND_HOST)
|
||||
self.runtime_settings.write_backend_port_setting(12092)
|
||||
client = self._client()
|
||||
|
||||
with patch.object(self.admin_router, "get_lan_access_host", return_value="192.168.1.23"):
|
||||
resp = client.get("/api/admin/mcp-access")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = resp.json()
|
||||
self.assertTrue(payload["enabled"])
|
||||
self.assertEqual(payload["host"], "0.0.0.0")
|
||||
self.assertEqual(payload["access_host"], "192.168.1.23")
|
||||
self.assertEqual(payload["accessHost"], "192.168.1.23")
|
||||
self.assertEqual(payload["mcp_endpoint"], "http://192.168.1.23:12092/mcp")
|
||||
self.assertEqual(payload["mcpEndpoint"], "http://192.168.1.23:12092/mcp")
|
||||
self.assertEqual(payload["skill_bundle_url"], "http://192.168.1.23:12092/mcp/skill/bundle")
|
||||
self.assertEqual(payload["skill_markdown_url"], "http://192.168.1.23:12092/mcp/skill")
|
||||
|
||||
def test_mcp_access_keeps_loopback_endpoint_when_lan_disabled(self) -> None:
|
||||
self.runtime_settings.write_backend_host_setting(self.runtime_settings.LOOPBACK_BACKEND_HOST)
|
||||
self.runtime_settings.write_backend_port_setting(12092)
|
||||
client = self._client()
|
||||
|
||||
with patch.object(self.admin_router, "get_lan_access_host", return_value="192.168.1.23"):
|
||||
resp = client.get("/api/admin/mcp-access")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = resp.json()
|
||||
self.assertFalse(payload["enabled"])
|
||||
self.assertEqual(payload["host"], "127.0.0.1")
|
||||
self.assertEqual(payload["access_host"], "127.0.0.1")
|
||||
self.assertEqual(payload["mcp_endpoint"], "http://127.0.0.1:12092/mcp")
|
||||
|
||||
|
||||
class TestNetworkAccessHost(unittest.TestCase):
|
||||
def test_get_lan_access_host_prefers_physical_private_ipv4(self) -> None:
|
||||
import wechat_decrypt_tool.network_access as network_access
|
||||
|
||||
with patch.object(network_access, "_add_psutil_candidates") as mocked_psutil, patch.object(
|
||||
network_access, "_add_route_candidates"
|
||||
) as mocked_route, patch.object(network_access, "_add_hostname_candidates") as mocked_hostname:
|
||||
|
||||
def add_psutil(candidates, seen):
|
||||
network_access._add_candidate(candidates, seen, "172.18.0.2", interface_name="Docker", source_order=0)
|
||||
network_access._add_candidate(candidates, seen, "192.168.1.23", interface_name="Wi-Fi", source_order=0)
|
||||
|
||||
mocked_psutil.side_effect = add_psutil
|
||||
mocked_route.side_effect = lambda candidates, seen: network_access._add_candidate(
|
||||
candidates, seen, "10.0.0.9", source_order=1
|
||||
)
|
||||
mocked_hostname.side_effect = lambda candidates, seen: None
|
||||
|
||||
self.assertEqual(network_access.get_lan_access_host(), "192.168.1.23")
|
||||
|
||||
def test_get_lan_access_host_falls_back_when_no_candidate(self) -> None:
|
||||
import wechat_decrypt_tool.network_access as network_access
|
||||
|
||||
with patch.object(network_access, "_add_psutil_candidates", return_value=None), patch.object(
|
||||
network_access, "_add_route_candidates", return_value=None
|
||||
), patch.object(network_access, "_add_hostname_candidates", return_value=None):
|
||||
self.assertEqual(network_access.get_lan_access_host(default="127.0.0.1"), "127.0.0.1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,668 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestMcpRouter(unittest.TestCase):
|
||||
TEST_TOKEN = "test-mcp-token-1234567890"
|
||||
REMOVED_MCP_TOOLS = {
|
||||
"wechat.setup.get_saved_keys",
|
||||
"wechat.setup.get_database_key",
|
||||
"wechat.setup.get_image_key",
|
||||
"wechat.setup.decrypt_databases",
|
||||
"wechat.setup.get_decrypt_stream_url",
|
||||
"wechat.setup.preview_import_decrypted",
|
||||
"wechat.setup.get_import_decrypted_stream_url",
|
||||
"wechat.setup.cancel_import_decrypted",
|
||||
"wechat.setup.save_media_keys",
|
||||
"wechat.setup.decrypt_all_media",
|
||||
"wechat.setup.get_decrypt_all_media_stream_url",
|
||||
"wechat.setup.get_download_all_emojis_stream_url",
|
||||
"wechat.contacts.export_contacts",
|
||||
"wechat.chat.get_realtime_status",
|
||||
"wechat.chat.sync_realtime_session",
|
||||
"wechat.chat.sync_realtime_all_sessions",
|
||||
"wechat.chat.get_realtime_events_url",
|
||||
"wechat.moments.sync_latest",
|
||||
"wechat.editing.list_edited_sessions",
|
||||
"wechat.editing.list_edited_messages",
|
||||
"wechat.editing.get_message_edit_status",
|
||||
"wechat.editing.edit_message",
|
||||
"wechat.editing.repair_message_sender",
|
||||
"wechat.editing.flip_message_direction",
|
||||
"wechat.editing.reset_message_edit",
|
||||
"wechat.editing.reset_session_edits",
|
||||
"wechat.export.preview_chat_targets",
|
||||
"wechat.export.create_chat_export",
|
||||
"wechat.export.list_chat_exports",
|
||||
"wechat.export.get_chat_export",
|
||||
"wechat.export.cancel_chat_export",
|
||||
"wechat.export.get_chat_export_download",
|
||||
"wechat.export.get_chat_export_events_url",
|
||||
"wechat.export.create_moments_export",
|
||||
"wechat.export.list_moments_exports",
|
||||
"wechat.export.get_moments_export",
|
||||
"wechat.export.cancel_moments_export",
|
||||
"wechat.export.get_moments_export_download",
|
||||
"wechat.export.get_moments_export_events_url",
|
||||
"wechat.export.create_account_archive",
|
||||
"wechat.export.get_account_archive",
|
||||
"wechat.export.cancel_account_archive",
|
||||
"wechat.export.get_account_archive_download",
|
||||
"wechat.mobile.export_job",
|
||||
"wechat.admin.detect_wechat_installation",
|
||||
"wechat.admin.get_current_wechat_account",
|
||||
"wechat.admin.get_wechat_runtime_status",
|
||||
"wechat.admin.delete_account_data",
|
||||
"wechat.system.api_root",
|
||||
"wechat.system.health_check",
|
||||
"wechat.system.get_backend_log_file",
|
||||
"wechat.system.open_backend_log_file",
|
||||
"wechat.system.log_frontend_server_error",
|
||||
"wechat.system.get_backend_port",
|
||||
"wechat.system.set_backend_port_setting",
|
||||
"wechat.system.set_backend_port_and_restart",
|
||||
"wechat.system.get_mcp_lan_access",
|
||||
"wechat.system.set_mcp_lan_access",
|
||||
"wechat.system.get_img_helper_status",
|
||||
"wechat.system.toggle_img_helper",
|
||||
"wechat.system.pick_directory",
|
||||
"wechat.chat.get_search_index_status",
|
||||
"wechat.chat.build_search_index",
|
||||
"wechat.chat.get_session_last_message_cache_status",
|
||||
"wechat.chat.build_session_last_message_cache",
|
||||
"wechat.media.download_chat_emoji",
|
||||
"wechat.media.open_chat_media_folder",
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
self._old_mcp_token = os.environ.get("WECHAT_TOOL_MCP_TOKEN")
|
||||
os.environ["WECHAT_TOOL_MCP_TOKEN"] = self.TEST_TOKEN
|
||||
|
||||
def tearDown(self):
|
||||
if self._old_mcp_token is None:
|
||||
os.environ.pop("WECHAT_TOOL_MCP_TOKEN", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_MCP_TOKEN"] = self._old_mcp_token
|
||||
|
||||
def _client(self, auth: bool = True) -> TestClient:
|
||||
from wechat_decrypt_tool.routers.mcp import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
if auth:
|
||||
client.headers.update({"Authorization": f"Bearer {self.TEST_TOKEN}"})
|
||||
return client
|
||||
|
||||
def _rpc(self, method, params=None, request_id=1):
|
||||
payload = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
return payload
|
||||
|
||||
def test_initialize_and_tools_list(self):
|
||||
client = self._client()
|
||||
|
||||
init_resp = client.post("/mcp", json=self._rpc("initialize"))
|
||||
self.assertEqual(init_resp.status_code, 200)
|
||||
init_payload = init_resp.json()["result"]
|
||||
self.assertEqual(init_payload["protocolVersion"], "2025-06-18")
|
||||
self.assertEqual(init_payload["serverInfo"]["name"], "wechat-data-analysis-mcp")
|
||||
|
||||
tools_resp = client.post("/mcp", json=self._rpc("tools/list"))
|
||||
self.assertEqual(tools_resp.status_code, 200)
|
||||
tools = tools_resp.json()["result"]["tools"]
|
||||
names = {tool["name"] for tool in tools}
|
||||
self.assertIn("wechat.core.get_status", names)
|
||||
self.assertIn("wechat.chat.search_messages", names)
|
||||
self.assertIn("wechat.chat.list_search_senders", names)
|
||||
self.assertIn("wechat.chat.resolve_chat_history", names)
|
||||
self.assertIn("wechat.chat.resolve_app_message", names)
|
||||
self.assertIn("wechat.moments.get_remote_video_url", names)
|
||||
self.assertNotIn("search_memory", names)
|
||||
self.assertNotIn("transcribe_voice_message", names)
|
||||
self.assertNotIn("transcribe_audio_file", names)
|
||||
self.assertFalse(self.REMOVED_MCP_TOOLS & names)
|
||||
|
||||
def test_mcp_requires_token(self):
|
||||
client = self._client(auth=False)
|
||||
|
||||
resp = client.post("/mcp", json=self._rpc("ping"))
|
||||
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
self.assertEqual(resp.headers.get("www-authenticate"), "Bearer")
|
||||
|
||||
def test_skill_endpoints_require_token(self):
|
||||
client = self._client(auth=False)
|
||||
|
||||
for path in ("/mcp/skill/bundle", "/mcp/skill"):
|
||||
with self.subTest(path=path):
|
||||
resp = client.get(path)
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
self.assertEqual(resp.headers.get("www-authenticate"), "Bearer")
|
||||
|
||||
def test_mcp_rejects_invalid_token(self):
|
||||
client = self._client(auth=False)
|
||||
|
||||
resp = client.post("/mcp", json=self._rpc("ping"), headers={"Authorization": "Bearer wrong-token"})
|
||||
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
def test_mcp_accepts_x_mcp_token_and_query_token(self):
|
||||
client = self._client(auth=False)
|
||||
|
||||
header_resp = client.post("/mcp", json=self._rpc("ping"), headers={"X-MCP-Token": self.TEST_TOKEN})
|
||||
query_resp = client.post(f"/mcp?token={self.TEST_TOKEN}", json=self._rpc("ping"))
|
||||
|
||||
self.assertEqual(header_resp.status_code, 200)
|
||||
self.assertEqual(header_resp.json()["result"], {})
|
||||
self.assertEqual(query_resp.status_code, 200)
|
||||
self.assertEqual(query_resp.json()["result"], {})
|
||||
|
||||
def test_get_mcp_returns_method_not_allowed(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.get("/mcp")
|
||||
|
||||
self.assertEqual(resp.status_code, 405)
|
||||
self.assertEqual(resp.headers.get("allow"), "POST")
|
||||
|
||||
def test_skill_bundle_can_be_loaded_over_http(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.get("/mcp/skill/bundle")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = resp.json()
|
||||
self.assertEqual(payload["status"], "success")
|
||||
self.assertEqual(payload["name"], "wechat-mcp-copilot")
|
||||
self.assertIn("bundleText", payload)
|
||||
self.assertIn("WeChat MCP Copilot", payload["bundleText"])
|
||||
self.assertTrue(any(ref["path"] == "references/mobile.md" for ref in payload["references"]))
|
||||
self.assertFalse(any(ref["path"] == "references/system.md" for ref in payload["references"]))
|
||||
self.assertFalse(any(ref["path"] == "references/setup-system.md" for ref in payload["references"]))
|
||||
self.assertFalse(any(ref["path"] == "references/export.md" for ref in payload["references"]))
|
||||
for tool_name in self.REMOVED_MCP_TOOLS:
|
||||
self.assertNotIn(tool_name, payload["bundleText"])
|
||||
|
||||
def test_skill_text_can_be_loaded_over_http(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.get("/mcp/skill")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn("WeChat MCP Copilot", resp.text)
|
||||
|
||||
def test_ping_returns_empty_result(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post("/mcp", json=self._rpc("ping"))
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()["result"], {})
|
||||
|
||||
def test_tools_list_supports_cursor_pagination(self):
|
||||
client = self._client()
|
||||
|
||||
first_resp = client.post("/mcp", json=self._rpc("tools/list", {"limit": 3}))
|
||||
self.assertEqual(first_resp.status_code, 200)
|
||||
first = first_resp.json()["result"]
|
||||
self.assertEqual(first["count"], 3)
|
||||
self.assertEqual(len(first["tools"]), 3)
|
||||
self.assertIn("nextCursor", first)
|
||||
self.assertGreater(first["total"], 3)
|
||||
|
||||
second_resp = client.post(
|
||||
"/mcp",
|
||||
json=self._rpc("tools/list", {"limit": 3, "cursor": first["nextCursor"]}),
|
||||
)
|
||||
second = second_resp.json()["result"]
|
||||
self.assertEqual(second["count"], 3)
|
||||
self.assertNotEqual(first["tools"][0]["name"], second["tools"][0]["name"])
|
||||
|
||||
def test_core_list_tools_supports_package_filter_and_pagination(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post(
|
||||
"/mcp",
|
||||
json=self._rpc(
|
||||
"tools/call",
|
||||
{"name": "wechat.core.list_tools", "arguments": {"package": "wechat.media", "limit": 2}},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
structured = resp.json()["result"]["structuredContent"]
|
||||
self.assertEqual(structured["status"], "success")
|
||||
self.assertEqual(structured["count"], 2)
|
||||
self.assertIn("nextCursor", structured)
|
||||
self.assertTrue(all(t["annotations"]["package"] == "wechat.media" for t in structured["tools"]))
|
||||
|
||||
def test_tools_call_status_uses_structured_content(self):
|
||||
client = self._client()
|
||||
|
||||
with patch("wechat_decrypt_tool.mcp.tools._list_decrypted_accounts", return_value=["wxid_test"]):
|
||||
resp = client.post(
|
||||
"/mcp",
|
||||
json=self._rpc(
|
||||
"tools/call",
|
||||
{"name": "wechat.core.get_status", "arguments": {}},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
result = resp.json()["result"]
|
||||
self.assertFalse(result["isError"])
|
||||
self.assertEqual(result["structuredContent"]["status"], "success")
|
||||
self.assertTrue(result["structuredContent"]["dbReady"])
|
||||
self.assertEqual(result["structuredContent"]["defaultAccount"], "wxid_test")
|
||||
self.assertEqual(result["content"][0]["type"], "text")
|
||||
|
||||
def test_direct_tool_method_is_supported(self):
|
||||
client = self._client()
|
||||
|
||||
with patch("wechat_decrypt_tool.mcp.tools._list_decrypted_accounts", return_value=[]):
|
||||
resp = client.post("/mcp", json=self._rpc("wechat.core.list_accounts"))
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
result = resp.json()["result"]
|
||||
self.assertTrue(result["isError"])
|
||||
structured = result["structuredContent"]
|
||||
self.assertEqual(structured["status"], "error")
|
||||
self.assertEqual(structured["accounts"], [])
|
||||
|
||||
def test_notification_returns_accepted_empty_body(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post(
|
||||
"/mcp",
|
||||
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.text, "")
|
||||
|
||||
def test_json_rpc_response_input_returns_accepted_empty_body(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post("/mcp", json={"jsonrpc": "2.0", "id": 99, "result": {"ok": True}})
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.text, "")
|
||||
|
||||
def test_notification_batch_returns_accepted_empty_body(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post(
|
||||
"/mcp",
|
||||
json=[
|
||||
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.text, "")
|
||||
|
||||
def test_empty_batch_returns_invalid_request(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post("/mcp", json=[])
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()["error"]["code"], -32600)
|
||||
|
||||
def test_non_string_method_returns_invalid_request(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": 1})
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()["error"]["code"], -32600)
|
||||
|
||||
def test_batch_mixed_requests_and_notifications(self):
|
||||
client = self._client()
|
||||
|
||||
with patch("wechat_decrypt_tool.mcp.tools._list_decrypted_accounts", return_value=["wxid_test"]):
|
||||
resp = client.post(
|
||||
"/mcp",
|
||||
json=[
|
||||
self._rpc("ping", request_id=1),
|
||||
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||
self._rpc("wechat.core.get_status", request_id=2),
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = resp.json()
|
||||
self.assertEqual(len(payload), 2)
|
||||
self.assertEqual(payload[0]["result"], {})
|
||||
self.assertEqual(payload[1]["result"]["structuredContent"]["defaultAccount"], "wxid_test")
|
||||
|
||||
def test_unknown_tool_returns_json_rpc_error(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post(
|
||||
"/mcp",
|
||||
json=self._rpc(
|
||||
"tools/call",
|
||||
{"name": "wechat.nope", "arguments": {}},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = resp.json()
|
||||
self.assertEqual(payload["error"]["code"], -32601)
|
||||
|
||||
def test_removed_mcp_tools_are_not_listed_or_callable(self):
|
||||
client = self._client()
|
||||
|
||||
tools_resp = client.post("/mcp", json=self._rpc("tools/list"))
|
||||
self.assertEqual(tools_resp.status_code, 200)
|
||||
names = {tool["name"] for tool in tools_resp.json()["result"]["tools"]}
|
||||
self.assertFalse(self.REMOVED_MCP_TOOLS & names)
|
||||
|
||||
for tool_name in sorted(self.REMOVED_MCP_TOOLS):
|
||||
with self.subTest(tool_name=tool_name):
|
||||
direct_resp = client.post("/mcp", json=self._rpc(tool_name, {}))
|
||||
call_resp = client.post(
|
||||
"/mcp",
|
||||
json=self._rpc("tools/call", {"name": tool_name, "arguments": {}}),
|
||||
)
|
||||
self.assertEqual(direct_resp.status_code, 200)
|
||||
self.assertEqual(call_resp.status_code, 200)
|
||||
self.assertEqual(direct_resp.json()["error"]["code"], -32601)
|
||||
self.assertEqual(call_resp.json()["error"]["code"], -32601)
|
||||
|
||||
def test_missing_tool_name_returns_invalid_params(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post("/mcp", json=self._rpc("tools/call", {"arguments": {}}))
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()["error"]["code"], -32602)
|
||||
|
||||
def test_non_object_arguments_returns_invalid_params(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post(
|
||||
"/mcp",
|
||||
json=self._rpc("tools/call", {"name": "wechat.core.get_status", "arguments": []}),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()["error"]["code"], -32602)
|
||||
|
||||
def test_media_url_helpers_pass_supported_parameters(self):
|
||||
client = self._client()
|
||||
|
||||
image_resp = client.post(
|
||||
"/mcp",
|
||||
json=self._rpc(
|
||||
"wechat.media.get_chat_image_url",
|
||||
{
|
||||
"md5": "abc",
|
||||
"file_id": "fid",
|
||||
"msg_svr_id": 123,
|
||||
"username": "wxid_a",
|
||||
"account": "wxid_acc",
|
||||
"deep_scan": True,
|
||||
"prefer_live": True,
|
||||
},
|
||||
),
|
||||
)
|
||||
image = image_resp.json()["result"]["structuredContent"]
|
||||
self.assertIn("/api/chat/media/image?", image["url"])
|
||||
self.assertEqual(image["params"]["server_id"], 123)
|
||||
self.assertEqual(image["params"]["file_id"], "fid")
|
||||
self.assertTrue(image["params"]["deep_scan"])
|
||||
self.assertTrue(image["params"]["prefer_live"])
|
||||
|
||||
moments_resp = client.post(
|
||||
"/mcp",
|
||||
json=self._rpc(
|
||||
"wechat.moments.get_media_url",
|
||||
{"tid": "post-a", "media_id": "media-a", "md5": "deadbeef", "use_cache": 1},
|
||||
),
|
||||
)
|
||||
moments = moments_resp.json()["result"]["structuredContent"]
|
||||
self.assertIn("/api/sns/media?", moments["url"])
|
||||
self.assertEqual(moments["params"]["post_id"], "post-a")
|
||||
self.assertEqual(moments["params"]["media_id"], "media-a")
|
||||
self.assertNotIn("use_cache", moments["params"])
|
||||
self.assertNotIn("use_cache", moments["url"])
|
||||
|
||||
def test_completed_mcp_packages_and_mobile_facade_are_listed(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post("/mcp", json=self._rpc("tools/list"))
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
tools = resp.json()["result"]["tools"]
|
||||
names = {tool["name"] for tool in tools}
|
||||
expected = {
|
||||
"wechat.media.get_decrypted_resource_url",
|
||||
"wechat.media.get_proxy_image_url",
|
||||
"wechat.media.get_favicon_url",
|
||||
"wechat.mobile.get_overview",
|
||||
"wechat.mobile.resolve_target",
|
||||
"wechat.mobile.search_chat",
|
||||
"wechat.mobile.get_chat_context",
|
||||
"wechat.mobile.search_moments",
|
||||
"wechat.mobile.get_media_links",
|
||||
}
|
||||
self.assertTrue(expected.issubset(names))
|
||||
self.assertFalse(self.REMOVED_MCP_TOOLS & names)
|
||||
self.assertNotIn("search_memory", names)
|
||||
self.assertNotIn("transcribe_voice_message", names)
|
||||
self.assertNotIn("transcribe_audio_file", names)
|
||||
|
||||
packages = {tool["annotations"]["package"] for tool in tools}
|
||||
self.assertTrue({"wechat.core", "wechat.mobile", "wechat.media"}.issubset(packages))
|
||||
self.assertFalse({"wechat.setup", "wechat.export", "wechat.editing", "wechat.system", "wechat.admin"} & packages)
|
||||
|
||||
def test_new_url_helpers_return_urls_and_params(self):
|
||||
client = self._client()
|
||||
|
||||
checks = [
|
||||
(
|
||||
"wechat.media.get_decrypted_resource_url",
|
||||
{"account": "wxid_a", "md5": "a" * 32},
|
||||
"url",
|
||||
"/api/media/resource/",
|
||||
),
|
||||
]
|
||||
|
||||
for tool_name, args, url_key, path_part in checks:
|
||||
with self.subTest(tool_name=tool_name):
|
||||
resp = client.post("/mcp", json=self._rpc(tool_name, args))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
structured = resp.json()["result"]["structuredContent"]
|
||||
self.assertEqual(structured["status"], "success")
|
||||
self.assertIn(path_part, structured[url_key])
|
||||
|
||||
def test_exposed_mcp_tools_are_read_only(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post("/mcp", json=self._rpc("tools/list"))
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
tools = resp.json()["result"]["tools"]
|
||||
self.assertTrue(tools)
|
||||
for tool in tools:
|
||||
with self.subTest(tool_name=tool["name"]):
|
||||
annotations = tool.get("annotations") or {}
|
||||
self.assertTrue(annotations.get("readOnlyHint"))
|
||||
self.assertFalse(annotations.get("destructiveHint"))
|
||||
|
||||
def test_analytics_schema_does_not_expose_refresh(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post("/mcp", json=self._rpc("tools/list"))
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
tools = {tool["name"]: tool for tool in resp.json()["result"]["tools"]}
|
||||
for tool_name in [
|
||||
"wechat.analytics.get_wrapped_meta",
|
||||
"wechat.analytics.get_wrapped_card",
|
||||
"wechat.analytics.get_wrapped_annual",
|
||||
]:
|
||||
with self.subTest(tool_name=tool_name):
|
||||
properties = tools[tool_name]["inputSchema"].get("properties") or {}
|
||||
self.assertNotIn("refresh", properties)
|
||||
|
||||
def test_analytics_tools_are_cache_only(self):
|
||||
client = self._client()
|
||||
|
||||
class FakeWrappedService:
|
||||
_CACHE_VERSION = 26
|
||||
_IMPLEMENTED_UPTO_ID = 7
|
||||
_WRAPPED_CARD_MANIFEST = ({"id": 0, "title": "Overview"},)
|
||||
|
||||
@staticmethod
|
||||
def _default_year():
|
||||
return 2025
|
||||
|
||||
def build_wrapped_annual_meta(self, **_kwargs):
|
||||
raise AssertionError("MCP analytics must not build wrapped meta.")
|
||||
|
||||
def build_wrapped_annual_card(self, **_kwargs):
|
||||
raise AssertionError("MCP analytics must not build wrapped card.")
|
||||
|
||||
def build_wrapped_annual_response(self, **_kwargs):
|
||||
raise AssertionError("MCP analytics must not build wrapped annual data.")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
account_dir = Path(tmp) / "wxid_a"
|
||||
account_dir.mkdir()
|
||||
with patch("wechat_decrypt_tool.mcp.tools._resolve_account_dir", return_value=account_dir), patch(
|
||||
"wechat_decrypt_tool.mcp.tools._wrapped_service", return_value=FakeWrappedService()
|
||||
):
|
||||
card_resp = client.post(
|
||||
"/mcp",
|
||||
json=self._rpc("wechat.analytics.get_wrapped_card", {"account": "wxid_a", "year": 2025, "card_id": 0}),
|
||||
)
|
||||
annual_resp = client.post(
|
||||
"/mcp",
|
||||
json=self._rpc("wechat.analytics.get_wrapped_annual", {"account": "wxid_a", "year": 2025}),
|
||||
)
|
||||
|
||||
self.assertFalse((account_dir / "_wrapped" / "cache").exists())
|
||||
|
||||
for resp in [card_resp, annual_resp]:
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
result = resp.json()["result"]
|
||||
self.assertTrue(result["isError"])
|
||||
structured = result["structuredContent"]
|
||||
self.assertEqual(structured["status"], "error")
|
||||
self.assertTrue(structured["cacheOnly"])
|
||||
self.assertEqual(structured["message"], "Wrapped cache not found. Open the app to generate it first.")
|
||||
|
||||
def test_mobile_overview_uses_compact_facade(self):
|
||||
client = self._client()
|
||||
|
||||
with patch("wechat_decrypt_tool.mcp.tools._list_decrypted_accounts", return_value=["wxid_a"]), patch(
|
||||
"wechat_decrypt_tool.mcp.tools._get_account_info",
|
||||
return_value={"status": "success", "account": "wxid_a", "databaseCount": 3},
|
||||
), patch(
|
||||
"wechat_decrypt_tool.mcp.tools._list_sessions",
|
||||
return_value={"status": "success", "sessions": [{"username": "friend", "displayName": "Friend"}]},
|
||||
):
|
||||
resp = client.post(
|
||||
"/mcp",
|
||||
json=self._rpc("wechat.mobile.get_overview", {"account": "wxid_a", "session_limit": 5}),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
structured = resp.json()["result"]["structuredContent"]
|
||||
self.assertTrue(structured["ok"])
|
||||
self.assertTrue(structured["ready"])
|
||||
self.assertEqual(structured["defaultAccount"], "wxid_a")
|
||||
self.assertIn("wechat.mobile.search_chat", structured["suggestedTools"])
|
||||
self.assertNotIn("wechat.mobile.export_job", structured["suggestedTools"])
|
||||
self.assertNotIn("realtime", structured["health"])
|
||||
self.assertNotIn("indexes", structured["health"])
|
||||
|
||||
def test_mobile_overview_does_not_expose_realtime_status(self):
|
||||
client = self._client()
|
||||
|
||||
with patch("wechat_decrypt_tool.mcp.tools._list_decrypted_accounts", return_value=["wxid_a"]), patch(
|
||||
"wechat_decrypt_tool.mcp.tools._get_account_info",
|
||||
return_value={"status": "success", "account": "wxid_a", "databaseCount": 3},
|
||||
), patch(
|
||||
"wechat_decrypt_tool.mcp.tools._list_sessions",
|
||||
return_value={"status": "success", "sessions": [{"username": "friend", "displayName": "Friend"}]},
|
||||
):
|
||||
resp = client.post(
|
||||
"/mcp",
|
||||
json=self._rpc("wechat.mobile.get_overview", {"account": "wxid_a", "session_limit": 5}),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = resp.json()
|
||||
self.assertNotIn("error", payload)
|
||||
structured = payload["result"]["structuredContent"]
|
||||
self.assertNotIn("realtime", structured["health"])
|
||||
self.assertNotIn("indexes", structured["health"])
|
||||
|
||||
def test_mobile_resolve_target_normalizes_candidates(self):
|
||||
client = self._client()
|
||||
|
||||
with patch(
|
||||
"wechat_decrypt_tool.mcp.tools._resolve_contact",
|
||||
return_value={"status": "success", "candidates": [{"username": "wxid_friend", "remark": "Alice", "confidence": 92}]},
|
||||
), patch(
|
||||
"wechat_decrypt_tool.mcp.tools._resolve_session",
|
||||
return_value={"status": "success", "candidates": [{"username": "chatroom", "displayName": "Alice Group", "confidence": 75}]},
|
||||
), patch(
|
||||
"wechat_decrypt_tool.mcp.tools._sns_users", return_value={"status": "success", "users": []}
|
||||
), patch(
|
||||
"wechat_decrypt_tool.mcp.tools._biz_accounts", return_value={"status": "success", "accounts": []}
|
||||
):
|
||||
resp = client.post("/mcp", json=self._rpc("wechat.mobile.resolve_target", {"query": "Alice", "limit": 5}))
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
structured = resp.json()["result"]["structuredContent"]
|
||||
self.assertEqual(structured["status"], "success")
|
||||
self.assertEqual(structured["best"]["username"], "wxid_friend")
|
||||
self.assertEqual(structured["best"]["kind"], "contact")
|
||||
|
||||
def test_mobile_media_links_does_not_fetch_binary_content(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post(
|
||||
"/mcp",
|
||||
json=self._rpc(
|
||||
"wechat.mobile.get_media_links",
|
||||
{"kind": "favicon", "url": "https://example.com/article", "max_items": 5},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
structured = resp.json()["result"]["structuredContent"]
|
||||
self.assertEqual(structured["status"], "success")
|
||||
self.assertEqual(structured["resources"][0]["kind"], "favicon")
|
||||
self.assertIn("/api/chat/media/favicon?", structured["resources"][0]["url"])
|
||||
|
||||
def test_invalid_json_returns_parse_error(self):
|
||||
client = self._client()
|
||||
|
||||
resp = client.post("/mcp", content="{not-json", headers={"Content-Type": "application/json"})
|
||||
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(resp.json()["error"]["code"], -32700)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user