2603 lines
132 KiB
Diff
2603 lines
132 KiB
Diff
--- 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">Ⅱ 暂停监听</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。
|