Files
kefu/deploy/protocol-integration-20260916/comparison.diff
T
2026-09-21 10:34:06 +08:00

2603 lines
132 KiB
Diff
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--- current/BACKEND.md
+++ protocol/BACKEND.md
@@ -49,29 +49,6 @@
同一台电脑上启动后台与桌面端时,后台会在 `backend_runtime.json` 发布一个仅限回环地址使用的临时只读同步凭证。桌面软件启动时会先检测该服务并拉取最新配置,所以即使没有保存后台账号登录,也会立即刷新“能力开关”“模型与身份”和“MCP 服务器”中的内容。该凭证不能管理用户或修改后台配置,后台停止后即失效。
-## 3.1 智能体(角色与规则)
-
-管理端「AI 模型 → 智能体」决定客服**用什么身份、按什么规矩说话**;模型清单和角色编排决定**用哪个模型**。两件事分开配,因为变更频率差一个数量级——模型半年动一次,话术一周动三次。
-
-一个智能体 = 人设 + 一组规则。规则只有三种:
-
-| 类型 | 触发 | 作用 |
-| --- | --- | --- |
-| 补充指令(guide) | 关键词留空 = 每轮都注入;填了关键词 = 客户这句话命中才注入 | 追加一句给模型的要求 |
-| 禁止措辞(forbid) | **回复里**出现关键词 | 换成这条规则的兜底话术;留空则用客户端内置的那句 |
-| 固定口径(reply) | **客户这句话**命中关键词 | 直接发配好的话术,不问模型 |
-
-「启用与协作」里有两种模式:
-
-- **单角色**:只上一个,所谓"切换智能体"就是换这里选的那个。
-- **协作**:多个同时在场,客户这句话命中谁的「负责话题」就由谁主答,其余角色的禁止措辞**仍然全体生效**。不论哪种模式,客户看到的始终是同一个人——不会自报角色名,也不会出现"转给同事"。
-
-方案按版本追加,不原地改,回滚就是再存一版指回去。保存后随桌面端配置下发,客户端不用发版。
-
-出厂自带「健康顾问」和「客户经理」两个角色,默认按协作上岗。两个角色都带同一条禁止措辞规则:把客户看得见的文字说成"系统编码/乱码/无法确认具体意思"一律拦下——这是线上真实发生过的事故(客户发来血糖值「13」,模型回"这串像系统编码")。即使一个智能体都不配,客户端里那条内置兜底也照样生效。
-
-相关权限码:`agent:read` / `agent:write`。升级到这个版本的老后台,管理员自动获得;配置员和只读用户需要在「角色权限」里勾一下。
-
## 4. 连接已部署的后台
后台所在电脑启动服务:
--- current/WINDOWS_EXE_BUILD.md
+++ protocol/WINDOWS_EXE_BUILD.md
@@ -1,91 +1,51 @@
-# Windows EXE 打包说明
+# Windows EXE 与安装包构建
-桌面端支持打包成单个 EXE 文件。首次打包需要联网安装构建依赖,后续可复用项目中的 `.build-venv` 和 `.uv-cache`。
+双击 `一键生成安装包.bat`(`一键打包EXE.bat` 当前也生成完整安装包)。
+也可在项目目录执行:
-## 一键打包
-
-最简单的方法是直接双击项目目录中的:
-
-```text
-一键打包EXE.bat
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File .\build_installer.ps1
```
-打包成功后会自动打开 `dist` 文件夹。若打包失败,命令窗口会保持打开并显示原因。
+脚本为本地构建进程设置执行方式,不修改系统执行策略或杀毒软件设置。
+首次构建需要 Python 3.11/3.12 并可能联网安装依赖;已有 `.build-venv` 可直接复用。
+仅构建应用目录时运行 `build_windows_exe.ps1`,可用 `-PythonPath` 指定 Python。
-也可以在项目目录打开 PowerShell,运行:
+## 构建输入与输出
-```powershell
-powershell -ExecutionPolicy Bypass -File build_windows_exe.ps1
-```
+版本取自 `app_version.py`。默认归档源码在本项目的 `archive_exporter` 中,
+不再依赖旧电脑上的 `2026-08-19-18-27-34` 目录。构建前会验证四个源码文件及语法。
+有意使用自有导出器时可设置 `WECOM_BUILD_EXPORTER_DIR`;失效配置会明确报错。
+该变量只影响构建,不影响安装后从包内加载导出器。
-生成文件位于:
+输出:
-```text
-dist/ZhenAI-WeCom-Assistant-v1.0.0.exe
-```
+- 应用目录:`dist\ZhenAI-WeCom-Assistant-v<版本>\`
+- 安装包:`dist\installer\甄AI客服-安装包-v<版本>.exe`
-EXE 可以直接复制到其他 Windows 电脑运行,不需要另外安装 Python。程序运行数据保存在:
+应用采用目录形式,主 EXE 必须与 `_internal` 和更新器一起使用,不能只复制主 EXE。
+安装包包含这些文件,支持 Windows 10/11 64 位;目标电脑无需 Python 或 WebView2。
+构建脚本验证 Qt WebEngine、中文资源、VC/OpenSSL 运行库,并在精简 PATH 下运行
+打包自检;归档、导航及发送保护使用合成数据和模拟输入,不向真实会话发送消息。
+安装器默认安装到当前用户目录,创建桌面和开始菜单快捷方式,不要求管理员权限。
-```text
-%LOCALAPPDATA%\ZhenYangTangRPA
-```
+## 数据与升级
-其中包含云端配置缓存、客服名称本地偏好、聊天记录和登记数据。升级 EXE 不会清空这些资料。
+归档组件按四文件白名单收取代码,不分发开发机的密钥、账号配置或聊天数据库。
+安装后的配置与聊天资料保存于 `%LOCALAPPDATA%\ZhenYangTangRPA`,更新及卸载时保留。
+读取企业微信加密数据库仍需目标电脑对应账号的密钥。
-## 目标电脑要求
+更改版本后重新构建;构建不会自动上传或发布。正式发布时使用 HTTPS 安装包地址和
+SHA256,云端升级版本必须高于已安装版本。先在目标环境验收再发布更新。
-- 支持 Windows 10 / Windows 11 64 位系统。
-- 不需要安装 Python。
-- 不需要安装 Microsoft WebView2 Runtime;EXE 已内置 Qt WebEngine 浏览器内核、浏览器子进程、中文语言包和资源文件。
-- 需要能够访问云端配置域名和客服网页域名。
-- 第一次打开单文件 EXE 会释放内置浏览器组件,因此启动时间可能比后续稍长。
+## 签名、杀毒告警与验收
-默认界面使用内置 Qt WebEngine。只有开发调试时主动添加 `--classic-ui` 参数,才会改用依赖系统 Microsoft Edge 的经典界面,正常客户使用无需该参数。
+签名用于确认发布者及文件完整性,不等同于杀毒软件审核;不能保证消除行为告警。
+可配置已有代码签名证书:`WECOM_CODESIGN_THUMBPRINT`,或 `WECOM_CODESIGN_PFX` 与
+`WECOM_CODESIGN_PFX_PASSWORD`。无证书时构建仍可完成,但产物会保持未签名。
+不要在源码或构建日志中保存证书密码。
-每次执行一键打包时,脚本都会强制检查以下内容:
-
-- 内置 `QtWebEngineProcess.exe` 浏览器子进程
-- Qt WebEngine 核心资源和字符数据
-- `zh-CN` 中文语言包
-- VC++ 运行库
-- 打包后 EXE 能否离线创建浏览器内核并成功加载测试页面
-
-任意一项缺失或浏览器自检失败,打包脚本都会返回失败,BAT 窗口会保留错误信息,不会把该 EXE 当成成功版本交付。
-
-## 发布新版本
-
-1. 修改 `app_version.py` 中的 `APP_VERSION`,例如改为 `1.0.1`。
-2. 重新运行 `build_windows_exe.ps1`。
-3. 把生成的 EXE 上传到可下载地址。
-4. 在云端管理后台“桌面端版本升级”中填写相同版本号和下载地址。
-5. 确认下载地址可用后,再决定是否开启强制升级。
-
-建议发布前在另一台 Windows 电脑上打开 EXE,确认软件能启动、能加载客服页面并能收到云端配置。
-
-## 生成可安装版本
-
-直接双击:
-
-```text
-一键生成安装包.bat
-```
-
-它会先重新生成并验证桌面 EXE,再制作中文 Windows 安装包。第一次制作安装包时,会从 NuGet 软件包源下载固定版本的开源 NSIS 构建工具,并校验文件哈希,后续可直接复用。
-
-安装包生成在:
-
-```text
-dist\installer\甄AI客服-安装包-v1.0.0.exe
-```
-
-安装后的效果:
-
-- 软件名称为“甄AI客服”
-- 自动创建桌面快捷方式
-- 自动创建开始菜单快捷方式
-- 快捷方式名称和安装后的程序名称均为“甄AI客服”
-- 安装到当前用户目录,无需管理员权限
-- 可以在 Windows“已安装的应用”中正常卸载
-- 覆盖安装新版时保留 `%LOCALAPPDATA%\ZhenYangTangRPA` 中的配置和聊天资料
-
-说明:桌面和开始菜单中显示的是“甄AI客服”。安装目录里的主程序会保留打包时的英文文件名,这是 Qt WebEngine 浏览器内核正常启动所必需的,请不要手动重命名安装目录中的 EXE。
+2026-09-15:1.4.11 出现 `Behavior:Win32/DefenseEvasion.A!ml` 行为检测并被隔离。
+1.4.12 修复构建依赖,按要求保留视觉和协议实现;构建成功不能证明该告警已解决。
+请查阅 `SECURITY_REVIEW_20260915.md` 的实际扫描、运行验证和复核状态。
+检测到行为告警时应保留报告并交由厂商分析,不应关闭防护、添加排除项或更换包装以规避检测。
--- current/admin_api.py
+++ protocol/admin_api.py
@@ -42,7 +42,6 @@
import admin_backend as backend
import app_version
import archive_api
-import knowledge_api
import zyt_auth
# 账号下拉框里「全部」的取值。用 -1 而不是 0:0 早就有含义了(旧版未归属数据),
@@ -339,7 +338,7 @@
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
return forwarded or (request.client.host if request.client else "")
- def current(request: Request) -> Principal:
+ async def current(request: Request) -> Principal:
raw = request.headers.get("authorization", "")
token = raw[7:].strip() if raw.lower().startswith("bearer ") else ""
user = database.session(token) if token else None
@@ -351,7 +350,7 @@
)
return Principal(user, database.permissions_for_user(int(user["id"])))
- def current_desktop(request: Request):
+ async def current_desktop(request: Request):
raw = request.headers.get("authorization", "")
token = raw[7:].strip() if raw.lower().startswith("bearer ") else ""
account = database.desktop_session(token)
@@ -667,8 +666,7 @@
# ── 客户端账号与设备 ─────────────────────────────────────────────────
@app.get("/api/v2/desktop-accounts")
- def list_desktop_accounts(
- include_counts: bool = True,
+ async def list_desktop_accounts(
principal: Principal = Depends(current),
) -> dict:
allowed = (
@@ -677,7 +675,7 @@
else database.desktop_account_ids_for_admin(principal.id)
)
return {
- "accounts": database.desktop_accounts(allowed, include_counts=include_counts),
+ "accounts": database.desktop_accounts(allowed),
"can_view_all": "desktop-account:all" in principal.permissions,
# 尚未分配任何账号的旧角色仍能查看升级前的未归属数据;一旦分配账号,
# 就必须明确选择授权账号,不能再借默认值绕过范围限制。
@@ -1278,19 +1276,12 @@
) -> dict:
# 这一份的结构由 `admin_backend.desktop_config_payload` 统一给出。
# 两处各写一份的话迟早漂移,表现成"某台客户端少了个字段",极难查。
- payload = backend.desktop_config_payload(
+ return backend.desktop_config_payload(
database,
scheme=request.headers.get("x-forwarded-proto", request.url.scheme),
host=request.headers.get("host", ""),
desktop_account=account,
)
- knowledge = app.state.knowledge_store
- tenant = str(account["tenant_id"])
- counts = await asyncio.to_thread(knowledge.stats, tenant)
- settings = await asyncio.to_thread(knowledge.settings, tenant)
- payload["knowledge"] = {"published": counts.get("published", 0),
- "enabled": bool(settings["enabled"]), "updated_at": settings["updated_at"]}
- return payload
# 桌面端回流一次编排调用。补上这条之前,客户端改指 8766 会丢掉全部调用留痕
# ——配置照常同步、回复照常发,唯独调用记录一条不进库,而那恰恰是出事后用来
@@ -1333,10 +1324,6 @@
resolve_admin_tenant=resolve_admin_tenant,
zyt_api_url=lambda: desktop_login_api_url()[0],
zyt_patient_searcher=zyt_patient_searcher,
- )
-
- knowledge_api.register_knowledge_routes(
- app, database, require, client_ip, resolve_admin_tenant, current_desktop,
)
# ── 前端静态资源 ─────────────────────────────────────────────────────
--- current/admin_backend.py
+++ protocol/admin_backend.py
@@ -299,6 +299,41 @@
},
],
}
+
+
+ALL_TENANTS = "*"
+"""「全部」:调用统计不限租户。
+
+和 `archive_store.ALL_TENANTS` 是同一个约定。两个模块各写一份常量而不是互相
+导入,是因为 `admin_backend` 是 `archive_store` 的下层(前者建库、后者建归档
+表),反过来导入会绕成一个环。
+"""
+
+
+def tenant_filter(tenant_id: Any, column: str = "tenant_id") -> tuple[str, list[str]]:
+ """把租户范围翻成一段 SQL 条件和它的参数。
+
+ 三种形状对应界面上账号下拉框的三档:
+
+ "default" 旧版未归属数据——老记录的 tenant_id 是空串或字面量 default
+ "*" 全部(有 desktop-account:all 的管理员)
+ 一组 id 全部(只被授权到若干客户端账号的管理员)
+ """
+ if isinstance(tenant_id, str) or tenant_id is None:
+ values = [str(tenant_id or "default")]
+ else:
+ values = [str(item) for item in tenant_id if str(item or "").strip()]
+ if not values:
+ values = ["default"]
+ if values == [ALL_TENANTS]:
+ # 恒真而不是空串:调用方都在 f-string 里拼 `WHERE {clause}`。
+ return "1=1", []
+ if values == ["default"]:
+ return f"{column} IN ('', 'default')", []
+ if len(values) == 1:
+ return f"{column} = ?", values
+ placeholders = ",".join("?" for _ in values)
+ return f"{column} IN ({placeholders})", values
# ── 开箱即用的智能体 ─────────────────────────────────────────────────────────
@@ -441,41 +476,6 @@
now,
),
)
-
-
-ALL_TENANTS = "*"
-"""「全部」:调用统计不限租户。
-
-和 `archive_store.ALL_TENANTS` 是同一个约定。两个模块各写一份常量而不是互相
-导入,是因为 `admin_backend` 是 `archive_store` 的下层(前者建库、后者建归档
-表),反过来导入会绕成一个环。
-"""
-
-
-def tenant_filter(tenant_id: Any, column: str = "tenant_id") -> tuple[str, list[str]]:
- """把租户范围翻成一段 SQL 条件和它的参数。
-
- 三种形状对应界面上账号下拉框的三档:
-
- "default" 旧版未归属数据——老记录的 tenant_id 是空串或字面量 default
- "*" 全部(有 desktop-account:all 的管理员)
- 一组 id 全部(只被授权到若干客户端账号的管理员)
- """
- if isinstance(tenant_id, str) or tenant_id is None:
- values = [str(tenant_id or "default")]
- else:
- values = [str(item) for item in tenant_id if str(item or "").strip()]
- if not values:
- values = ["default"]
- if values == [ALL_TENANTS]:
- # 恒真而不是空串:调用方都在 f-string 里拼 `WHERE {clause}`。
- return "1=1", []
- if values == ["default"]:
- return f"{column} IN ('', 'default')", []
- if len(values) == 1:
- return f"{column} = ?", values
- placeholders = ",".join("?" for _ in values)
- return f"{column} IN ({placeholders})", values
def effective_config(stored: dict[str, Any]) -> dict[str, Any]:
@@ -1227,7 +1227,7 @@
db.commit()
def desktop_accounts(
- self, allowed_ids: list[int] | None = None, *, include_counts: bool = True
+ self, allowed_ids: list[int] | None = None
) -> list[dict[str, Any]]:
allowed = None if allowed_ids is None else sorted({int(item) for item in allowed_ids})
where = ""
@@ -1237,13 +1237,6 @@
return []
where = f" WHERE a.id IN ({','.join('?' for _ in allowed)})"
params = tuple(allowed)
- archive_counts = (
- """(SELECT COUNT(*) FROM archive_source_account sa
- WHERE sa.tenant_id=a.tenant_id) AS source_account_count,
- (SELECT COUNT(*) FROM archive_message m
- WHERE m.tenant_id=a.tenant_id) AS message_count"""
- if include_counts else "0 AS source_account_count,0 AS message_count"
- )
now_epoch = int(time.time())
online_cutoff = (
datetime.now().astimezone() - timedelta(seconds=20)
@@ -1265,7 +1258,10 @@
(SELECT d.app_version FROM desktop_devices d
WHERE d.account_id=a.id ORDER BY d.last_seen_at DESC LIMIT 1)
AS app_version,
- {archive_counts}
+ (SELECT COUNT(*) FROM archive_source_account sa
+ WHERE sa.tenant_id=a.tenant_id) AS source_account_count,
+ (SELECT COUNT(*) FROM archive_message m
+ WHERE m.tenant_id=a.tenant_id) AS message_count
FROM desktop_accounts a{where}
ORDER BY a.last_online_at DESC,a.id DESC""",
(now_epoch, online_cutoff, *params),
--- current/app_version.py
+++ protocol/app_version.py
@@ -7,7 +7,7 @@
from typing import Any
-APP_VERSION = "1.4.9"
+APP_VERSION = "1.4.16"
_VERSION_PATTERN = re.compile(
r"^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
r"(?:[-+][0-9A-Za-z.-]+)?$"
--- current/archive_api.py
+++ protocol/archive_api.py
@@ -148,13 +148,6 @@
status_code=400,
detail="这一步要先在右上角选择具体的客户端账号,「全部」下无法确定归属",
)
- export_slot = asyncio.Lock()
-
- async def run_export(job_id: str) -> None:
- # Queued jobs wait asynchronously, never occupying the API worker pool.
- async with export_slot:
- await asyncio.to_thread(store.run_export_job, job_id)
-
def _desktop_patient_access(account: Any) -> dict[str, Any]:
try:
@@ -211,7 +204,7 @@
async def _search_patients_for_tenant(
tenant: str, keyword: str, page_no: int, page_size: int
) -> dict[str, Any]:
- token = await asyncio.to_thread(store.zyt_token, tenant)
+ token = store.zyt_token(tenant)
if zyt_patient_searcher is not None:
result = await asyncio.to_thread(
zyt_patient_searcher,
@@ -232,7 +225,7 @@
)
if not isinstance(result, dict):
raise ZytPatientError("ZYT 患者查询结果格式不正确")
- items = await asyncio.to_thread(store.cache_patients, list(result.get("items") or []), tenant)
+ items = store.cache_patients(list(result.get("items") or []), tenant)
return {
"items": items,
"total": int(result.get("total") or len(items)),
@@ -245,7 +238,7 @@
) -> dict[str, Any] | None:
"""绑定前重新确认该患者仍在当前 ZYT 账号的数据范围内。"""
- token = await asyncio.to_thread(store.zyt_token, tenant)
+ token = store.zyt_token(tenant)
if zyt_patient_searcher is not None:
result = await asyncio.to_thread(
zyt_patient_searcher, token, str(int(patient_id)), 1, 50
@@ -266,14 +259,14 @@
return await asyncio.to_thread(client.get_patient, int(patient_id))
@app.get("/api/v2/archive/stats")
- def archive_stats(
+ async def archive_stats(
account_id: int | None = None,
principal: Any = Depends(require("im:read")),
) -> dict[str, Any]:
return store.stats(resolve_admin_tenant(principal, account_id))
@app.get("/api/v2/archive/conversations")
- def archive_conversations(
+ async def archive_conversations(
limit: int = 50,
cursor: str = "",
account_id: int | None = None,
@@ -287,7 +280,7 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/conversations/{conversation_id}/messages")
- def archive_messages(
+ async def archive_messages(
conversation_id: str,
limit: int = 100,
cursor: str = "",
@@ -305,7 +298,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/imports/messages")
- def archive_import_messages(
+ async def archive_import_messages(
body: ImportBody,
request: Request,
principal: Any = Depends(require("im:import")),
@@ -318,7 +311,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/imports/metadata")
- def archive_import_metadata(
+ async def archive_import_metadata(
body: MetadataBody,
request: Request,
principal: Any = Depends(require("im:import")),
@@ -331,7 +324,7 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/desktop/checkpoint")
- def desktop_archive_checkpoint(
+ async def desktop_archive_checkpoint(
external_account_id: str,
source_table: str = "message_table",
account: Any = Depends(current_desktop),
@@ -345,7 +338,7 @@
}
@app.post("/api/v2/archive/desktop/checkpoint")
- def desktop_archive_advance_checkpoint(
+ async def desktop_archive_advance_checkpoint(
body: DesktopCheckpointBody,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
@@ -359,7 +352,7 @@
}
@app.get("/api/v2/archive/desktop/pending-attachments")
- def desktop_pending_attachments(
+ async def desktop_pending_attachments(
external_account_id: str,
limit: int = 500,
account: Any = Depends(current_desktop),
@@ -373,7 +366,7 @@
}
@app.post("/api/v2/archive/desktop/imports/messages")
- def desktop_archive_import_messages(
+ async def desktop_archive_import_messages(
body: ImportBody,
request: Request,
account: Any = Depends(current_desktop),
@@ -386,7 +379,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/desktop/imports/metadata")
- def desktop_archive_import_metadata(
+ async def desktop_archive_import_metadata(
body: MetadataBody,
request: Request,
account: Any = Depends(current_desktop),
@@ -399,7 +392,7 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/people")
- def archive_people(
+ async def archive_people(
limit: int = 100,
keyword: str = "",
account_id: int | None = None,
@@ -412,7 +405,7 @@
}
@app.get("/api/v2/archive/people/{person_id}")
- def archive_person(
+ async def archive_person(
person_id: str,
account_id: int | None = None,
principal: Any = Depends(require("im:identity:write")),
@@ -427,7 +420,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/people/{person_id}/identities")
- def bind_archive_identity(
+ async def bind_archive_identity(
person_id: str,
body: IdentityBody,
request: Request,
@@ -455,9 +448,7 @@
account_id: int | None = None,
principal: Any = Depends(require("im:identity:write")),
) -> dict[str, Any]:
- tenant = single_tenant(
- await asyncio.to_thread(resolve_admin_tenant, principal, account_id)
- )
+ tenant = single_tenant(resolve_admin_tenant(principal, account_id))
try:
return await _search_patients_for_tenant(
tenant, keyword, page_no, page_size
@@ -466,7 +457,7 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/desktop/patient-context")
- def desktop_patient_context(
+ async def desktop_patient_context(
external_account_id: str,
conversation_external_id: str,
person_id: str = "",
@@ -522,7 +513,7 @@
status_code=403,
detail="当前 ZYT 账号无权访问该患者,不能建立绑定",
)
- context = await asyncio.to_thread(store.conversation_patient_context,
+ context = store.conversation_patient_context(
body.external_account_id,
body.conversation_external_id,
tenant,
@@ -533,7 +524,7 @@
if context.get("requires_person_selection"):
raise ValueError("群聊中有多个联系人,请先选择需要绑定的联系人")
raise ValueError(str(context.get("reason") or "当前会话没有可绑定的联系人"))
- binding = await asyncio.to_thread(store.bind_patient,
+ binding = store.bind_patient(
str(person["id"]),
body.model_dump(),
None,
@@ -543,7 +534,7 @@
return {
"binding": binding,
"context": _context_with_access(
- await asyncio.to_thread(store.conversation_patient_context,
+ store.conversation_patient_context(
body.external_account_id,
body.conversation_external_id,
tenant,
@@ -556,7 +547,7 @@
raise _http_error(exc) from exc
@app.delete("/api/v2/archive/desktop/patient-binding")
- def desktop_unbind_archive_patient(
+ async def desktop_unbind_archive_patient(
request: Request,
external_account_id: str,
conversation_external_id: str,
@@ -594,7 +585,7 @@
raise _http_error(exc) from exc
@app.put("/api/v2/archive/people/{person_id}/patient-bindings")
- def bind_archive_patient(
+ async def bind_archive_patient(
person_id: str,
body: PatientBindingBody,
request: Request,
@@ -620,7 +611,7 @@
@app.delete(
"/api/v2/archive/people/{person_id}/patient-bindings/{binding_id}"
)
- def unbind_archive_patient(
+ async def unbind_archive_patient(
person_id: str,
binding_id: str,
request: Request,
@@ -641,13 +632,13 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/storage")
- def archive_storage(
+ async def archive_storage(
_: Any = Depends(require("im:storage:write")),
) -> dict[str, Any]:
return {"storage": store.storage_config()}
@app.put("/api/v2/archive/storage")
- def save_archive_storage(
+ async def save_archive_storage(
body: StorageBody,
request: Request,
principal: Any = Depends(require("im:storage:write")),
@@ -662,7 +653,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/storage/test")
- def test_archive_storage(
+ async def test_archive_storage(
request: Request,
principal: Any = Depends(require("im:storage:write")),
) -> dict[str, Any]:
@@ -685,7 +676,7 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/media")
- def archive_media(
+ async def archive_media(
limit: int = 100,
account_id: int | None = None,
principal: Any = Depends(require("im:content:read")),
@@ -697,7 +688,7 @@
}
@app.post("/api/v2/archive/media/access-urls")
- def archive_media_access_urls(
+ async def archive_media_access_urls(
body: MediaAccessBody,
request: Request,
account_id: int | None = None,
@@ -720,7 +711,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/media/prepare")
- def prepare_archive_media(
+ async def prepare_archive_media(
body: MediaPrepareBody,
_: Any = Depends(require("im:import")),
) -> dict[str, Any]:
@@ -730,7 +721,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/desktop/media/prepare")
- def desktop_prepare_archive_media(
+ async def desktop_prepare_archive_media(
body: MediaPrepareBody,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
@@ -742,7 +733,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/media/{media_id}/complete")
- def complete_archive_media(
+ async def complete_archive_media(
media_id: str,
_: Any = Depends(require("im:import")),
) -> dict[str, Any]:
@@ -752,7 +743,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/media/{media_id}/multipart-complete")
- def complete_archive_multipart_media(
+ async def complete_archive_multipart_media(
media_id: str,
body: MediaMultipartCompleteBody,
_: Any = Depends(require("im:import")),
@@ -769,7 +760,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/desktop/media/{media_id}/complete")
- def desktop_complete_archive_media(
+ async def desktop_complete_archive_media(
media_id: str,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
@@ -781,7 +772,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/desktop/media/{media_id}/multipart-complete")
- def desktop_complete_archive_multipart_media(
+ async def desktop_complete_archive_multipart_media(
media_id: str,
body: MediaMultipartCompleteBody,
account: Any = Depends(current_desktop),
@@ -799,7 +790,7 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/media/{media_id}/view")
- def view_archive_media(
+ async def view_archive_media(
media_id: str,
request: Request,
account_id: int | None = None,
@@ -817,7 +808,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/exports")
- def create_archive_export(
+ async def create_archive_export(
body: ExportBody,
request: Request,
background: BackgroundTasks,
@@ -832,13 +823,13 @@
client_ip(request),
single_tenant(resolve_admin_tenant(principal, account_id)),
)
- background.add_task(run_export, job["id"])
+ background.add_task(store.run_export_job, job["id"])
return {"job": job}
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/exports")
- def archive_exports(
+ async def archive_exports(
limit: int = 100,
account_id: int | None = None,
principal: Any = Depends(require("im:export")),
@@ -850,7 +841,7 @@
}
@app.get("/api/v2/archive/exports/{job_id}")
- def archive_export(
+ async def archive_export(
job_id: str,
account_id: int | None = None,
principal: Any = Depends(require("im:export")),
@@ -865,7 +856,7 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/export-files/{file_id}/download")
- def download_archive_export(
+ async def download_archive_export(
file_id: str,
request: Request,
account_id: int | None = None,
--- current/archive_auto_backup.py
+++ protocol/archive_auto_backup.py
@@ -74,6 +74,10 @@
if is_frozen():
bundled = resource_path("archive_exporter")
return bundled if (bundled / "wxwork_export_final.py").is_file() else None
+
+ local = Path(__file__).resolve().parent / "archive_exporter"
+ if (local / "wxwork_export_final.py").is_file():
+ return local
project_parent = Path(__file__).resolve().parent.parent
exact = project_parent / EXACT_EXPORTER_DIR
--- current/archive_store.py
+++ protocol/archive_store.py
@@ -372,17 +372,6 @@
ON archive_conversation_member(person_id, conversation_id);
CREATE INDEX IF NOT EXISTS idx_archive_conv_recent
ON archive_conversation(tenant_id, last_message_at DESC, id);
--- New name also upgrades existing databases with the old mixed-direction index.
-CREATE INDEX IF NOT EXISTS idx_archive_conv_page
- ON archive_conversation(tenant_id, last_message_at DESC, id DESC);
-CREATE INDEX IF NOT EXISTS idx_archive_person_recent
- ON archive_person(tenant_id, updated_at DESC, id DESC);
-CREATE INDEX IF NOT EXISTS idx_archive_msg_recent
- ON archive_message(tenant_id, sent_at DESC, id DESC);
-CREATE INDEX IF NOT EXISTS idx_archive_media_recent
- ON archive_media_object(tenant_id, created_at DESC, id DESC);
-CREATE INDEX IF NOT EXISTS idx_archive_export_files
- ON archive_export_file(job_id, file_name);
CREATE INDEX IF NOT EXISTS idx_archive_msg_timeline
ON archive_message(tenant_id, conversation_id, sent_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_archive_msg_sender
@@ -2741,7 +2730,7 @@
FROM archive_conversation_member cm
WHERE cm.person_id=p.id) AS conversation_count,
(SELECT COUNT(*) FROM archive_message m
- WHERE m.tenant_id=p.tenant_id AND m.sender_person_id=p.id) AS message_count,
+ WHERE m.sender_person_id=p.id) AS message_count,
(SELECT COUNT(*) FROM archive_patient_binding pb
WHERE pb.tenant_id=p.tenant_id AND pb.person_id=p.id
AND pb.status='active') AS patient_binding_count,
@@ -2931,34 +2920,35 @@
params: list[Any] = list(scope.params)
where = scope.clause("c")
if cursor:
- where += " AND (c.last_message_at,c.id) < (?,?)"
+ where += " AND (c.last_message_at < ? OR (c.last_message_at=? AND c.id<?))"
try:
cursor_time, cursor_id = cursor.split("|", 1)
except ValueError as exc:
raise ValueError("会话游标格式不正确") from exc
- params.extend([cursor_time, cursor_id])
+ params.extend([cursor_time, cursor_time, cursor_id])
params.append(limit + 1)
with self.database.connect() as db:
rows = db.execute(
- f"""WITH page AS (
- SELECT c.* FROM archive_conversation c WHERE {where}
- ORDER BY c.last_message_at DESC,c.id DESC LIMIT ?
- )
- SELECT c.*,a.external_account_id,
+ f"""SELECT c.*,a.external_account_id,
(SELECT COUNT(*) FROM archive_message m
- WHERE m.tenant_id=c.tenant_id AND m.conversation_id=c.id)
- AS message_count,
- last.content AS last_content,last.message_type AS last_message_type,
+ WHERE m.conversation_id=c.id) AS message_count,
+ (SELECT m.content FROM archive_message m
+ WHERE m.conversation_id=c.id
+ ORDER BY m.sent_at DESC,m.id DESC LIMIT 1) AS last_content,
+ (SELECT m.message_type FROM archive_message m
+ WHERE m.conversation_id=c.id
+ ORDER BY m.sent_at DESC,m.id DESC LIMIT 1) AS last_message_type,
(SELECT COUNT(*) FROM archive_message_attachment ma
- WHERE ma.message_id=last.id) AS last_attachment_count
- FROM page c
+ JOIN archive_message m ON m.id=ma.message_id
+ WHERE m.conversation_id=c.id
+ AND m.id=(SELECT lm.id FROM archive_message lm
+ WHERE lm.conversation_id=c.id
+ ORDER BY lm.sent_at DESC,lm.id DESC LIMIT 1))
+ AS last_attachment_count
+ FROM archive_conversation c
JOIN archive_source_account a ON a.id=c.source_account_id
- LEFT JOIN archive_message last ON last.id=(
- SELECT lm.id FROM archive_message lm
- WHERE lm.tenant_id=c.tenant_id AND lm.conversation_id=c.id
- ORDER BY lm.sent_at DESC,lm.id DESC LIMIT 1
- )
- ORDER BY c.last_message_at DESC,c.id DESC""",
+ WHERE {where}
+ ORDER BY c.last_message_at DESC,c.id DESC LIMIT ?""",
tuple(params),
).fetchall()
has_more = len(rows) > limit
@@ -3002,8 +2992,8 @@
cursor_time, cursor_id = cursor.split("|", 1)
except ValueError as exc:
raise ValueError("消息游标格式不正确") from exc
- where += " AND (m.sent_at,m.id) > (?,?)"
- params.extend([cursor_time, cursor_id])
+ where += " AND (m.sent_at > ? OR (m.sent_at=? AND m.id>?))"
+ params.extend([cursor_time, cursor_time, cursor_id])
params.append(limit + 1)
with self.database.connect() as db:
rows = db.execute(
@@ -3163,10 +3153,6 @@
"SELECT * FROM archive_export_file WHERE job_id=? ORDER BY file_name",
(job_id,),
).fetchall()
- return self._public_export_job(row, files)
-
- @staticmethod
- def _public_export_job(row: Any, files: Iterable[Any]) -> dict[str, Any]:
item = {key: row[key] for key in row.keys()}
item["formats"] = json.loads(item.pop("formats_json"))
item["filters"] = json.loads(item.pop("filters_json"))
@@ -3189,22 +3175,15 @@
) -> list[dict[str, Any]]:
scope = tenant_scope(tenant_id)
with self.database.connect() as db:
- rows = db.execute(
- f"""SELECT * FROM archive_export_job WHERE {scope.clause()}
- ORDER BY created_at DESC,id DESC LIMIT ?""",
- (*scope.params, max(1, min(int(limit), 500))),
- ).fetchall()
- files_by_job: dict[str, list[Any]] = {row["id"]: [] for row in rows}
- if rows:
- placeholders = ",".join("?" for _ in rows)
- files = db.execute(
- f"""SELECT * FROM archive_export_file
- WHERE job_id IN ({placeholders}) ORDER BY file_name""",
- tuple(files_by_job),
+ ids = [
+ row["id"]
+ for row in db.execute(
+ f"""SELECT id FROM archive_export_job WHERE {scope.clause()}
+ ORDER BY created_at DESC LIMIT ?""",
+ (*scope.params, max(1, min(int(limit), 500))),
).fetchall()
- for file in files:
- files_by_job[file["job_id"]].append(file)
- return [self._public_export_job(row, files_by_job[row["id"]]) for row in rows]
+ ]
+ return [self.export_job(job_id) for job_id in ids]
def _export_where(self, filters: dict[str, Any], cutoff: str) -> tuple[str, list[Any]]:
where = ["m.tenant_id=?", "m.created_at<=?"]
--- current/assets/ui/console-app.js
+++ protocol/assets/ui/console-app.js
@@ -131,7 +131,7 @@
var people=[["高","高瑞@微信","处理中","00:00:12",""],["小","一个小迷糊@微信","等待中","00:01:45","green"],["李","李先生@微信","等待中","00:02:10",""] ,["陈","陈女士@微信","待重试","—","violet"],["张","张小明@微信","等待中","00:03:22","cyan"]];
var listening=false;
- var head=(title,sub,actions)=>`<header class="page-head"><div><h1>${title}</h1><p>${sub}</p></div><div class="head-actions">${actions}</div></header>`;
+ var head=(title,sub,actions="")=>`<header class="page-head"><div><h1>${title}</h1><p>${sub}</p></div><div class="head-actions">${actions}</div></header>`;
var connected=(extra="",primary="")=>`<span class="btn"><i class="led"></i>企业微信已连接</span><span class="btn"><i class="led"></i>AI 服务正常</span>${extra}<button class="btn ${primary}" data-action="toggle-listen">Ⅱ&nbsp; 暂停监听</button>`;
var ribbon=items=>`<section class="panel wave-ribbon">${items.map(x=>`<div class="metric"><div class="metric-icon ${x[3]||""}">${icon(x[2])}</div><div class="metric-copy"><span>${x[0]}</span><strong class="${x[4]||x[3]||""}"${x[5]?` data-live-metric="${esc(x[5])}"`:""}>${x[1]}</strong></div></div>`).join("")}</section>`;
var queueList=(archive=false)=>`<div class="queue-list">${people.map((p,i)=>`<div class="queue-row ${i===0?"active":""}"><div class="avatar ${p[4]}">${p[0]}</div><div class="q-copy"><strong>${p[1]}</strong><span>${archive?(i===0?"请问你们的灵芝孢子粉怎么服用…":i===1?"每天吃几次比较合适?":i===2?"有不良反应吗?":"会话已归档"):`<i class="${i===3?"orange":i?"gray":""}"></i>${p[2]}`}</span></div><div class="q-meta"><span>${archive?["16:31","16:18","15:42","14:20","13:35"][i]:p[3]}</span>${archive?`<span class="badge ${i===2?"orange":""}">${i===2?"已转人工":"AI 已回复"}</span>`:`<span class="bubble-count">${icon("message")} ${i===3?1:0}</span>`}</div></div>`).join("")}</div>`;
@@ -399,11 +399,59 @@
return `<div class="screen">${head("AI 设置","配置模型、客服人格、知识库与工具能力",`<span class="btn" data-config-status="1"><i class="led"></i>${configLabel}</span><button class="btn" data-action="test-ai">▷ 测试 AI</button><button class="btn primary" data-action="save-ai">${icon("send")}保存并发布</button>`)}<div class="tabs">${tabs}</div><div class="ai-top"><section class="panel panel-pad"><div class="panel-title"><h2>客服人格</h2></div><div class="ai-profile"><div class="ai-ball">AI</div><div class="stack"><div class="field"><label>客服名称:</label><input class="input" id="ai-name" value="${esc(ai.name || "贴心管家")}" maxlength="40"></div><div class="toolbar"><b style="font-size:13px">服务风格:</b><span class="badge green">温和</span><span class="badge">专业</span><span class="badge" style="color:#7657e7;background:rgba(125,91,236,.08)">简洁</span></div><div class="field"><label>人格提示词:</label><textarea class="textarea" id="ai-prompt" readonly aria-readonly="true">${esc(ai.prompt || "")}</textarea></div></div><div class="preview-box"><b>✧ 实时预览</b><p>您好!我是甄养堂${esc(ai.name || "贴心管家")},<br>很高兴为您服务 😊</p><p>请问有什么可以帮您的吗?</p></div></div></section><section class="panel panel-pad"><div class="panel-title"><h2>✣ 模型编排</h2><small>由后台决定,本机只执行</small></div><div class="stack" style="gap:7px"><div class="system-line"><span>答题模型:</span><b>${esc(plan.answer || "后台未配置")}</b></div><div class="system-line"><span>裁判模型:</span><b>${esc(plan.judge || "未启用")}</b></div><div class="system-line"><span>裁判模式:</span><b>${esc(plan.mode || "—")}</b></div><div class="system-line"><span>编排版本:</span><b>${plan.version ? "v" + esc(plan.version) : "—"}</b></div><div class="system-line"><span>上下文轮数:</span><b>${esc(ai.context || 8)} 轮</b></div></div><div class="tiny" style="margin-top:10px;word-break:break-all">网关:${esc(plan.gateway || "未下发——模型调用会失败,请去「系统设置 → 后台连接」")}</div><div class="tiny" style="margin-top:6px">最后同步:${esc(plan.syncedAt || "尚未同步")}</div><div class="tiny" style="margin-top:10px;line-height:1.7">模型接口、密钥、温度、最大 tokens 现在统一在后台的<b>「AI 模型 → 模型清单 / 角色编排」</b>里配。<br>上下文轮数和 MCP 在<b>「桌面端 → 客户端配置」</b>里配,随配置下发——在这台机器上改会被下一次同步覆盖。</div></section></div><div class="ai-bottom"><section class="panel panel-pad"><div class="panel-title"><h2>▰ 企业知识库</h2></div><div class="knowledge"><div class="donut"><strong>${esc(knowledgeCount)}<small>个知识片段</small></strong></div><div class="stack"><span class="badge green">● ${esc(syncLabel)}</span><span class="badge green">✓ ${connectedKnowledge?"已连接":"未连接"}</span><button class="btn small primary" data-action="sync-knowledge">${icon("refresh")}立即同步</button><button class="btn small" data-action="edit-knowledge">${icon("folder")}管理知识</button></div></div></section><section class="panel panel-pad"><div class="panel-title"><h2>◇ MCP 工具</h2><small>单次最多调用 ${esc(ai.mcpRounds || 5)} 轮 · 由后台下发</small></div><div class="tool-grid">${names.map((name,index)=>`<div class="tool-card"><div class="tool-icon">${toolTokens[index]}</div><b>${name}</b><div class="row"><small>${toolsOn[index]?"已启用":"未启用"}</small><div class="switch ${toolsOn[index]?"on":""} switch-locked" title="由后台「桌面端 → 客户端配置」决定"></div></div></div>`).join("")}</div><div class="tiny" style="margin-top:10px;line-height:1.7">工具清单和调用轮数在后台的<b>「桌面端 → 客户端配置 → MCP 服务器」</b>里配,随配置下发。<br>在这台机器上改会被下一次同步覆盖,所以这里只展示不修改。</div></section><section class="panel panel-pad"><div class="panel-title"><h2>${icon("shield")} 选择性审核规则</h2><small>由后台决定,本机只执行</small></div><div class="stack">${reviewRulesHtml}</div><div class="tiny" style="margin-top:8px;line-height:1.7">命中任意一条,这一条回复才会停下来等人工确认;其余照常自动发送。模型裁判判定为高风险时同样会转人工,那一条不在这份清单里、关不掉。</div><div class="tiny" style="margin-top:4px">在后台的<b>「桌面端 → 客户端配置 → 审核规则」</b>里配置,随配置下发。</div></section><section class="panel panel-pad"><div class="panel-title"><h2>发送模式</h2></div><div class="row" style="height:100%;align-items:center"><span class="badge ${blanketReview?"orange":"green"}">${icon("shield")}${blanketReview?"全部审核后发送":"自动发送(命中规则单独审核)"}</span></div><div class="tiny" style="margin-top:8px">在「自动化设置」页顶部切换;这个总开关和上面的选择性规则是 OR 的关系,任一个成立都会拦下。</div></section></div><section class="panel panel-pad config-strip"><div class="panel-title"><h2>配置状态</h2></div>${statusCells([["cloud",STATE.aiOk===false?"AI 服务异常":"云端模型正常","最后检查:刚刚"],["folder",knowledgeStatus,`${esc(knowledgeCount)} 个知识片段`],["folder",`${toolCount} 个工具可用`,toolStatus],["shield","安全策略已生效","严格模式"]])}</section></div>`;
};
+function replyTransportPanel(a){
+ const p = a.protocol || {};
+ const selected = a.transport || "visual";
+ const native = p.nativeAdapter ? {} : (p.nativeTrial || {});
+ const ready = p.canSend === true && p.status === "native_ready" && p.databaseReady === true;
+ const trialTime = native.checkedAt ? new Date(native.checkedAt * 1000).toLocaleString("zh-CN") : "";
+ const checking = p.status === "checking";
+ const checked = p.checkedAt ? new Date(p.checkedAt * 1000).toLocaleString("zh-CN") : "尚未检测";
+ return `<section class="panel panel-pad" style="margin:0 0 16px">
+ <div class="panel-title"><h2>回复通道</h2><span class="badge ${selected==="visual"||ready?"green":"orange"}">${selected==="visual"?"视觉模式":selected==="protocol"?(ready?"协议模式 · 已就绪":checking?"协议模式 · 检测中":p.checkedAt?"协议模式 · 需处理":"协议模式 · 待检测"):"配置无效"}</span></div>
+ <div class="toolbar" style="flex-wrap:wrap;margin-bottom:12px">
+ <button class="btn ${selected==="visual"?"primary":""}" data-action="reply-transport" data-key="visual">视觉模式</button>
+ <button class="btn ${selected==="protocol"?"primary":""}" data-action="reply-transport" data-key="protocol">协议模式</button>
+ <button class="btn" data-action="probe-protocol" ${checking?"disabled":""}>${icon("refresh")}${checking?"检测中…":"检测本机协议"}</button>
+ ${p.needsSendValidation?`<button class="btn primary" data-action="verify-protocol-send" ${checking||STATE.listening?"disabled":""}>首次发送验证</button>`:""}
+ <button class="btn" data-action="export-protocol" ${p.exportable&&!checking?"":"disabled"}>${icon("download")}导出报告</button>
+ </div>
+ <div class="system-line"><span>协议状态</span><b>${esc(p.label || "尚未检测")}</b></div>
+ <p style="margin:8px 0;line-height:1.7">${esc(p.message || "检测企业微信版本、程序架构和内置接口组件。")}</p>
+ <div class="toolbar" style="flex-wrap:wrap">
+ <span class="badge">客户端:${esc(p.clientVersion || "未检测")} ${esc(p.architecture || "")}</span>
+ <span class="badge ${p.adapterInstalled?"green":"orange"}">协议适配器:${p.adapterInstalled?"构建已匹配":p.checkedAt?"未就绪":"待检测"}</span>
+ ${p.nativeAccount?`<span class="badge green">当前账号:${esc(p.nativeAccount)}</span>`:""}
+ <span class="badge ${p.databaseReady?"green":"orange"}">本机消息库:${esc((p.database||{}).label || "待检测")}</span>
+ ${native.checkedAt?`<span class="badge ${native.transportVerified?"green":"orange"}">原生 MCP 上次实测:${native.transportVerified?"握手成功":"未验证"}</span>`:""}
+ <span class="badge ${p.loginVerified?"green":"orange"}">原账号登录:${p.loginVerified?"已核对":"未验证"}</span>
+ <span class="badge ${p.sendVerified?"green":"orange"}">客户单聊发送:${p.sendVerified?(p.localSendVerified?"当前账号已实测":"适配版本已实测"):"未验证"}</span>
+ <span class="badge ${p.receiptVerified?"green":"orange"}">发送回执:${p.receiptVerified?(p.localSendVerified?"当前账号已实测":"适配版本已实测"):"未验证"}</span>
+ </div>
+ ${native.checkedAt?`<div class="tiny" style="margin-top:10px">独立实测记录:${esc(trialTime)} · 客户端 ${esc(native.clientVersion)}${native.sameClientVersion?"":"(与当前版本不同)"} · ${native.businessStatus==="not_enabled"?"业务接口未开放(-90020005 / user not in gray)":"业务能力未验证"}。此记录不代表当前连接、账号权限或发送成功。</div>`:""}
+ ${p.clientInstallPath?`<div class="tiny" style="margin-top:10px;overflow-wrap:anywhere">企业微信位置:${esc(p.clientInstallPath)}</div>`:""}
+ ${p.database && !p.databaseReady?`<div class="toolbar" style="margin-top:10px"><button class="btn" data-action="wechat-db-directory" ${checking||STATE.listening?"disabled":""}>${icon("folder")}选择聊天数据目录</button><span class="tiny">数据可以放在其他盘,选择一次后会记住。</span></div>`:""}
+ <div class="tiny" style="margin-top:10px">检测时间:${esc(checked)} · 自动检查本机环境并按需初始化消息库,不发送消息。发送与回执标记来自适配版本实测,本机实际结果以发送回执为准。</div>
+ </section>`;
+}
+
automationView = function(){
if (STATE.demo) return _automationView();
const a = STATE.automation || {};
const autoOn = (STATE.sendMode || "auto") === "auto";
- return `<div class="screen">${head("自动化设置","配置企业微信监听、消息合并与自动发送规则",`<span class="btn"><i class="led"></i>${esc(a.wecom || "企业微信未识别")}</span><span class="btn"><i class="led"></i>规则已生效</span><button class="btn primary" data-action="save-automation">${icon("save")}保存设置</button>`)}<div class="tabs"><button class="tab ${autoOn?"active":""}" data-action="send-mode" data-key="auto">${icon("send")} 自动发送</button><button class="tab ${autoOn?"":"active"}" data-action="send-mode" data-key="review">${icon("users")} 审核后发送</button></div><div class="automation-top"><section class="panel panel-pad"><div class="panel-title"><h2>监听与发送策略</h2></div><span class="badge green">${icon("activity")} ● 规则运行中</span><div class="auto-flow">${[["activity","扫描未读"],["log","提取消息"],["folder","合并消息"],["brain","请求 AI"],["send","回填发送"]].map(x=>`<div class="flow-step"><div class="flow-circle">${icon(x[0])}</div><strong>${x[1]}</strong></div>`).join("")}</div><div class="form-grid"><div class="flow-pill">${icon("clock")}扫描间隔 <input class="num" id="auto-poll" type="number" min="0.2" max="300" step="0.1" value="${esc(a.poll || "2")}"> 秒</div><div class="flow-pill">${icon("hourglass")} 连续消息等待 <input class="num" id="auto-batch" type="number" min="1" max="120" step="1" value="${esc(a.batch || "20")}"> 秒</div><div class="flow-pill">${icon("clock")}发送前等待 <input class="num" id="auto-delay" type="number" min="0" max="30" step="0.5" value="${esc(a.delay || "1")}"> 秒</div></div></section><section class="panel panel-pad"><div class="panel-title"><h2>企业微信窗口</h2></div><div class="wechat-graphic"><div class="fake-window"><div class="fake-check">✓</div></div><div class="stack"><div class="row"><span>${icon("clipboard")} 主窗口:</span><b style="color:var(--green)">● ${esc(a.wecom || "未识别")}</b></div><div class="row"><span>${icon("user")} 当前账号:</span><b>${esc(a.account || "—")}</b></div><div class="row"><span>${icon("globe")} DPI:</span><b>${esc(STATE.dpi || "—")}</b></div><div class="row"><span>${icon("monitor")} 最小化恢复:</span><b style="color:var(--green)">● 已开启</b></div></div></div><div class="task-actions"><button class="btn small blue" data-action="redetect">${icon("refresh")}重新检测</button></div></section></div><div class="automation-mid"><section class="panel panel-pad"><div class="panel-title"><h2>人机共存</h2></div><div class="setting-line"><span>启用人工操作保护</span><div class="switch ${a.mouseProtect?"on":""}" data-action="toggle-idle"></div></div><div style="text-align:center;margin-top:8px">鼠标静止 <input class="idle-num" id="auto-idle" type="number" min="0" max="3600" step="0.5" value="${esc(a.idle || "5")}"> 秒 后恢复</div><div class="setting-line" style="margin-top:10px"><span>草稿超时直发</span><div class="switch ${a.draftAutosend > 0 ? "on" : ""}" data-action="toggle-draft-autosend"></div></div><div style="text-align:center;margin-top:6px">输入框里有人工草稿 <input class="idle-num" id="auto-draft-autosend" type="number" min="1" max="1440" step="5" value="${esc(a.draftAutosend > 0 ? a.draftAutosend : 15)}" ${a.draftAutosend > 0 ? "" : "disabled"}> 分钟后直接发出</div><div class="tiny" style="text-align:center;margin-top:4px;color:var(--muted);font-size:11px">关闭时只跳过该会话、绝不动别人写的字;命中审核规则的仍会等人确认</div><div class="mouse-orb"><span style="color:#315fec">${icon("bot")}<br><small>AI 运行中</small></span><div class="mouse-pointer">${icon("cursor")}</div><span style="color:var(--orange)">${icon("pause")}<br><small>检测到人工操作<br>AI 暂停</small></span></div></section><section class="panel panel-pad"><div class="panel-title"><h2>消息处理</h2></div><div class="stack"><div class="setting-line"><span>✓ 同一客户连续消息自动合并</span></div><div class="setting-line"><span>✓ 保留最近 <input class="ai-num" id="auto-context" type="number" min="1" max="50" value="${esc(a.context || 8)}"> 轮上下文</span><span class="badge">${esc(a.context || 8)} 轮</span></div><div class="setting-line"><span>✓ 一次只处理 1 个会话</span><span class="badge">1 个</span></div></div></section><section class="panel panel-pad"><div class="panel-title"><h2>异常与兜底</h2></div><div class="stack"><div class="setting-line"><span style="color:var(--orange)">${icon("clock")}</span><span style="flex:1">AI 超时:${esc(a.timeout || 120)} 秒后重试</span><span class="badge">2 次</span></div><div class="setting-line"><span style="color:var(--orange)">${icon("alert")}</span><span style="flex:1">发送失败:自动重试</span><span class="badge green">已开启</span></div><div class="setting-line"><span style="color:#3c69f4">${icon("message")}</span><span style="flex:1">固定回复:${esc(a.reply || "在的,请稍等")}</span><button class="badge" data-action="edit-reply">编辑</button></div></div></section><section class="panel panel-pad"><div class="panel-title"><h2>高风险转人工</h2><span class="badge green">● 已开启</span></div><div class="risk-tags"><span class="risk-tag">${icon("shield")} 诊断</span><span class="risk-tag">${icon("package")} 用药调整</span><span class="risk-tag">${icon("coins")} 投诉退款</span></div><button class="btn small blue" style="margin:11px auto 0;display:flex" data-action="security-rules">${icon("settings")}规则管理</button></section></div><section class="panel panel-pad check-strip"><div class="panel-title"><h2>启动前检查</h2></div>${statusCells([["check","企业微信已登录", STATE.wecomConnected ? "检查完成" : "等待连接"],["cloud","AI 服务正常","检查完成"],["activity","窗口坐标已校准", STATE.wecomConnected ? "检查完成" : "待校准"],["shield","规则配置有效","检查完成"]])}</section></div>`;
+ const transportPanel = replyTransportPanel(a);
+ if (a.transport && a.transport !== "visual") {
+ const p = a.protocol || {};
+ const ready = p.canSend === true && p.status === "native_ready" && p.databaseReady === true;
+ return `<div class="screen">${head("自动化设置","协议模式 · 保留原员工账号与现有客户会话",`<button class="btn primary" data-action="save-automation">${icon("save")}保存设置</button>`)}${transportPanel}
+ <div class="tabs"><button class="tab ${autoOn?"active":""}" data-action="send-mode" data-key="auto">自动发送</button><button class="tab ${autoOn?"":"active"}" data-action="send-mode" data-key="review">审核后发送</button></div>
+ <section class="panel panel-pad"><div class="panel-title"><h2>${ready?"协议自动回复已就绪":"协议模式尚未就绪"}</h2><span class="badge ${ready?"green":"orange"}">${esc(ready?"原账号文本单聊":p.label || "正在准备检测")}</span></div>
+ <p>启动后监听新到的文本消息,使用当前员工账号回复现有客户。审核模式下,回复保留在任务队列,点击“通过并发送”后发送。</p>
+ <div class="form-grid"><label class="flow-pill">监听间隔 <input class="num" id="auto-poll" type="number" min="0.2" max="300" step="0.1" value="${esc(a.poll ?? 2)}"> 秒</label><label class="flow-pill">连续消息等待 <input class="num" id="auto-batch" type="number" min="1" max="120" value="${esc(a.batch ?? 20)}"> 秒</label><label class="flow-pill">发送前等待 <input class="num" id="auto-delay" type="number" min="0" max="30" step="0.5" value="${esc(a.delay ?? 1)}"> 秒</label></div>
+ <p class="tiny">支持 ${esc(p.supportedClient || "5.0.10.6023 x86、5.0.11.6018 x86")}。自动识别安装盘符和目录;其他企业微信版本、x64 客户端仍需单独适配。当前自动处理文本消息。</p></section>
+ <section class="panel panel-pad" style="margin-top:16px"><div class="panel-title"><h2>发送核对</h2><button class="btn small blue" data-action="security-rules">审核规则</button></div><p>客户补充消息时重新生成回复;人工已经回复时取消旧任务。发送结果不明确时暂停监听并保留记录,防止重复发送。</p><p class="tiny">协议模式按会话 ID 发送,不操作聊天窗口。切换账号或升级企业微信后,会重新校验可用性。</p></section></div>`;
+ }
+ return `<div class="screen">${head("自动化设置","配置企业微信监听、消息合并与自动发送规则",`<span class="btn"><i class="led"></i>${esc(a.wecom || "企业微信未识别")}</span><span class="btn"><i class="led"></i>规则已生效</span><button class="btn primary" data-action="save-automation">${icon("save")}保存设置</button>`)}${transportPanel}<div class="tabs"><button class="tab ${autoOn?"active":""}" data-action="send-mode" data-key="auto">${icon("send")} 自动发送</button><button class="tab ${autoOn?"":"active"}" data-action="send-mode" data-key="review">${icon("users")} 审核后发送</button></div><div class="automation-top"><section class="panel panel-pad"><div class="panel-title"><h2>监听与发送策略</h2></div><span class="badge green">${icon("activity")} ● 规则运行中</span><div class="auto-flow">${[["activity","扫描未读"],["log","提取消息"],["folder","合并消息"],["brain","请求 AI"],["send","回填发送"]].map(x=>`<div class="flow-step"><div class="flow-circle">${icon(x[0])}</div><strong>${x[1]}</strong></div>`).join("")}</div><div class="form-grid"><div class="flow-pill">${icon("clock")}扫描间隔 <input class="num" id="auto-poll" type="number" min="0.2" max="300" step="0.1" value="${esc(a.poll || "2")}"> 秒</div><div class="flow-pill">${icon("hourglass")} 连续消息等待 <input class="num" id="auto-batch" type="number" min="1" max="120" step="1" value="${esc(a.batch || "20")}"> 秒</div><div class="flow-pill">${icon("clock")}发送前等待 <input class="num" id="auto-delay" type="number" min="0" max="30" step="0.5" value="${esc(a.delay || "1")}"> 秒</div></div></section><section class="panel panel-pad"><div class="panel-title"><h2>企业微信窗口</h2></div><div class="wechat-graphic"><div class="fake-window"><div class="fake-check">✓</div></div><div class="stack"><div class="row"><span>${icon("clipboard")} 主窗口:</span><b style="color:var(--green)">● ${esc(a.wecom || "未识别")}</b></div><div class="row"><span>${icon("user")} 当前账号:</span><b>${esc(a.account || "—")}</b></div><div class="row"><span>${icon("globe")} DPI:</span><b>${esc(STATE.dpi || "—")}</b></div><div class="row"><span>${icon("monitor")} 最小化恢复:</span><b style="color:var(--green)">● 已开启</b></div></div></div><div class="task-actions"><button class="btn small blue" data-action="redetect">${icon("refresh")}重新检测</button></div></section></div><div class="automation-mid"><section class="panel panel-pad"><div class="panel-title"><h2>人机共存</h2></div><div class="setting-line"><span>启用人工操作保护</span><div class="switch ${a.mouseProtect?"on":""}" data-action="toggle-idle"></div></div><div style="text-align:center;margin-top:8px">鼠标静止 <input class="idle-num" id="auto-idle" type="number" min="0" max="3600" step="0.5" value="${esc(a.idle || "5")}"> 秒 后恢复</div><div class="setting-line" style="margin-top:10px"><span>草稿超时直发</span><div class="switch ${a.draftAutosend > 0 ? "on" : ""}" data-action="toggle-draft-autosend"></div></div><div style="text-align:center;margin-top:6px">输入框里有人工草稿 <input class="idle-num" id="auto-draft-autosend" type="number" min="1" max="1440" step="5" value="${esc(a.draftAutosend > 0 ? a.draftAutosend : 15)}" ${a.draftAutosend > 0 ? "" : "disabled"}> 分钟后直接发出</div><div class="tiny" style="text-align:center;margin-top:4px;color:var(--muted);font-size:11px">关闭时只跳过该会话、绝不动别人写的字;命中审核规则的仍会等人确认</div><div class="mouse-orb"><span style="color:#315fec">${icon("bot")}<br><small>AI 运行中</small></span><div class="mouse-pointer">${icon("cursor")}</div><span style="color:var(--orange)">${icon("pause")}<br><small>检测到人工操作<br>AI 暂停</small></span></div></section><section class="panel panel-pad"><div class="panel-title"><h2>消息处理</h2></div><div class="stack"><div class="setting-line"><span>✓ 同一客户连续消息自动合并</span></div><div class="setting-line"><span>✓ 保留最近 <input class="ai-num" id="auto-context" type="number" min="1" max="50" value="${esc(a.context || 8)}"> 轮上下文</span><span class="badge">${esc(a.context || 8)} 轮</span></div><div class="setting-line"><span>✓ 一次只处理 1 个会话</span><span class="badge">1 个</span></div></div></section><section class="panel panel-pad"><div class="panel-title"><h2>异常与兜底</h2></div><div class="stack"><div class="setting-line"><span style="color:var(--orange)">${icon("clock")}</span><span style="flex:1">AI 超时:${esc(a.timeout || 120)} 秒后重试</span><span class="badge">2 次</span></div><div class="setting-line"><span style="color:var(--orange)">${icon("alert")}</span><span style="flex:1">发送失败:自动重试</span><span class="badge green">已开启</span></div><div class="setting-line"><span style="color:#3c69f4">${icon("message")}</span><span style="flex:1">固定回复:${esc(a.reply || "在的,请稍等")}</span><button class="badge" data-action="edit-reply">编辑</button></div></div></section><section class="panel panel-pad"><div class="panel-title"><h2>高风险转人工</h2><span class="badge green">● 已开启</span></div><div class="risk-tags"><span class="risk-tag">${icon("shield")} 诊断</span><span class="risk-tag">${icon("package")} 用药调整</span><span class="risk-tag">${icon("coins")} 投诉退款</span></div><button class="btn small blue" style="margin:11px auto 0;display:flex" data-action="security-rules">${icon("settings")}规则管理</button></section></div><section class="panel panel-pad check-strip"><div class="panel-title"><h2>启动前检查</h2></div>${statusCells([["check","企业微信已登录", STATE.wecomConnected ? "检查完成" : "等待连接"],["cloud","AI 服务正常","检查完成"],["activity","窗口坐标已校准", STATE.wecomConnected ? "检查完成" : "待校准"],["shield","规则配置有效","检查完成"]])}</section></div>`;
};
logsView = function(){
@@ -1055,6 +1103,12 @@
button: action, busyLabel: "保存中…",
pending: "正在保存…", okLabel: "设置已保存并生效",
}),
+ "reply-transport": () => callBridgeWithFeedback("setReplyTransport", [key], {
+ button: action, busyLabel: "切换中…", pending: "正在保存回复通道…",
+ }),
+ "probe-protocol": () => callBridge("probeProtocol"),
+ "verify-protocol-send": () => callBridge("validateProtocolSend"),
+ "export-protocol": () => callBridge("exportProtocolReport"),
"send-mode": () => callBridge("setSendMode", key),
"toggle-idle": () => callBridge("toggleIdle"),
"toggle-draft-autosend": () => {
--- current/backend_client.py
+++ protocol/backend_client.py
@@ -901,7 +901,6 @@
"zyt_token_protected": _protect_secret(zyt_token),
"desktop_token_protected": _protect_secret(response["access_token"]),
"desktop_account": response.get("account") or {},
- "knowledge_summary": {},
"desktop_expires_at": int(time.time()) + int(response.get("expires_in") or 0),
"last_error": "",
}
@@ -921,7 +920,6 @@
"zyt_token_protected": "",
"desktop_token_protected": "",
"desktop_account": {},
- "knowledge_summary": {},
"desktop_expires_at": 0,
"last_error": "",
}
@@ -1060,8 +1058,6 @@
"last_release": cached_release,
}
)
- if isinstance(response.get("knowledge"), dict):
- settings["knowledge_summary"] = response["knowledge"]
# 模型清单与角色编排随配置一起缓存下来,后端连不上时照旧按既定编排工作。
# 老版本后端不返回这两段,此时保留上一次的缓存而不是清空。
if isinstance(response.get("models"), list):
@@ -1293,7 +1289,3 @@
def startup_sync_config(*, timeout: float = 3.0) -> dict[str, Any]:
"""软件启动前先请求固定云端,使首屏直接使用服务器配置。"""
return sync_cloud_config(timeout=timeout)
-
-
-def knowledge_management_url() -> str:
- return str(load_settings().get("server_url") or DEFAULT_SERVER_URL).rstrip("/") + "/knowledge/center"
--- current/build_windows_exe.ps1
+++ protocol/build_windows_exe.ps1
@@ -89,6 +89,8 @@
Push-Location $projectRoot
try {
+ & $python (Join-Path $projectRoot "packaging_exporter.py") --project-root $projectRoot
+ if ($LASTEXITCODE -ne 0) { throw "Archive exporter preflight failed; see the missing source file above" }
& $python -m PyInstaller --noconfirm --clean $spec
if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed" }
}
--- current/gui_runtime.py
+++ protocol/gui_runtime.py
@@ -15,6 +15,7 @@
import traceback
from runtime_paths import application_data_dir
+from reply_transport import VISUAL, normalize_reply_transport, transport_start_blocker
MESSAGE_BATCH_WINDOW_SECONDS = 20.0
@@ -332,10 +333,12 @@
enable_engine_b=True,
engine_b_poll_interval=2.0,
engine_a_enabled=True,
- engine_b_data_source="parallel"):
+ engine_b_data_source="parallel",
+ reply_transport=VISUAL):
super().__init__(daemon=True)
self.target_queue = target_queue
self.reply_text = reply_text
+ self.reply_transport = normalize_reply_transport(reply_transport)
self.poll_seconds = poll_seconds
self.mouse_idle_enabled = mouse_idle_enabled
self.mouse_idle_seconds = mouse_idle_seconds
@@ -496,6 +499,15 @@
bot = None
failed = False
try:
+ blocker = transport_start_blocker(self.reply_transport)
+ if blocker is not None:
+ failed = True
+ self.target_queue.put(("log", "[协议] " + blocker["message"]))
+ self.target_queue.put(("status", "error"))
+ return
+ if self.reply_transport == "protocol":
+ self._run_protocol()
+ return
import wechat_bot as bot_module
bot_module.AUTO_REPLY_TEXT = self.reply_text
@@ -650,6 +662,35 @@
if not failed:
self.target_queue.put(("status", "stopped"))
+ def _run_protocol(self):
+ from protocol_engine import ProtocolBot
+ bot = ProtocolBot(stop_event=self.stop_event, reply_text=self.reply_text,
+ log=lambda message: self.target_queue.put(("log", message)),
+ progress=self._report_progress)
+ self.bot = bot
+ bot._reply_wakeup = self._reply_wakeup
+ try:
+ if self.stop_event.is_set():
+ return
+ self._drain_queued_cancellations(bot)
+ self._drain_queued_retries(bot)
+ self._drain_queued_approvals(bot)
+ self.target_queue.put(("status", "running"))
+ self.target_queue.put(("log", "[协议] 原账号已连接;从启动时刻监听新文本消息,按会话 ID 回复。"))
+ self._report_progress("协议已连接 · 正在检查新消息")
+ while not self.stop_event.is_set():
+ self._reply_wakeup.clear()
+ bot.message_batch_window_seconds = self.message_batch_window_seconds
+ bot.send_delay_seconds = self.send_delay_seconds
+ bot.send_mode = self.send_mode
+ bot.poll_once()
+ self.target_queue.put(("stats", {"replied": bot.reply_count, "false_pos": 0}))
+ if bot.needs_attention:
+ raise RuntimeError("协议发送结果待人工核对,已暂停,禁止自动重发。")
+ self._reply_wakeup.wait(max(.2, self.poll_seconds))
+ finally:
+ bot.close()
+
def stop(self):
self.stop_event.set()
self._reply_wakeup.set()
--- current/implementation_plan.md
+++ protocol/implementation_plan.md
@@ -1,4 +1,9 @@
+> 2026-09-14 实现进展:[本机协议分析与实现状态](WXWORK_PROTOCOL_ANALYSIS.md)。检测、SDK 通信层、通道配置、EXE 诊断入口及精确读取已落地;本机 SDK 运行时未连通,真实协议发送仍待验证。
+
# 企业微信 GUI 自动化实施计划
+
+> 新增功能规划(2026-09-14):[企业微信原账号协议自动回复功能规划](PROTOCOL_REPLY_FEATURE_PLAN.md)。包含客户端能力验证、首版功能、界面交互、数据迁移与验收要求;以下保留为早期 GUI 实施记录。
+
## 1. 环境准备
- **语言**:Python 3.8+
--- current/model_gateway.py
+++ protocol/model_gateway.py
@@ -44,8 +44,6 @@
import model_protocol
import admin_backend
-from knowledge_store import KnowledgeStore
-from knowledge_retriever import KnowledgeRetriever, inject_references
# ── 超时分层 ─────────────────────────────────────────────────────────────────
# 整体必须小于桌面端的超时,否则桌面端先超时重发,网关还在跑,双倍扣费。
@@ -479,9 +477,6 @@
app = FastAPI(title="模型统一网关", version="1.0")
database = admin_backend.Database(db_path)
database.migrate()
- knowledge_store = KnowledgeStore(database)
- knowledge_store.initialize()
- knowledge_retriever = KnowledgeRetriever(knowledge_store)
catalog = Catalog(db_path)
idempotency: dict[str, tuple[float, dict]] = {}
log_queue: asyncio.Queue = asyncio.Queue(maxsize=2000)
@@ -527,9 +522,6 @@
record = await log_queue.get()
try:
await asyncio.to_thread(_write_log, db_path, record)
- if record.get("knowledge_retrieval") is not None:
- await asyncio.to_thread(knowledge_retriever.record, record["tenant_id"],
- record["task_id"], record["knowledge_retrieval"])
except Exception:
pass
finally:
@@ -592,12 +584,7 @@
)
if idempotency_key and idempotency_key in idempotency:
# 桌面端重发(超时后重试)不该重复扣费
- cached = idempotency[idempotency_key][1]
- cached_hits = (cached.get("knowledge") or {}).get("hits", [])
- if not cached_hits or await asyncio.to_thread(
- knowledge_retriever.references_current, str(account["tenant_id"]), cached_hits):
- return JSONResponse(cached)
- idempotency.pop(idempotency_key, None)
+ return JSONResponse(idempotency[idempotency_key][1])
body = await request.json()
answers, judge, mode, version = catalog.plan()
@@ -619,32 +606,6 @@
tools = body.get("tools") or None
started = time.monotonic()
- knowledge_result = None
- knowledge_trace = {"enabled": False, "hits": [], "mode": "disabled", "elapsed_ms": 0}
- tenant = str(account["tenant_id"])
- if str(body.get("purpose") or "chat") == "chat":
- settings = await asyncio.to_thread(knowledge_store.settings, tenant)
- if settings["enabled"]:
- from knowledge_processing import latest_query
- query = str(body.get("customer_text") or "")
- if not query:
- for turn in reversed(messages):
- if turn.get("role") == "user" and isinstance(turn.get("content"), str):
- query = turn["content"]
- break
- query = latest_query(query) or query
- try:
- knowledge_result = await asyncio.wait_for(
- asyncio.to_thread(knowledge_retriever.search, tenant, query), timeout=4.0)
- knowledge_trace = {"enabled": True, "mode": knowledge_result["mode"],
- "elapsed_ms": knowledge_result["elapsed_ms"], "hits": [
- {k: h[k] for k in ("id", "revision", "score")} for h in knowledge_result["hits"]]}
- messages = inject_references(messages, knowledge_result["hits"])
- except Exception:
- knowledge_trace = {"enabled": True, "hits": [], "mode": "unavailable", "elapsed_ms": 4000}
- from knowledge_processing import redact
- knowledge_result = {**knowledge_trace, "query": redact(query)[:1000]}
- messages = inject_references(messages, [])
if tools:
# 工具轮:只问主出口,不并发也不评审。
@@ -673,24 +634,12 @@
"judge": dict(_EMPTY_VERDICT),
"judge_mode": mode,
"roles_version": version,
- "knowledge": knowledge_trace,
"total_ms": int((time.monotonic() - started) * 1000),
}
if single.get("error") and not payload["reply"] and not payload["tool_calls"]:
return JSONResponse(payload, status_code=502)
- if idempotency_key:
- idempotency[idempotency_key] = (now, payload)
- try:
- log_queue.put_nowait({
- "desktop_account_id": int(account["id"]), "tenant_id": tenant,
- "device_id": x_device_id, "task_id": str(body.get("task_id") or ""),
- "roles_version": version, "judge_mode": mode, "chosen": payload["chosen"],
- "judge": payload["judge"], "candidates": [single], "total_ms": payload["total_ms"],
- "customer_text": str(body.get("customer_text") or ""), "reply_text": payload["reply"],
- "purpose": str(body.get("purpose") or "chat"), "knowledge_retrieval": knowledge_result,
- })
- except asyncio.QueueFull:
- pass
+ if x_idempotency_key:
+ idempotency[x_idempotency_key] = (now, payload)
return JSONResponse(payload)
results = await asyncio.gather(
@@ -734,7 +683,6 @@
payload = {
"reply": chosen["text"],
- "knowledge": knowledge_trace,
"tool_calls": [],
"chosen": chosen["provider"],
"candidates": results,
@@ -760,7 +708,6 @@
"customer_text": str(body.get("customer_text") or ""),
"reply_text": payload["reply"],
"purpose": str(body.get("purpose") or "chat"),
- "knowledge_retrieval": knowledge_result,
})
except asyncio.QueueFull:
pass # 观测数据丢一条,不能因此拖慢回复
--- current/packaging_checks.py
+++ protocol/packaging_checks.py
@@ -531,3 +531,45 @@
hotkey.assert_not_called()
press.assert_not_called()
bot._begin_bot_mouse.assert_not_called()
+
+
+def check_protocol_environment() -> None:
+ """Frozen imports and account checks with relocated Unicode paths, no live client."""
+ import os
+ import sqlite3
+ import wxwork_db as database
+ import reply_database
+ from wecom_environment import protocol_database_status
+ from wecom_native_builds import BUILDS, build_for_version
+ from wecom_native_stub import STUBS, relocate
+ from wecom_native_evidence import local_send_receipt
+ for version, argument_bytes in (("5.0.10.6023", 12), ("5.0.11.6018", 16)):
+ profile = build_for_version(version)
+ if profile is None or profile.send_argument_bytes != argument_bytes:
+ raise RuntimeError("Bundled client call layout missing: " + version)
+ values = {name: 0x100000 for name, _ in STUBS[profile.send_stub][1]}
+ if len(relocate(profile.send_stub, values)) < 200:
+ raise RuntimeError("Bundled client call stub incomplete: " + version)
+ with tempfile.TemporaryDirectory(prefix="wecom-protocol-env-") as temporary:
+ root = Path(temporary)
+ if local_send_receipt(build_for_version("5.0.11.6018"), "100", journal_path=root / "no-receipt.sqlite3"):
+ raise RuntimeError("Pending adapter enabled without actual send evidence")
+ source = root / "自选磁盘 中文 空格" / "WXWork"
+ message = source / "100" / "Data" / "message.db"
+ message.parent.mkdir(parents=True)
+ conn = sqlite3.connect(message)
+ conn.execute("CREATE TABLE message_table(sender_id TEXT,conversation_id TEXT,content_type INT,send_time INT,content TEXT)")
+ conn.commit(); conn.close()
+ data = root / "另一个用户" / "配置目录"
+ with mock.patch.object(database, "_SCRIPT_DIR", str(data)), \
+ mock.patch.object(database, "KEYS_FILE", str(data / "wxwork_keys.json")), \
+ mock.patch.object(reply_database, "application_data_dir", return_value=data), \
+ mock.patch.dict(os.environ, {"WECOM_ARCHIVE_SOURCE_DIR": str(message.parent)}), \
+ mock.patch("wxwork_local_setup.acquire_local_keys", side_effect=RuntimeError("plain DB needs no key worker")):
+ if database.detect_wxwork_dir(account="100") != str(source):
+ raise RuntimeError("Moved protocol database directory was not resolved")
+ report = protocol_database_status("100", initialize=True)
+ if not report["ready"]:
+ raise RuntimeError("Current-account protocol database check failed")
+ if not (data / "wxwork_reply_cache" / "100" / "message.db").is_file():
+ raise RuntimeError("Protocol cache was not created in the current user data directory")
--- current/rbac.py
+++ protocol/rbac.py
@@ -27,11 +27,6 @@
("model:write", "增删改模型与编排", "模型"),
("agent:read", "查看智能体", "模型"),
("agent:write", "增删改智能体与协作方案", "模型"),
- ("knowledge:read", "查看知识中心", "知识库"),
- ("knowledge:write", "加工和编辑知识草稿", "知识库"),
- ("knowledge:review", "审核知识", "知识库"),
- ("knowledge:publish", "发布和停用知识", "知识库"),
- ("knowledge:export", "导出已发布知识", "知识库"),
("review:read", "查看待审队列", "审核"),
("review:write", "通过/驳回待审回复", "审核"),
("stats:read", "查看调用统计", "运营"),
--- current/reply_database.py
+++ protocol/reply_database.py
@@ -23,26 +23,28 @@
self._since = time.time() - 120.0
@staticmethod
- def _open_database():
+ def _open_database(*, account="", initialize=True):
from wxwork_local_setup import acquire_local_keys, select_source_directory
- source = detect_wxwork_dir()
+ source = detect_wxwork_dir(account=account) if account else detect_wxwork_dir()
if not source:
- raise DatabaseReadError("未找到企业微信数据目录,可暂用聊天内容复制")
+ raise DatabaseReadError("未找到当前账号的企业微信数据目录,请选择本机聊天数据所在目录")
try:
keys = load_keys()
except ValueError:
keys = {}
cache = str(application_data_dir() / "wxwork_reply_cache")
database = WXWorkDB(source, keys, cache_dir=cache)
- if database.health_check():
+ if database.health_check(account=account or None):
return database
database.close()
+ if not initialize:
+ raise DatabaseReadError("已找到当前账号消息库,尚未完成本机初始化;请点击检测本机协议,自动获取并校验本机密钥")
select_source_directory(source)
acquire_local_keys()
database = WXWorkDB(source, load_keys(), cache_dir=cache)
- if not database.health_check():
+ if not database.health_check(account=account or None):
database.close()
- raise DatabaseReadError("自动解密尚未得到消息数据库,可暂用聊天内容复制")
+ raise DatabaseReadError("本机初始化后,当前账号消息库仍不可读取。请核对数据目录、当前登录账号及本机密钥")
return database
def _run(self):
@@ -151,6 +153,16 @@
except DatabaseReadError:
return None
+ def get_conversation_context_by_id(self, account, conv_id, *, limit=100):
+ if not isinstance(account, str) or not account.strip():
+ raise ValueError("读取协议会话必须提供账号 ID")
+ if not isinstance(conv_id, str) or not conv_id.strip():
+ raise ValueError("读取协议会话必须提供会话 ID")
+ limit = max(1, min(500, int(limit)))
+ key = ("context_by_id", account, conv_id, limit)
+ # Preserve errors/timeouts; protocol readers must never trigger clipboard fallback.
+ return self._request(key, "get_conversation_context_by_id", account, conv_id, limit=limit)
+
def health_check(self):
return self._ready and not self._stop.is_set()
--- current/run_backend.py
+++ protocol/run_backend.py
@@ -27,13 +27,6 @@
SCRIPT_DIR = Path(__file__).resolve().parent
SERVICES = {
- "knowledge": {
- "label": "知识加工 Worker",
- "default_port": None,
- "argv": lambda db, host, port, _zyt_api_url: [
- sys.executable, "knowledge_worker.py", "--db", str(db),
- ],
- },
"api": {
# 老的网页后台(console/8765)已整体退役:界面由 Vue 管理端承担,
# 桌面端同步、调用留痕上报也都并进了这个服务。
@@ -116,13 +109,12 @@
"--only", nargs="*", choices=sorted(SERVICES), default=sorted(SERVICES)
)
for name, spec in SERVICES.items():
- if spec["default_port"] is not None:
- parser.add_argument(f"--{name}-port", type=int, default=spec["default_port"])
+ parser.add_argument(f"--{name}-port", type=int, default=spec["default_port"])
args = parser.parse_args()
db = Path(args.db).resolve()
# 管理端负责建表和播种 admin,必须最先起来:网关和它读同一个库,表不在就只能报错
- order = [name for name in ("api", "gateway", "knowledge") if name in args.only]
+ order = [name for name in ("api", "gateway") if name in args.only]
if not order:
raise SystemExit("没有选中任何服务")
@@ -132,7 +124,7 @@
busy = [
(name, getattr(args, f"{name}_port"))
for name in order
- if SERVICES[name]["default_port"] is not None and _port_busy(args.host, getattr(args, f"{name}_port"))
+ if _port_busy(args.host, getattr(args, f"{name}_port"))
]
if busy:
print("以下端口已被占用,服务没有启动:")
@@ -156,7 +148,7 @@
print(f"数据库:{db}")
for name in order:
spec = SERVICES[name]
- port = getattr(args, f"{name}_port", None)
+ port = getattr(args, f"{name}_port")
proc = subprocess.Popen(
spec["argv"](db, args.host, port, args.zyt_api_url),
cwd=SCRIPT_DIR,
@@ -171,8 +163,7 @@
threading.Thread(
target=_pump, args=(name, proc.stdout), daemon=True
).start()
- address = f"http://{args.host}:{port}" if port else "后台任务"
- print(f"已启动 {name:8s} {address} {spec['label']}")
+ print(f"已启动 {name:8s} http://{args.host}:{port} {spec['label']}")
if name == "api":
# 让建表先跑完,网关起来时表就已经在了
time.sleep(1.5)
--- current/sign_artifact.ps1
+++ protocol/sign_artifact.ps1
@@ -4,8 +4,8 @@
# 给构建产物加 Authenticode 签名。
#
-# 下载提示"有风险/有病毒"的根源是 EXE 没有数字签名、也没有下载信誉,
-# SmartScreen 和浏览器会拦截一切无签名的新程序。买到代码签名证书后:
+# 签名用于验证发布者和文件完整性;它不证明程序无风险,不能消除行为检测。
+# 未知发布者/下载信誉提示与 Defender 病毒或行为告警必须分别调查。使用已有证书时:
# - 证书在系统证书库里: 设 WECOM_CODESIGN_THUMBPRINT=<证书指纹>
# - 证书是 PFX 文件: 设 WECOM_CODESIGN_PFX=<pfx 路径>,
# 密码放 WECOM_CODESIGN_PFX_PASSWORD
@@ -13,15 +13,15 @@
$ErrorActionPreference = "Stop"
+if (-not (Test-Path -LiteralPath $Path)) {
+ throw ("Cannot sign, file not found: " + $Path)
+}
+
$thumbprint = ([string]$env:WECOM_CODESIGN_THUMBPRINT).Trim()
$pfxPath = ([string]$env:WECOM_CODESIGN_PFX).Trim()
if (-not $thumbprint -and -not $pfxPath) {
Write-Output ("Code signing skipped (no certificate configured): " + [System.IO.Path]::GetFileName($Path))
exit 0
-}
-
-if (-not (Test-Path -LiteralPath $Path)) {
- throw ("Cannot sign, file not found: " + $Path)
}
function Find-SignTool {
--- current/task.md
+++ protocol/task.md
@@ -1,4 +1,24 @@
# 企业微信 PC 端自动化 (RPA) 任务清单
+
+## 当前新增:原账号协议自动回复(2026-09-14)
+
+完整范围与验收见 [协议自动回复功能规划](PROTOCOL_REPLY_FEATURE_PLAN.md)。保留原员工账号和现有客户会话,首版采用数据库接收与已登录客户端桥接发送。
+
+- [x] 完成现有代码梳理、功能规划、页面交互和分阶段验收设计。
+- [x] 检测实际客户端 5.0.10.6023(x86)、静态分析内置代理与 SDK,输出诊断报告。
+- [x] 实现协议通道配置、后台检测、报告导出、启动拦截与按账号/会话 ID 读取上下文。
+- [x] 实现内置 SDK NDJSON 通信层、连接诊断与 EXE 只读诊断入口;通过本轮 118 项相关回归和 JavaScript 语法检查。
+- [x] 构建 1.4.9 EXE,包内自检与实际 EXE 协议诊断通过;协议发送仍明确不可用。
+- [ ] P0:验证在线原账号/会话映射、可调用文本发送入口及可靠结果确认。
+- [ ] P1-A:统一消息身份、持久化队列、游标提交、发送事务和旧数据迁移。
+- [ ] P1-B:实现协议收发闭环、AI 接入、结果核对、暂停恢复和人工接管。
+- [ ] P1-C:接入通道设置、托管客户、控制台、审核、日志与 EXE 打包。
+- [ ] P1-D:完成故障恢复、24 小时稳定验证和视觉模式回归。
+- [ ] P2:逐项扩展客户端事件、图片文件、多账号及受约束自动切换。
+
+当前已实现 SDK 通信层与协议准备功能;本机 SDK 运行时未连通,真实发送入口、身份与回执验证仍未完成,不能启动协议自动回复。
+证据与限制见 [本机协议分析与实现状态](WXWORK_PROTOCOL_ANALYSIS.md)。以下为项目早期 GUI 任务记录。
+
## 目标
从零构建基于 Python 的无侵入式企业微信 PC 端 GUI 自动化脚本。
--- current/test_admin_api.py
+++ protocol/test_admin_api.py
@@ -1360,7 +1360,7 @@
_SHAPE = {
"version", "updated_at", "updated_by", "config", "models", "roles", "account",
- "release", "knowledge",
+ "release",
# 网关地址由后台算好下发——桌面端只配一个后台地址,其余自动
"gateway",
}
@@ -1374,7 +1374,7 @@
401,
)
- def test_payload_preserves_legacy_fields_and_adds_tenant_knowledge_summary(self) -> None:
+ def test_the_payload_has_the_same_shape_as_the_legacy_endpoint(self) -> None:
resp = self.client.get(
"/api/v2/desktop/config",
headers=self.desktop_login(),
@@ -1399,9 +1399,6 @@
"updated_at",
},
)
- self.assertEqual(set(data["knowledge"]), {"published", "enabled", "updated_at"})
- self.assertEqual(data["knowledge"]["published"], 0)
- self.assertFalse(data["knowledge"]["enabled"])
self.assertEqual(set(data["gateway"]), {"enabled", "url"})
self.assertTrue(
data["gateway"]["url"].startswith("http"),
--- current/test_packaging_paths.py
+++ protocol/test_packaging_paths.py
@@ -17,7 +17,7 @@
def test_exporter_import_does_not_replace_desktop_crypto(self):
import wxwork_crypto
original_path = sys.path[:]
- exporter_root = Path(__file__).resolve().parent.parent / backup.EXACT_EXPORTER_DIR
+ exporter_root = Path(__file__).resolve().parent / "archive_exporter"
exporter, media = backup._load_exporter_modules(exporter_root)
self.assertTrue(callable(exporter.decrypt_wxwork_database))
self.assertTrue(callable(media.export_media))
--- current/test_qt_capsule.py
+++ protocol/test_qt_capsule.py
@@ -122,6 +122,14 @@
self.capsule.set_status("running", "监听中")
self.app.processEvents()
self.assertEqual(self.capsule.progress.text(), "当前进程 · 等待新消息")
+
+ def test_running_replaces_stale_connection_or_window_waiting_progress(self):
+ for state in ('connecting','waiting'):
+ with self.subTest(state=state):
+ self.capsule.set_progress('')
+ self.capsule.set_status(state,'连接中')
+ self.capsule.set_status('running','监听中')
+ self.assertEqual(self.capsule.progress.text(),'当前进程 · 等待新消息')
def test_a_long_nickname_is_shortened_instead_of_overflowing(self):
long_name = "正在回复 甄养堂医疗助理-盛灵莉客户服务专员@微信"
--- current/wechat_bot.py
+++ protocol/wechat_bot.py
@@ -9091,6 +9091,8 @@
if sent:
self._log_queue_event(fp, "已发送", reply_text)
return
+ if self._session_is_unrepliable(fp):
+ return
reason = self.last_send_failure_reason()
state = self._pending_reply_state(fp) or {}
if state.get("foreign_draft_at") is not None:
@@ -9746,6 +9748,46 @@
return True
return False
+ def _skip_contact_verification(self, fp: bytes, full=None) -> bool:
+ """Read-only OCR of the verified current chat; never click a verification link."""
+ from contact_reply_guard import visual_contact_reason, CONTACT_REASON
+ key = bytes(fp or b"").hex()
+ if not key:
+ return False
+ attributes = ("_chat_rel_x", "_chat_rel_y", "_chat_rel_w", "_chat_rel_h")
+ if not all(hasattr(self, name) for name in attributes):
+ return False
+ try:
+ if full is None:
+ full = self._capture_full_window()
+ if full is None or getattr(full, "ndim", 0) != 3:
+ return False
+ x, y, w, h = (int(getattr(self, name)) for name in attributes)
+ if x < 0 or y < 0 or w < 20 or h < 20 or x+w > full.shape[1] or y+h > full.shape[0]:
+ return False
+ region = np.ascontiguousarray(full[y:y+h, x:x+w, :3])
+ signature = (key, hashlib.sha256(region.tobytes()).digest())
+ cached = getattr(self, "_contact_notice_cache", None)
+ if cached and cached[0] == signature and time.monotonic()-cached[2] < 2.0:
+ reason = cached[1]
+ else:
+ reader = getattr(self, "_name_reader_instance", None) or self._name_reader
+ reason = visual_contact_reason(region, reader.read_layout(region, max_results=120))
+ self._contact_notice_cache = (signature, reason, time.monotonic())
+ except Exception:
+ # A failed OCR read is not evidence that a customer is unreachable.
+ return False
+ if not reason:
+ return False
+ if not hasattr(self, "_unrepliable_sessions"):
+ self._unrepliable_sessions = {}
+ self._unrepliable_sessions[key] = time.time()
+ self._persist_unrepliable_sessions()
+ self._log_queue_event(fp, "跳过", CONTACT_REASON)
+ self._clear_reply_pending(fp)
+ print(f" [会话过滤] {CONTACT_REASON};6 小时后重新检查会话状态。")
+ return True
+
def _session_accepts_replies(self, fp: bytes) -> bool:
"""Confirm the open page has a composer before spending an AI call.
@@ -9757,6 +9799,9 @@
if not key:
return True
if self._session_is_unrepliable(fp):
+ self._clear_reply_pending(fp)
+ return False
+ if self._skip_contact_verification(fp):
return False
strikes = getattr(self, "_composerless_strikes", None)
if strikes is None:
@@ -11294,6 +11339,8 @@
send_state = str(state.get("send_state") or "")
if not send_state:
return "unsent"
+ if self._skip_contact_verification(fp):
+ return "skipped"
known_states = {"sending", "sent_uncommitted", "uncertain"}
state_was_uncertain = send_state == "uncertain"
if send_state not in known_states:
@@ -12003,6 +12050,12 @@
print(" [发送保护] 当前聊天对象已经变化,已取消粘贴和发送。")
self._run_ai_page_guard("发送前会话校验异常")
return self._deny_send("粘贴前发现打开的已不是目标会话")
+ if expected_fp is not None:
+ if self._session_is_unrepliable(expected_fp):
+ self._clear_reply_pending(expected_fp)
+ return self._deny_send("该会话暂不可回复,已跳过")
+ if self._skip_contact_verification(expected_fp, full=full):
+ return self._deny_send("对方需要联系人验证,已取消待回复并跳过")
reply_text = text or AUTO_REPLY_TEXT
expected_reply = self._normalized_message_text(reply_text)
if not expected_reply:
@@ -13699,7 +13752,7 @@
):
continue
send_reconciliation = self._reconcile_uncertain_send(target_fp, state)
- if send_reconciliation == "sent":
+ if send_reconciliation in {"sent", "skipped"}:
return True
if send_reconciliation == "uncertain":
# Isolate only this transaction. Keep walking the queue so
@@ -13818,7 +13871,7 @@
state = migrated_state
send_reconciliation = self._reconcile_uncertain_send(target_fp, state)
- if send_reconciliation == "sent":
+ if send_reconciliation in {"sent", "skipped"}:
return True
if send_reconciliation == "uncertain":
continue
@@ -14013,6 +14066,10 @@
判不出左右时(宽气泡、深色主题)返回 False,也就是照常回复——宁可多问
一句,不能因为看不清就把客户晾着。
"""
+ # A failed outgoing bubble can contain the exact draft text. Contact
+ # restrictions must win over every "already sent" heuristic, including review drafts.
+ if self._skip_contact_verification(fp):
+ return True
database_text = self._database_chat_text(fp)
database_direction = self._database_message_direction(fp, database_text)
if database_direction is False:
@@ -14209,7 +14266,7 @@
return True
return False
send_reconciliation = self._reconcile_uncertain_send(fp, pending_state)
- if send_reconciliation == "sent":
+ if send_reconciliation in {"sent", "skipped"}:
return True
if send_reconciliation == "uncertain":
return False
@@ -15476,6 +15533,7 @@
break
self._set_task_stage(target_fp, "reading", detail="会话已打开,正在读取新消息")
if not self._session_accepts_replies(target_fp):
+ processed_fp.add(target_fp)
continue
who = self._session_label(target_fp)
if self._reply_awaiting_review(target_fp):
--- current/wechat_gui.py
+++ protocol/wechat_gui.py
@@ -196,6 +196,7 @@
)
+from reply_transport import VISUAL, normalize_reply_transport
from gui_runtime import ( # noqa: F401 # 供旧代码与测试通过 wechat_gui 引用
MESSAGE_BATCH_WINDOW_MAX_SECONDS,
MESSAGE_BATCH_WINDOW_MIN_SECONDS,
@@ -497,6 +498,7 @@
"mouse_idle_seconds": MOUSE_IDLE_SECONDS,
"send_delay_seconds": SEND_DELAY_SECONDS,
"send_mode": SEND_MODE_AUTO,
+ "reply_transport": VISUAL,
"message_batch_window_seconds": MESSAGE_BATCH_WINDOW_SECONDS,
}
@@ -509,6 +511,7 @@
return settings
if not isinstance(saved, dict):
return settings
+ settings["reply_transport"] = normalize_reply_transport(saved.get("reply_transport", VISUAL))
try:
reply = str(saved.get("auto_reply_text", settings["auto_reply_text"])).strip()
poll = float(saved.get("poll_interval", settings["poll_interval"]))
@@ -545,7 +548,9 @@
"message_batch_window_seconds": batch_window,
})
except (TypeError, ValueError):
- return self._runtime_defaults()
+ defaults = self._runtime_defaults()
+ defaults["reply_transport"] = settings["reply_transport"]
+ return defaults
return settings
def _schedule_runtime_settings_save(self, *_args):
@@ -605,6 +610,8 @@
return False
settings = {
+ "reply_transport": normalize_reply_transport(
+ getattr(self, "_runtime_settings", {}).get("reply_transport", VISUAL)),
"auto_reply_text": self._reply_var.get().strip() or AUTO_REPLY_TEXT,
"poll_interval": poll,
"mouse_idle_enabled": bool(self._mouse_idle_var.get()),
@@ -3853,6 +3860,7 @@
send_mode=normalize_send_mode(
self._runtime_settings.get("send_mode", SEND_MODE_AUTO)
),
+ reply_transport=self._runtime_settings.get("reply_transport", VISUAL),
enable_engine_b=bool(
self._runtime_settings.get("enable_engine_b", True)
),
--- current/wechat_gui_qt.py
+++ protocol/wechat_gui_qt.py
@@ -2,6 +2,7 @@
"""PySide6 desktop shell for the WeCom customer-service assistant."""
from __future__ import annotations
+from reply_transport import protocol_ready, queue_path_for_transport
import json
import math
@@ -94,6 +95,7 @@
# 直接用 GUI 无关的共享运行时;过去 import wechat_gui 会连带把整个
# tkinter/tcl 拖进 Qt 进程,冷启动白付一笔加载费。
+from reply_transport import VISUAL, PROTOCOL, normalize_reply_transport
from gui_runtime import (
BotThread,
LogQueue,
@@ -5374,10 +5376,9 @@
knowledge_copy.addWidget(sync_knowledge)
manage_knowledge = _button("▱ 管理知识")
manage_knowledge.setFixedSize(140, 38)
- def open_knowledge_center():
- import backend_client
- QDesktopServices.openUrl(QUrl(backend_client.knowledge_management_url()))
- manage_knowledge.clicked.connect(open_knowledge_center)
+ manage_knowledge.clicked.connect(
+ lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(SCRIPT_DIR)))
+ )
knowledge_copy.addWidget(manage_knowledge)
knowledge_body.addLayout(knowledge_copy)
knowledge_body.addStretch(1)
@@ -6739,7 +6740,7 @@
def _refresh_queue(self) -> None:
try:
- with open(SCRIPT_DIR / "pending_replies.json", encoding="utf-8") as handle:
+ with open(queue_path_for_transport(root=SCRIPT_DIR), encoding="utf-8") as handle:
raw = json.load(handle)
except FileNotFoundError:
raw = {}
@@ -7775,7 +7776,9 @@
self.set_progress("正在连接企业微信")
elif state == "waiting" and not self._progress_text:
self.set_progress("等待企业微信窗口")
- elif state == "running" and not self._progress_text:
+ elif state == "running" and self._progress_text in (
+ "", "正在连接企业微信", "等待企业微信窗口"
+ ):
self.set_progress("等待新消息")
def set_progress(self, text: str) -> None:
@@ -8507,7 +8510,10 @@
except (OSError, ValueError) as exc:
QMessageBox.warning(self.host, "数据目录不可用", str(exc))
return
- self.host._refresh_wechat_database(refresh_cache=True)
+ if getattr(self.host, "runtime_settings", {}).get("reply_transport") == PROTOCOL:
+ self.host._refresh_protocol_probe()
+ else:
+ self.host._refresh_wechat_database(refresh_cache=True)
@Slot()
def importWeChatDatabaseKeys(self) -> None:
@@ -8742,8 +8748,18 @@
@Slot()
def editKnowledge(self) -> None:
- import backend_client
- QDesktopServices.openUrl(QUrl(backend_client.knowledge_management_url()))
+ page = self.host.persona_page
+ text, accepted = QInputDialog.getMultiLineText(
+ self.host,
+ "管理知识",
+ "MCP 服务器 JSON(数组):",
+ page.mcp_json.toPlainText(),
+ )
+ if not accepted:
+ return
+ page.mcp_json.setPlainText(text)
+ self.host._console_ai_synced = bool(page.save_config())
+ self.host._push_console_state()
@Slot()
def syncKnowledge(self) -> None:
@@ -8876,6 +8892,35 @@
self.host.settings_page.reply.setText(str(text or "").strip()[:120])
self.host.settings_page._emit_save()
self.host._push_console_state()
+
+ @Slot(str, result=str)
+ def setReplyTransport(self, value: str) -> str:
+ transport = normalize_reply_transport(value)
+ if transport not in (VISUAL, PROTOCOL):
+ return json.dumps({"ok": False, "message": "无效的回复通道"}, ensure_ascii=False)
+ thread = getattr(self.host, "_thread", None)
+ if thread is not None and thread.is_alive():
+ return json.dumps({"ok": False, "message": "请先停止监听,再切换回复通道"}, ensure_ascii=False)
+ if self.host.save_runtime_settings({"reply_transport": transport}) is False:
+ return json.dumps({"ok": False, "message": "通道设置未保存,请查看运行日志"}, ensure_ascii=False)
+ if transport == PROTOCOL:
+ self.host._refresh_protocol_probe()
+ self.host._push_console_state(force=True)
+ message = "已选择协议模式;检测通过并完成发送适配后才可启动" if transport == PROTOCOL else "已选择视觉模式"
+ return json.dumps({"ok": True, "message": message}, ensure_ascii=False)
+
+ @Slot()
+ def probeProtocol(self) -> None:
+ self.host._refresh_protocol_probe()
+
+ @Slot()
+ def validateProtocolSend(self) -> None:
+ from wecom_protocol_validation_ui import start_validation
+ start_validation(self.host)
+
+ @Slot()
+ def exportProtocolReport(self) -> None:
+ self.host._export_protocol_report()
@Slot(str, result=str)
def saveAutomationSettings(self, payload: str) -> str:
@@ -9195,6 +9240,7 @@
class MainWindow(QMainWindow):
desktopSessionChecked = Signal(str, object)
wechatDatabaseLoaded = Signal(object)
+ protocolProbeLoaded = Signal(object)
def _set_account_menu_title(self, account: dict | None = None) -> None:
if account is None:
@@ -9428,6 +9474,7 @@
self.capsule = CapsuleWindow()
self._connect_signals()
+ QTimer.singleShot(250, self._maybe_refresh_protocol_probe)
self.business_refresh_timer = QTimer(self)
self.business_refresh_timer.setSingleShot(True)
self.business_refresh_timer.setInterval(220)
@@ -9526,6 +9573,7 @@
self.capsule.expandRequested.connect(self.expand_console)
self.capsule.stopRequested.connect(self.stop_monitoring)
self.wechatDatabaseLoaded.connect(self._wechat_database_loaded)
+ self.protocolProbeLoaded.connect(self._protocol_probe_loaded)
@staticmethod
def _runtime_defaults() -> dict:
@@ -9540,6 +9588,7 @@
"mouse_idle_seconds": 5.0,
"send_delay_seconds": SEND_DELAY_SECONDS,
"send_mode": SEND_MODE_AUTO,
+ "reply_transport": VISUAL,
"message_batch_window_seconds": MESSAGE_BATCH_WINDOW_SECONDS,
"foreign_draft_autosend_minutes": FOREIGN_DRAFT_AUTOSEND_MINUTES,
"auto_launch": False,
@@ -9567,6 +9616,7 @@
settings.update({key: saved[key] for key in settings if key in saved})
except (OSError, ValueError, TypeError):
pass
+ settings["reply_transport"] = normalize_reply_transport(settings["reply_transport"])
try:
settings["poll_interval"] = max(0.2, float(settings["poll_interval"]))
settings["mouse_idle_seconds"] = max(0.0, float(settings["mouse_idle_seconds"]))
@@ -9596,7 +9646,9 @@
or "在的,您慢慢说,我这边看着呢。"
)
except (TypeError, ValueError):
- return self._runtime_defaults()
+ defaults = self._runtime_defaults()
+ defaults["reply_transport"] = settings["reply_transport"]
+ return defaults
return settings
def _on_dashboard_engine_settings(self, engine: dict) -> None:
@@ -9609,13 +9661,20 @@
merged.update(engine or {})
self.save_runtime_settings(merged)
- def save_runtime_settings(self, settings: dict | None = None) -> None:
+ def save_runtime_settings(self, settings: dict | None = None) -> bool:
previous_auto_launch = bool(
getattr(self, "runtime_settings", {}).get("auto_launch", False)
)
merged = dict(self._runtime_defaults())
merged.update(getattr(self, "runtime_settings", {}) or {})
merged.update(settings or self.settings_page.values())
+ merged["reply_transport"] = normalize_reply_transport(merged.get("reply_transport", VISUAL))
+ thread = getattr(self, "_thread", None)
+ if (thread is not None and thread.is_alive()
+ and merged["reply_transport"] != getattr(self, "runtime_settings", {}).get("reply_transport", VISUAL)):
+ self._broadcast_save_result(False, "请先停止监听,再切换回复通道")
+ return False
+ previous_settings = getattr(self, "runtime_settings", {})
self.runtime_settings = merged
try:
temporary = APP_SETTINGS_FILE.with_suffix(".json.tmp")
@@ -9629,7 +9688,9 @@
# 只回报前者的话,从系统设置页点保存的人盯着的是另一块屏幕区域,
# 什么都不会看到——"点了没反应"的一半原因就在这里。
self._broadcast_save_result(False, f"保存失败:{exc}")
+ self.runtime_settings = previous_settings
self.append_log(f"运行参数保存失败:{exc}", "err")
+ return False
else:
if previous_auto_launch != bool(self.runtime_settings.get("auto_launch", False)):
self._sync_auto_launch(bool(self.runtime_settings.get("auto_launch", False)))
@@ -9646,6 +9707,7 @@
self.runtime_settings["foreign_draft_autosend_minutes"]
))
self._broadcast_save_result(True, "设置已保存并生效")
+ return True
def _broadcast_save_result(self, ok: bool, message: str) -> None:
"""把保存结果同时告诉所有共用这份运行参数的页面。"""
@@ -9706,6 +9768,10 @@
shell = getattr(self, "console_shell", None)
if changed and shell is not None:
shell.set_view(view)
+ if view == "automation":
+ refresh_protocol = getattr(self, "_maybe_refresh_protocol_probe", None)
+ if callable(refresh_protocol):
+ refresh_protocol()
if view == "wechatdb":
refresh_database = getattr(self, "_refresh_wechat_database", None)
if callable(refresh_database):
@@ -9732,6 +9798,10 @@
self._console_select_task(selected)
if name == "archive" and getattr(self, "business_page", None) is not None:
self.business_page.refresh_data()
+ if name == "automation":
+ refresh_protocol = getattr(self, "_maybe_refresh_protocol_probe", None)
+ if callable(refresh_protocol):
+ refresh_protocol()
if name == "wechatdb":
refresh_database = getattr(self, "_refresh_wechat_database", None)
if callable(refresh_database):
@@ -10076,6 +10146,9 @@
def _console_wecom_info(self) -> dict:
info = {"connected": False, "account": "—", "dpi": "", "hwnd": 0}
bot = getattr(getattr(self, "_thread", None), "bot", None)
+ if bot is not None and getattr(bot, "account", None) and self.runtime_settings.get("reply_transport") == PROTOCOL:
+ return {"connected": not bool(getattr(bot, "needs_attention", False)),
+ "account": str(bot.account), "dpi": "协议通道", "hwnd": 0}
hwnd = int(getattr(bot, "hwnd", 0) or 0) if bot is not None else 0
if hwnd:
info["connected"] = True
@@ -10765,13 +10838,6 @@
servers = parsed
except (json.JSONDecodeError, TypeError, AttributeError):
servers = []
- try:
- import backend_client
- knowledge_summary = backend_client.load_settings().get("knowledge_summary") or {}
- except Exception:
- knowledge_summary = {}
- knowledge_count = max(0, int(knowledge_summary.get("published") or 0))
- knowledge_enabled = bool(knowledge_summary.get("enabled"))
return {
"name": page.agent_name.text().strip() or "贴心管家",
"prompt": page.persona_prompt.toPlainText(),
@@ -10781,9 +10847,9 @@
"maxTokens": page.max_tokens.value(),
"replyLengthIndex": length_index,
"timeout": timeout,
- "knowledge": str(knowledge_count),
- "knowledgeCount": f"{knowledge_count:,}",
- "knowledgeConnected": knowledge_enabled,
+ "knowledge": str(len(servers)),
+ "knowledgeCount": f"{len(servers):,}",
+ "knowledgeConnected": bool(servers),
"syncAt": last_sync,
"syncLabel": "尚未同步" if last_sync == "尚未同步" else "刚刚同步",
"mcpRounds": page.mcp_rounds.value(),
@@ -10814,6 +10880,91 @@
})
return cleaned
+ def _maybe_refresh_protocol_probe(self) -> None:
+ if getattr(self, "runtime_settings", {}).get("reply_transport") != PROTOCOL:
+ return
+ thread = getattr(self, "_thread", None)
+ if thread is not None and thread.is_alive():
+ return
+ report = getattr(self, "_protocol_report", {}) or {}
+ if time.time() - float(report.get("checkedAt") or 0) >= 30:
+ self._refresh_protocol_probe()
+
+ def _refresh_protocol_probe(self) -> None:
+ if getattr(self, "_protocol_probe_running", False):
+ return
+ self._protocol_probe_running = True
+ self._protocol_probe = {
+ "status": "checking", "label": "正在检测", "canSend": False,
+ "message": "正在识别企业微信实际安装位置、当前账号及聊天数据目录;首次初始化可能需要约 60 秒,不发送消息…",
+ }
+ self._push_console_state(force=True)
+
+ thread = getattr(self, "_thread", None)
+ initialize = (getattr(self, "runtime_settings", {}).get("reply_transport") == PROTOCOL
+ and not (thread is not None and thread.is_alive()))
+
+ def worker():
+ try:
+ from wxwork_protocol_probe import probe_installed_client
+ report = probe_installed_client(initialize_database=initialize)
+ except Exception as exc:
+ report = {
+ "schemaVersion": 1, "checkedAt": time.time(), "readOnly": True,
+ "status": "probe_failed", "label": "检测失败", "canSend": False,
+ "message": "客户端检测失败:" + str(exc)[:400],
+ }
+ try:
+ self.protocolProbeLoaded.emit(report)
+ except RuntimeError:
+ pass
+
+ threading.Thread(target=worker, name="protocol-client-probe", daemon=True).start()
+
+ @Slot(object)
+ def _protocol_probe_loaded(self, report: object) -> None:
+ self._protocol_probe_running = False
+ self._protocol_report = dict(report)
+ self._protocol_probe = {
+ key: self._protocol_report.get(key)
+ for key in ("status", "label", "message", "checkedAt", "clientVersion", "architecture", "nativeAdapter", "nativeAccount", "adapterInstalled", "database", "databaseReady", "clientInstallPath", "supportedClient", "host", "verificationScope", "targetSendVerified", "clientSha256", "localSendVerified", "needsSendValidation", "lastSendValidation")
+ }
+ self._protocol_probe.update(
+ canSend=protocol_ready(self._protocol_report), exportable=True,
+ sdkRuntime=self._protocol_report.get("sdkRuntime") or {},
+ nativeTrial=self._protocol_report.get("nativeTrial") or {},
+ clientDetected=bool(self._protocol_report.get("clients")),
+ bundledAgentDetected=any(item.get("installed") for item in self._protocol_report.get("bundledAgents", [])),
+ loginVerified=bool(self._protocol_report.get("loginVerified")),
+ sendVerified=bool(self._protocol_report.get("sendVerified")), receiptVerified=bool(self._protocol_report.get("receiptVerified")),
+ )
+ self.append_log("[协议检测] " + str(self._protocol_probe.get("message") or "已完成"),
+ "ok" if self._protocol_probe["canSend"] else "warn")
+ self._push_console_state(force=True)
+ if getattr(self, "_protocol_start_pending", False):
+ self._protocol_start_pending = False
+ if self._protocol_probe["canSend"] and self.runtime_settings.get("reply_transport") == PROTOCOL:
+ QTimer.singleShot(0, self.start_monitoring)
+
+ def _export_protocol_report(self) -> None:
+ report = getattr(self, "_protocol_report", None)
+ if not report or getattr(self, "_protocol_probe_running", False):
+ self.append_log("请等待协议检测完成后导出报告。", "warn")
+ self._refresh_protocol_probe()
+ return
+ suggested = SCRIPT_DIR / f"wecom-protocol-{time.strftime('%Y%m%d-%H%M%S')}.json"
+ filename, _ = QFileDialog.getSaveFileName(
+ self, "导出协议诊断报告", str(suggested), "JSON 文件 (*.json)")
+ if not filename:
+ return
+ try:
+ from wxwork_protocol_probe import export_probe_report
+ export_probe_report(report, filename)
+ except (OSError, ValueError) as exc:
+ self.append_log(f"协议报告导出失败:{exc}", "err")
+ return
+ self.append_log(f"协议诊断报告已保存:{filename}", "ok")
+
def _console_automation_state(self, wecom) -> dict:
settings = self.settings_page.values()
connected = bool(wecom) if isinstance(wecom, bool) else bool((wecom or {}).get("connected"))
@@ -10826,6 +10977,11 @@
except Exception:
timeout = 120
return {
+ "transport": getattr(self, "runtime_settings", {}).get("reply_transport", VISUAL),
+ "protocol": getattr(self, "_protocol_probe", {
+ "status": "not_checked", "label": "尚未检测", "canSend": False,
+ "message": "选择协议模式后会自动检测本机环境;也可点击检测本机协议。",
+ }),
"poll": settings.get("poll_interval"),
"batch": settings.get("message_batch_window_seconds"),
"delay": settings.get("send_delay_seconds"),
@@ -10936,7 +11092,7 @@
archive = file_size(SCRIPT_DIR / "conversations.json")
log = file_size(SCRIPT_DIR / "runtime.log") + file_size(SCRIPT_DIR / "wechat_rpa.log")
cache = (
- file_size(SCRIPT_DIR / "pending_replies.json")
+ file_size(queue_path_for_transport(self.runtime_settings.get("reply_transport", VISUAL)))
+ file_size(SCRIPT_DIR / "false_pos_cache.json")
+ file_size(SCRIPT_DIR / "vision_status.json")
)
@@ -10986,7 +11142,7 @@
else:
result = delete_pending_reply_file(
requested,
- str(SCRIPT_DIR / "pending_replies.json"),
+ str(queue_path_for_transport(self.runtime_settings.get("reply_transport", VISUAL))),
)
except Exception as exc:
QMessageBox.warning(self, "删除失败", f"无法删除队列任务:{exc}")
@@ -11143,7 +11299,20 @@
if self._thread is not None and self._thread.is_alive():
self.append_log("监听线程已经在运行", "warn")
return
- self.save_runtime_settings()
+ if self.save_runtime_settings() is False:
+ return
+ transport = normalize_reply_transport(self.runtime_settings.get("reply_transport", VISUAL))
+ if transport not in (VISUAL, PROTOCOL):
+ self.append_log("回复通道配置无效,请在自动化设置中重新选择。", "err")
+ return
+ if transport == PROTOCOL:
+ report = getattr(self, "_protocol_report", {}) or {}
+ if (getattr(self, "_protocol_probe_running", False) or not protocol_ready(report)
+ or time.time() - float(report.get("checkedAt") or 0) >= 30):
+ self._protocol_start_pending = True
+ self.append_log("正在检查本机协议环境,检查通过后继续启动监听。", "notify")
+ self._refresh_protocol_probe()
+ return
self.persona_page.save_config()
self._stdout_proxy = LogQueue(
self._queue,
@@ -11161,6 +11330,7 @@
),
send_delay_seconds=float(self.runtime_settings["send_delay_seconds"]),
send_mode=self.runtime_settings["send_mode"],
+ reply_transport=self.runtime_settings.get("reply_transport", VISUAL),
foreign_draft_autosend_minutes=float(
self.runtime_settings["foreign_draft_autosend_minutes"]
),
@@ -11180,7 +11350,7 @@
self._running = True
self._start_time = time.time()
self.set_status("connecting", "连接中")
- self.append_log("正在连接企业微信窗口…", "notify")
+ self.append_log("正在连接企业微信原账号协议…" if transport == PROTOCOL else "正在连接企业微信窗口…", "notify")
self._thread.start()
# 一旦开始监听就收进胶囊:控制台占着大半个屏幕,而机器人要操作的是
# 企业微信窗口——留着整块面板挡在前面既碍事,也容易被误点。胶囊上有
@@ -11188,6 +11358,7 @@
QTimer.singleShot(0, self.enter_capsule_mode)
def stop_monitoring(self) -> None:
+ self._protocol_start_pending = False
if self._thread is None or not self._thread.is_alive():
self._finish_thread("stopped")
return
@@ -11828,13 +11999,14 @@
import desktop_login
import desktop_updater
import wxwork_message_browser
- from packaging_checks import check_database_initialization, check_https_runtime, check_login_routing, check_message_navigation
+ from packaging_checks import check_database_initialization, check_https_runtime, check_login_routing, check_message_navigation, check_protocol_environment
import startup_update
check_https_runtime()
check_login_routing()
check_login_routing(legacy_terminal=True)
check_database_initialization()
+ check_protocol_environment()
check_message_navigation()
except Exception:
import traceback
@@ -11898,6 +12070,12 @@
def main() -> None:
+ if "--protocol-send-test" in sys.argv:
+ from wecom_native_verification import run_send_test
+ raise SystemExit(run_send_test(sys.argv[1:]))
+ if "--protocol-diagnostics" in sys.argv:
+ from wxwork_protocol_probe import run_packaged_diagnostics
+ raise SystemExit(run_packaged_diagnostics(sys.argv[1:]))
_apply_saved_scale_preference()
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
--- current/wechat_rpa.spec
+++ protocol/wechat_rpa.spec
@@ -5,6 +5,7 @@
from pathlib import Path
from packaging_native import pin_ssl_binaries, python_ssl_binaries
+from packaging_exporter import exporter_data_files
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
from PyInstaller.utils.win32.versioninfo import (
@@ -22,8 +23,7 @@
app_version = os.environ.get("WECOM_BUILD_VERSION", "1.0.0").strip() or "1.0.0"
app_name = f"ZhenAI-WeCom-Assistant-v{app_version}"
-# 完整的版本信息资源。没有它,EXE 在杀软启发式评分和 SmartScreen 提示里
-# 就是一个"来历不明"的匿名程序;有了它至少提示里能显示发布者与产品名。
+# 产品版本资源仅用于标识程序,不等同于可信发布者签名或杀毒软件审核。
_version_parts = [int(p) for p in (app_version.split(".") + ["0", "0", "0"])[:4]]
version_resource = VSVersionInfo(
ffi=FixedFileInfo(
@@ -62,6 +62,17 @@
"conversation_store",
"dsh_agent",
"gui_runtime",
+ "reply_transport",
+ "wxwork_protocol_probe",
+ "wecom_sdk_client",
+ "wecom_native_protocol",
+ "wecom_native_sender",
+ "wecom_native_builds",
+ "wecom_native_evidence",
+ "wecom_protocol_validation_ui",
+ "wecom_native_stub",
+ "wecom_native_verification",
+ "protocol_engine",
"mcp_bridge",
"registration_store",
"runtime_paths",
@@ -76,18 +87,8 @@
]
hidden_imports += collect_submodules("mcp")
-# Only ship exporter code, never local keys, account settings or chat databases.
-exporter_root = project_root.parent / "2026-08-19-18-27-34"
-exporter_files = [
- "wxwork_export_final.py", "wxwork_export_media.py",
- "wxwork_voice2text.py", "wxwork_crypto.py",
-]
-exporter_data = []
-for filename in exporter_files:
- source = exporter_root / filename
- if not source.is_file():
- raise FileNotFoundError(f"Required archive exporter is missing: {source}")
- exporter_data.append((str(source), "archive_exporter"))
+# Explicit code allowlist: never include local keys, account settings or chat data.
+exporter_data = exporter_data_files(project_root)
hidden_imports += ["Crypto.Cipher.AES"]
# Explicitly pin the SSL DLLs before and after dependency analysis.
--- current/wxwork_db.py
+++ protocol/wxwork_db.py
@@ -407,10 +407,15 @@
return values
-def detect_wxwork_dir(candidates=None) -> str | None:
- """优先保存位置,再找当前用户文档、OneDrive 和其他用户的常见位置。"""
+def detect_wxwork_dir(candidates=None, *, account="") -> str | None:
+ """Resolve moved folders and optionally require the currently logged-in account."""
+ account = str(account or "")
+ if account and not account.isdecimal():
+ raise ValueError("企业微信账号 ID 无效")
def valid(base):
try:
+ if account:
+ return os.path.isfile(os.path.join(base, account, "Data", "message.db"))
return any(os.path.isfile(os.path.join(base, d, "Data", "message.db"))
for d in os.listdir(base))
except OSError:
@@ -427,6 +432,8 @@
configured.append(str(settings["db_dir"]))
except (OSError, ValueError, TypeError):
pass
+ from wecom_environment import registry_locations
+ configured.extend(registry_locations()["data"])
# 重定向后的文档先于旧的 Documents,避免误选迁移前遗留的数据。
configured.extend(os.path.join(folder, "WXWork") for folder in _windows_documents_dirs())
for variable in ("OneDrive", "OneDriveCommercial", "OneDriveConsumer"):
@@ -435,20 +442,25 @@
configured.extend(os.path.join(cloud, folder, "WXWork") for folder in ("Documents", "文档"))
configured.append(os.path.join(os.path.expanduser("~"), "Documents", "WXWork"))
users_root = os.path.join(os.environ.get("SystemDrive", "C:") + os.sep, "Users")
- try:
- configured.extend(os.path.join(users_root, user, "Documents", "WXWork")
- for user in sorted(os.listdir(users_root)))
- except OSError:
- pass
+ if not account:
+ try:
+ configured.extend(os.path.join(users_root, user, "Documents", "WXWork")
+ for user in sorted(os.listdir(users_root)))
+ except OSError:
+ pass
seen = set()
for candidate in configured:
if not candidate:
continue
candidate = os.path.abspath(os.path.expanduser(os.path.expandvars(os.fspath(candidate))))
- identity = os.path.normcase(candidate)
- if identity not in seen and valid(candidate):
- return candidate
- seen.add(identity)
+ # Accept a WXWork root, account folder, Data folder, or message.db path.
+ if os.path.basename(candidate).lower() == "message.db":
+ candidate = os.path.dirname(candidate)
+ for base in (candidate, os.path.dirname(candidate), os.path.dirname(os.path.dirname(candidate))):
+ identity = os.path.normcase(base)
+ if identity not in seen and valid(base):
+ return base
+ seen.add(identity)
return None
@@ -913,7 +925,27 @@
found_account, found_conv, name = matches[0]
if (account and str(account) != found_account) or (conv_id and str(conv_id) != found_conv):
return None
- account, conv_id = found_account, found_conv
+ return self._read_conversation_context(found_account, found_conv, name, limit)
+
+ @_db_locked
+ def get_conversation_context_by_id(self, account: str, conv_id: str, *, limit=100):
+ """Protocol-only ID lookup; duplicate display names never affect routing.
+
+ This verifies local DB identity only, not the client's active login.
+ The legacy screen-title lookup intentionally retains its ambiguity check.
+ """
+ if not isinstance(account, str) or not account.strip():
+ raise ValueError("读取协议会话必须提供账号 ID")
+ if not isinstance(conv_id, str) or not conv_id.strip():
+ raise ValueError("读取协议会话必须提供会话 ID")
+ self._refresh_if_needed()
+ if account not in self._conns or not is_personal_chat(conv_id):
+ return None
+ name = self._conv_display_name(account, conv_id)
+ return self._read_conversation_context(account, conv_id, name, limit)
+
+ def _read_conversation_context(self, account, conv_id, name, limit):
+ # Caller holds _db_locked for both refresh and query.
connection = self._conns[account]
columns = [row[1] for row in connection.execute("PRAGMA table_info(message_table)")]
rows = connection.execute(
@@ -982,11 +1014,17 @@
}
@_db_locked
- def health_check(self) -> bool:
+ def health_check(self, account=None) -> bool:
"""连接与密钥有效性自检。"""
if not self._conns:
return False
try:
+ if account is not None:
+ conn = self._conns.get(str(account))
+ if conn is None:
+ return False
+ conn.execute("SELECT sender_id, conversation_id, content_type, send_time, content FROM message_table LIMIT 0")
+ return True
for conn in self._conns.values():
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM sqlite_master")
--- current/打包说明.md
+++ protocol/打包说明.md
@@ -5,7 +5,7 @@
也可以在 PowerShell 运行:
```powershell
-cd C:\kefu\wechat_rpa
+cd C:\wechat_rpa
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\build_installer.ps1
```
@@ -21,4 +21,6 @@
软件每次打开登录窗口时,先异步查询公开版本清单,不需要先登录。无更新时继续登录;普通更新可以稍后处理;强制更新必须完成升级才能登录。检查超时不阻塞登录,但已缓存的强制升级策略仍然有效。选择更新后复用 HTTPS 下载、SHA256 校验和独立更新器,安装完自动重新打开软件。
-发布时请在管理端填写更高版本号、HTTPS 安装包地址及 SHA256;仅本地打包不会让其他电脑自动收到更新。当前新包版本为 1.4.1,后续云端需发布高于 1.4.1 的版本才会提示更新。
+发布时请在管理端填写更高版本号、HTTPS 安装包地址及 SHA256;仅本地打包不会让其他电脑自动收到更新。本次修复版本为 1.4.12;版本号以 app_version.py 为准。安全复核未完成前不要发布为已验收版本。
+
+归档源码已随项目保存在 archive_exporter/,不再需要旧电脑目录。构建成功与安全验收是两件事;2026-09-15 的 Defender 行为告警和复核状态见 SECURITY_REVIEW_20260915.md。