--- 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)=>``;
+ var head=(title,sub,actions="")=>``;
var connected=(extra="",primary="")=>` 企业微信已连接 AI 服务正常 ${extra}Ⅱ 暂停监听 `;
var ribbon=items=>`${items.map(x=>`${icon(x[2])}
${x[0]} ${x[1]}
`).join("")} `;
var queueList=(archive=false)=>`
${people.map((p,i)=>`
${p[0]}
${p[1]} ${archive?(i===0?"请问你们的灵芝孢子粉怎么服用…":i===1?"每天吃几次比较合适?":i===2?"有不良反应吗?":"会话已归档"):` ${p[2]}`}
${archive?["16:31","16:18","15:42","14:20","13:35"][i]:p[3]} ${archive?`${i===2?"已转人工":"AI 已回复"} `:`${icon("message")} ${i===3?1:0} `}
`).join("")}
`;
@@ -399,11 +399,59 @@
return `${head("AI 设置","配置模型、客服人格、知识库与工具能力",`
${configLabel}▷ 测试 AI ${icon("send")}保存并发布 `)}
${tabs}
客服人格 AI
✧ 实时预览 您好!我是甄养堂${esc(ai.name || "贴心管家")}, 很高兴为您服务 😊
请问有什么可以帮您的吗?
✣ 模型编排 由后台决定,本机只执行 答题模型: ${esc(plan.answer || "后台未配置")}
裁判模型: ${esc(plan.judge || "未启用")}
裁判模式: ${esc(plan.mode || "—")}
编排版本: ${plan.version ? "v" + esc(plan.version) : "—"}
上下文轮数: ${esc(ai.context || 8)} 轮
网关:${esc(plan.gateway || "未下发——模型调用会失败,请去「系统设置 → 后台连接」")}
最后同步:${esc(plan.syncedAt || "尚未同步")}
模型接口、密钥、温度、最大 tokens 现在统一在后台的「AI 模型 → 模型清单 / 角色编排」 里配。 上下文轮数和 MCP 在「桌面端 → 客户端配置」 里配,随配置下发——在这台机器上改会被下一次同步覆盖。
▰ 企业知识库 ${esc(knowledgeCount)}个知识片段
● ${esc(syncLabel)} ✓ ${connectedKnowledge?"已连接":"未连接"} ${icon("refresh")}立即同步 ${icon("folder")}管理知识
◇ MCP 工具 单次最多调用 ${esc(ai.mcpRounds || 5)} 轮 · 由后台下发 工具清单和调用轮数在后台的「桌面端 → 客户端配置 → MCP 服务器」 里配,随配置下发。 在这台机器上改会被下一次同步覆盖,所以这里只展示不修改。
${icon("shield")} 选择性审核规则 由后台决定,本机只执行 ${reviewRulesHtml}
命中任意一条,这一条回复才会停下来等人工确认;其余照常自动发送。模型裁判判定为高风险时同样会转人工,那一条不在这份清单里、关不掉。
在后台的「桌面端 → 客户端配置 → 审核规则」 里配置,随配置下发。
发送模式 ${icon("shield")}${blanketReview?"全部审核后发送":"自动发送(命中规则单独审核)"}
在「自动化设置」页顶部切换;这个总开关和上面的选择性规则是 OR 的关系,任一个成立都会拦下。
配置状态 ${statusCells([["cloud",STATE.aiOk===false?"AI 服务异常":"云端模型正常","最后检查:刚刚"],["folder",knowledgeStatus,`${esc(knowledgeCount)} 个知识片段`],["folder",`${toolCount} 个工具可用`,toolStatus],["shield","安全策略已生效","严格模式"]])} `;
};
+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 `
+
回复通道 ${selected==="visual"?"视觉模式":selected==="protocol"?(ready?"协议模式 · 已就绪":checking?"协议模式 · 检测中":p.checkedAt?"协议模式 · 需处理":"协议模式 · 待检测"):"配置无效"}
+
+ 视觉模式
+ 协议模式
+ ${icon("refresh")}${checking?"检测中…":"检测本机协议"}
+ ${p.needsSendValidation?`首次发送验证 `:""}
+ ${icon("download")}导出报告
+
+ 协议状态 ${esc(p.label || "尚未检测")}
+ ${esc(p.message || "检测企业微信版本、程序架构和内置接口组件。")}
+
+ 客户端:${esc(p.clientVersion || "未检测")} ${esc(p.architecture || "")}
+ 协议适配器:${p.adapterInstalled?"构建已匹配":p.checkedAt?"未就绪":"待检测"}
+ ${p.nativeAccount?`当前账号:${esc(p.nativeAccount)} `:""}
+ 本机消息库:${esc((p.database||{}).label || "待检测")}
+ ${native.checkedAt?`原生 MCP 上次实测:${native.transportVerified?"握手成功":"未验证"} `:""}
+ 原账号登录:${p.loginVerified?"已核对":"未验证"}
+ 客户单聊发送:${p.sendVerified?(p.localSendVerified?"当前账号已实测":"适配版本已实测"):"未验证"}
+ 发送回执:${p.receiptVerified?(p.localSendVerified?"当前账号已实测":"适配版本已实测"):"未验证"}
+
+ ${native.checkedAt?`独立实测记录:${esc(trialTime)} · 客户端 ${esc(native.clientVersion)}${native.sameClientVersion?"":"(与当前版本不同)"} · ${native.businessStatus==="not_enabled"?"业务接口未开放(-90020005 / user not in gray)":"业务能力未验证"}。此记录不代表当前连接、账号权限或发送成功。
`:""}
+ ${p.clientInstallPath?`企业微信位置:${esc(p.clientInstallPath)}
`:""}
+ ${p.database && !p.databaseReady?`${icon("folder")}选择聊天数据目录 数据可以放在其他盘,选择一次后会记住。
`:""}
+ 检测时间:${esc(checked)} · 自动检查本机环境并按需初始化消息库,不发送消息。发送与回执标记来自适配版本实测,本机实际结果以发送回执为准。
+ `;
+}
+
automationView = function(){
if (STATE.demo) return _automationView();
const a = STATE.automation || {};
const autoOn = (STATE.sendMode || "auto") === "auto";
- return `${head("自动化设置","配置企业微信监听、消息合并与自动发送规则",`
${esc(a.wecom || "企业微信未识别")} 规则已生效${icon("save")}保存设置 `)}
${icon("send")} 自动发送 ${icon("users")} 审核后发送
监听与发送策略 ${icon("activity")} ● 规则运行中 ${[["activity","扫描未读"],["log","提取消息"],["folder","合并消息"],["brain","请求 AI"],["send","回填发送"]].map(x=>`
`).join("")}
企业微信窗口 ${icon("clipboard")} 主窗口: ● ${esc(a.wecom || "未识别")}
${icon("user")} 当前账号: ${esc(a.account || "—")}
${icon("globe")} DPI: ${esc(STATE.dpi || "—")}
${icon("monitor")} 最小化恢复: ● 已开启
${icon("refresh")}重新检测
消息处理 ✓ 同一客户连续消息自动合并
✓ 保留最近 轮上下文 ${esc(a.context || 8)} 轮
✓ 一次只处理 1 个会话 1 个
异常与兜底 ${icon("clock")} AI 超时:${esc(a.timeout || 120)} 秒后重试 2 次
${icon("alert")} 发送失败:自动重试 已开启
${icon("message")} 固定回复:${esc(a.reply || "在的,请稍等")} 编辑
高风险转人工 ● 已开启 ${icon("shield")} 诊断 ${icon("package")} 用药调整 ${icon("coins")} 投诉退款
${icon("settings")}规则管理
启动前检查 ${statusCells([["check","企业微信已登录", STATE.wecomConnected ? "检查完成" : "等待连接"],["cloud","AI 服务正常","检查完成"],["activity","窗口坐标已校准", STATE.wecomConnected ? "检查完成" : "待校准"],["shield","规则配置有效","检查完成"]])} `;
+ 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 `${head("自动化设置","协议模式 · 保留原员工账号与现有客户会话",`
${icon("save")}保存设置 `)}${transportPanel}
+
自动发送 审核后发送
+
+
发送核对 审核规则 客户补充消息时重新生成回复;人工已经回复时取消旧任务。发送结果不明确时暂停监听并保留记录,防止重复发送。
协议模式按会话 ID 发送,不操作聊天窗口。切换账号或升级企业微信后,会重新校验可用性。
`;
+ }
+ return `${head("自动化设置","配置企业微信监听、消息合并与自动发送规则",`
${esc(a.wecom || "企业微信未识别")} 规则已生效${icon("save")}保存设置 `)}${transportPanel}
${icon("send")} 自动发送 ${icon("users")} 审核后发送
监听与发送策略 ${icon("activity")} ● 规则运行中 ${[["activity","扫描未读"],["log","提取消息"],["folder","合并消息"],["brain","请求 AI"],["send","回填发送"]].map(x=>`
`).join("")}
企业微信窗口 ${icon("clipboard")} 主窗口: ● ${esc(a.wecom || "未识别")}
${icon("user")} 当前账号: ${esc(a.account || "—")}
${icon("globe")} DPI: ${esc(STATE.dpi || "—")}
${icon("monitor")} 最小化恢复: ● 已开启
${icon("refresh")}重新检测
消息处理 ✓ 同一客户连续消息自动合并
✓ 保留最近 轮上下文 ${esc(a.context || 8)} 轮
✓ 一次只处理 1 个会话 1 个
异常与兜底 ${icon("clock")} AI 超时:${esc(a.timeout || 120)} 秒后重试 2 次
${icon("alert")} 发送失败:自动重试 已开启
${icon("message")} 固定回复:${esc(a.reply || "在的,请稍等")} 编辑
高风险转人工 ● 已开启 ${icon("shield")} 诊断 ${icon("package")} 用药调整 ${icon("coins")} 投诉退款
${icon("settings")}规则管理
启动前检查 ${statusCells([["check","企业微信已登录", STATE.wecomConnected ? "检查完成" : "等待连接"],["cloud","AI 服务正常","检查完成"],["activity","窗口坐标已校准", STATE.wecomConnected ? "检查完成" : "待校准"],["shield","规则配置有效","检查完成"]])} `;
};
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=,
# 密码放 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。