--- 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 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)=>`

${title}

${sub}

${actions}
`; + var head=(title,sub,actions="")=>`

${title}

${sub}

${actions}
`; var connected=(extra="",primary="")=>`企业微信已连接AI 服务正常${extra}`; var ribbon=items=>`
${items.map(x=>`
${icon(x[2])}
${x[0]}${x[1]}
`).join("")}
`; var queueList=(archive=false)=>`
${people.map((p,i)=>`
${p[0]}
${p[1]}${archive?(i===0?"请问你们的灵芝孢子粉怎么服用…":i===1?"每天吃几次比较合适?":i===2?"有不良反应吗?":"会话已归档"):`${p[2]}`}
${archive?["16:31","16:18","15:42","14:20","13:35"][i]:p[3]}${archive?`${i===2?"已转人工":"AI 已回复"}`:`${icon("message")} ${i===3?1:0}`}
`).join("")}
`; @@ -399,11 +399,59 @@ return `
${head("AI 设置","配置模型、客服人格、知识库与工具能力",`${configLabel}`)}
${tabs}

客服人格

AI
服务风格:温和专业简洁
✧ 实时预览

您好!我是甄养堂${esc(ai.name || "贴心管家")},
很高兴为您服务 😊

请问有什么可以帮您的吗?

✣ 模型编排

由后台决定,本机只执行
答题模型:${esc(plan.answer || "后台未配置")}
裁判模型:${esc(plan.judge || "未启用")}
裁判模式:${esc(plan.mode || "—")}
编排版本:${plan.version ? "v" + esc(plan.version) : "—"}
上下文轮数:${esc(ai.context || 8)} 轮
网关:${esc(plan.gateway || "未下发——模型调用会失败,请去「系统设置 → 后台连接」")}
最后同步:${esc(plan.syncedAt || "尚未同步")}
模型接口、密钥、温度、最大 tokens 现在统一在后台的「AI 模型 → 模型清单 / 角色编排」里配。
上下文轮数和 MCP 在「桌面端 → 客户端配置」里配,随配置下发——在这台机器上改会被下一次同步覆盖。

▰ 企业知识库

${esc(knowledgeCount)}个知识片段
● ${esc(syncLabel)}✓ ${connectedKnowledge?"已连接":"未连接"}

◇ MCP 工具

单次最多调用 ${esc(ai.mcpRounds || 5)} 轮 · 由后台下发
${names.map((name,index)=>`
${toolTokens[index]}
${name}
${toolsOn[index]?"已启用":"未启用"}
`).join("")}
工具清单和调用轮数在后台的「桌面端 → 客户端配置 → MCP 服务器」里配,随配置下发。
在这台机器上改会被下一次同步覆盖,所以这里只展示不修改。

${icon("shield")} 选择性审核规则

由后台决定,本机只执行
${reviewRulesHtml}
命中任意一条,这一条回复才会停下来等人工确认;其余照常自动发送。模型裁判判定为高风险时同样会转人工,那一条不在这份清单里、关不掉。
在后台的「桌面端 → 客户端配置 → 审核规则」里配置,随配置下发。

发送模式

${icon("shield")}${blanketReview?"全部审核后发送":"自动发送(命中规则单独审核)"}
在「自动化设置」页顶部切换;这个总开关和上面的选择性规则是 OR 的关系,任一个成立都会拦下。

配置状态

${statusCells([["cloud",STATE.aiOk===false?"AI 服务异常":"云端模型正常","最后检查:刚刚"],["folder",knowledgeStatus,`${esc(knowledgeCount)} 个知识片段`],["folder",`${toolCount} 个工具可用`,toolStatus],["shield","安全策略已生效","严格模式"]])}
`; }; +function replyTransportPanel(a){ + const p = a.protocol || {}; + const selected = a.transport || "visual"; + const native = p.nativeAdapter ? {} : (p.nativeTrial || {}); + const ready = p.canSend === true && p.status === "native_ready" && p.databaseReady === true; + const trialTime = native.checkedAt ? new Date(native.checkedAt * 1000).toLocaleString("zh-CN") : ""; + const checking = p.status === "checking"; + const checked = p.checkedAt ? new Date(p.checkedAt * 1000).toLocaleString("zh-CN") : "尚未检测"; + return `
+

回复通道

${selected==="visual"?"视觉模式":selected==="protocol"?(ready?"协议模式 · 已就绪":checking?"协议模式 · 检测中":p.checkedAt?"协议模式 · 需处理":"协议模式 · 待检测"):"配置无效"}
+
+ + + + ${p.needsSendValidation?``:""} + +
+
协议状态${esc(p.label || "尚未检测")}
+

${esc(p.message || "检测企业微信版本、程序架构和内置接口组件。")}

+
+ 客户端:${esc(p.clientVersion || "未检测")} ${esc(p.architecture || "")} + 协议适配器:${p.adapterInstalled?"构建已匹配":p.checkedAt?"未就绪":"待检测"} + ${p.nativeAccount?`当前账号:${esc(p.nativeAccount)}`:""} + 本机消息库:${esc((p.database||{}).label || "待检测")} + ${native.checkedAt?`原生 MCP 上次实测:${native.transportVerified?"握手成功":"未验证"}`:""} + 原账号登录:${p.loginVerified?"已核对":"未验证"} + 客户单聊发送:${p.sendVerified?(p.localSendVerified?"当前账号已实测":"适配版本已实测"):"未验证"} + 发送回执:${p.receiptVerified?(p.localSendVerified?"当前账号已实测":"适配版本已实测"):"未验证"} +
+ ${native.checkedAt?`
独立实测记录:${esc(trialTime)} · 客户端 ${esc(native.clientVersion)}${native.sameClientVersion?"":"(与当前版本不同)"} · ${native.businessStatus==="not_enabled"?"业务接口未开放(-90020005 / user not in gray)":"业务能力未验证"}。此记录不代表当前连接、账号权限或发送成功。
`:""} + ${p.clientInstallPath?`
企业微信位置:${esc(p.clientInstallPath)}
`:""} + ${p.database && !p.databaseReady?`
数据可以放在其他盘,选择一次后会记住。
`:""} +
检测时间:${esc(checked)} · 自动检查本机环境并按需初始化消息库,不发送消息。发送与回执标记来自适配版本实测,本机实际结果以发送回执为准。
+
`; +} + automationView = function(){ if (STATE.demo) return _automationView(); const a = STATE.automation || {}; const autoOn = (STATE.sendMode || "auto") === "auto"; - return `
${head("自动化设置","配置企业微信监听、消息合并与自动发送规则",`${esc(a.wecom || "企业微信未识别")}规则已生效`)}

监听与发送策略

${icon("activity")} ● 规则运行中
${[["activity","扫描未读"],["log","提取消息"],["folder","合并消息"],["brain","请求 AI"],["send","回填发送"]].map(x=>`
${icon(x[0])}
${x[1]}
`).join("")}
${icon("clock")}扫描间隔  秒
${icon("hourglass")} 连续消息等待  秒
${icon("clock")}发送前等待  秒

企业微信窗口

✓
${icon("clipboard")} 主窗口:● ${esc(a.wecom || "未识别")}
${icon("user")} 当前账号:${esc(a.account || "—")}
${icon("globe")} DPI:${esc(STATE.dpi || "—")}
${icon("monitor")} 最小化恢复:● 已开启

人机共存

启用人工操作保护
鼠标静止  秒 后恢复
草稿超时直发
输入框里有人工草稿  0 ? "" : "disabled"}> 分钟后直接发出
关闭时只跳过该会话、绝不动别人写的字;命中审核规则的仍会等人确认
${icon("bot")}
AI 运行中
${icon("cursor")}
${icon("pause")}
检测到人工操作
AI 暂停

消息处理

✓ 同一客户连续消息自动合并
✓ 保留最近 轮上下文${esc(a.context || 8)} 轮
✓ 一次只处理 1 个会话1 个

异常与兜底

${icon("clock")}AI 超时:${esc(a.timeout || 120)} 秒后重试2 次
${icon("alert")}发送失败:自动重试已开启
${icon("message")}固定回复:${esc(a.reply || "在的,请稍等")}

高风险转人工

● 已开启
${icon("shield")} 诊断${icon("package")} 用药调整${icon("coins")} 投诉退款

启动前检查

${statusCells([["check","企业微信已登录", STATE.wecomConnected ? "检查完成" : "等待连接"],["cloud","AI 服务正常","检查完成"],["activity","窗口坐标已校准", STATE.wecomConnected ? "检查完成" : "待校准"],["shield","规则配置有效","检查完成"]])}
`; + const transportPanel = replyTransportPanel(a); + if (a.transport && a.transport !== "visual") { + const p = a.protocol || {}; + const ready = p.canSend === true && p.status === "native_ready" && p.databaseReady === true; + return `
${head("自动化设置","协议模式 · 保留原员工账号与现有客户会话",``)}${transportPanel} +
+

${ready?"协议自动回复已就绪":"协议模式尚未就绪"}

${esc(ready?"原账号文本单聊":p.label || "正在准备检测")}
+

启动后监听新到的文本消息,使用当前员工账号回复现有客户。审核模式下,回复保留在任务队列,点击“通过并发送”后发送。

+
+

支持 ${esc(p.supportedClient || "5.0.10.6023 x86、5.0.11.6018 x86")}。自动识别安装盘符和目录;其他企业微信版本、x64 客户端仍需单独适配。当前自动处理文本消息。

+

发送核对

客户补充消息时重新生成回复;人工已经回复时取消旧任务。发送结果不明确时暂停监听并保留记录,防止重复发送。

协议模式按会话 ID 发送,不操作聊天窗口。切换账号或升级企业微信后,会重新校验可用性。

`; + } + return `
${head("自动化设置","配置企业微信监听、消息合并与自动发送规则",`${esc(a.wecom || "企业微信未识别")}规则已生效`)}${transportPanel}

监听与发送策略

${icon("activity")} ● 规则运行中
${[["activity","扫描未读"],["log","提取消息"],["folder","合并消息"],["brain","请求 AI"],["send","回填发送"]].map(x=>`
${icon(x[0])}
${x[1]}
`).join("")}
${icon("clock")}扫描间隔  秒
${icon("hourglass")} 连续消息等待  秒
${icon("clock")}发送前等待  秒

企业微信窗口

✓
${icon("clipboard")} 主窗口:● ${esc(a.wecom || "未识别")}
${icon("user")} 当前账号:${esc(a.account || "—")}
${icon("globe")} DPI:${esc(STATE.dpi || "—")}
${icon("monitor")} 最小化恢复:● 已开启

人机共存

启用人工操作保护
鼠标静止  秒 后恢复
草稿超时直发
输入框里有人工草稿  0 ? "" : "disabled"}> 分钟后直接发出
关闭时只跳过该会话、绝不动别人写的字;命中审核规则的仍会等人确认
${icon("bot")}
AI 运行中
${icon("cursor")}
${icon("pause")}
检测到人工操作
AI 暂停

消息处理

✓ 同一客户连续消息自动合并
✓ 保留最近 轮上下文${esc(a.context || 8)} 轮
✓ 一次只处理 1 个会话1 个

异常与兜底

${icon("clock")}AI 超时:${esc(a.timeout || 120)} 秒后重试2 次
${icon("alert")}发送失败:自动重试已开启
${icon("message")}固定回复:${esc(a.reply || "在的,请稍等")}

高风险转人工

● 已开启
${icon("shield")} 诊断${icon("package")} 用药调整${icon("coins")} 投诉退款

启动前检查

${statusCells([["check","企业微信已登录", STATE.wecomConnected ? "检查完成" : "等待连接"],["cloud","AI 服务正常","检查完成"],["activity","窗口坐标已校准", STATE.wecomConnected ? "检查完成" : "待校准"],["shield","规则配置有效","检查完成"]])}
`; }; logsView = function(){ @@ -1055,6 +1103,12 @@ button: action, busyLabel: "保存中…", pending: "正在保存…", okLabel: "设置已保存并生效", }), + "reply-transport": () => callBridgeWithFeedback("setReplyTransport", [key], { + button: action, busyLabel: "切换中…", pending: "正在保存回复通道…", + }), + "probe-protocol": () => callBridge("probeProtocol"), + "verify-protocol-send": () => callBridge("validateProtocolSend"), + "export-protocol": () => callBridge("exportProtocolReport"), "send-mode": () => callBridge("setSendMode", key), "toggle-idle": () => callBridge("toggleIdle"), "toggle-draft-autosend": () => { --- current/backend_client.py +++ protocol/backend_client.py @@ -901,7 +901,6 @@ "zyt_token_protected": _protect_secret(zyt_token), "desktop_token_protected": _protect_secret(response["access_token"]), "desktop_account": response.get("account") or {}, - "knowledge_summary": {}, "desktop_expires_at": int(time.time()) + int(response.get("expires_in") or 0), "last_error": "", } @@ -921,7 +920,6 @@ "zyt_token_protected": "", "desktop_token_protected": "", "desktop_account": {}, - "knowledge_summary": {}, "desktop_expires_at": 0, "last_error": "", } @@ -1060,8 +1058,6 @@ "last_release": cached_release, } ) - if isinstance(response.get("knowledge"), dict): - settings["knowledge_summary"] = response["knowledge"] # 模型清单与角色编排随配置一起缓存下来,后端连不上时照旧按既定编排工作。 # 老版本后端不返回这两段,此时保留上一次的缓存而不是清空。 if isinstance(response.get("models"), list): @@ -1293,7 +1289,3 @@ def startup_sync_config(*, timeout: float = 3.0) -> dict[str, Any]: """软件启动前先请求固定云端,使首屏直接使用服务器配置。""" return sync_cloud_config(timeout=timeout) - - -def knowledge_management_url() -> str: - return str(load_settings().get("server_url") or DEFAULT_SERVER_URL).rstrip("/") + "/knowledge/center" --- current/build_windows_exe.ps1 +++ protocol/build_windows_exe.ps1 @@ -89,6 +89,8 @@ Push-Location $projectRoot try { + & $python (Join-Path $projectRoot "packaging_exporter.py") --project-root $projectRoot + if ($LASTEXITCODE -ne 0) { throw "Archive exporter preflight failed; see the missing source file above" } & $python -m PyInstaller --noconfirm --clean $spec if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed" } } --- current/gui_runtime.py +++ protocol/gui_runtime.py @@ -15,6 +15,7 @@ import traceback from runtime_paths import application_data_dir +from reply_transport import VISUAL, normalize_reply_transport, transport_start_blocker MESSAGE_BATCH_WINDOW_SECONDS = 20.0 @@ -332,10 +333,12 @@ enable_engine_b=True, engine_b_poll_interval=2.0, engine_a_enabled=True, - engine_b_data_source="parallel"): + engine_b_data_source="parallel", + reply_transport=VISUAL): super().__init__(daemon=True) self.target_queue = target_queue self.reply_text = reply_text + self.reply_transport = normalize_reply_transport(reply_transport) self.poll_seconds = poll_seconds self.mouse_idle_enabled = mouse_idle_enabled self.mouse_idle_seconds = mouse_idle_seconds @@ -496,6 +499,15 @@ bot = None failed = False try: + blocker = transport_start_blocker(self.reply_transport) + if blocker is not None: + failed = True + self.target_queue.put(("log", "[协议] " + blocker["message"])) + self.target_queue.put(("status", "error")) + return + if self.reply_transport == "protocol": + self._run_protocol() + return import wechat_bot as bot_module bot_module.AUTO_REPLY_TEXT = self.reply_text @@ -650,6 +662,35 @@ if not failed: self.target_queue.put(("status", "stopped")) + def _run_protocol(self): + from protocol_engine import ProtocolBot + bot = ProtocolBot(stop_event=self.stop_event, reply_text=self.reply_text, + log=lambda message: self.target_queue.put(("log", message)), + progress=self._report_progress) + self.bot = bot + bot._reply_wakeup = self._reply_wakeup + try: + if self.stop_event.is_set(): + return + self._drain_queued_cancellations(bot) + self._drain_queued_retries(bot) + self._drain_queued_approvals(bot) + self.target_queue.put(("status", "running")) + self.target_queue.put(("log", "[协议] 原账号已连接;从启动时刻监听新文本消息,按会话 ID 回复。")) + self._report_progress("协议已连接 · 正在检查新消息") + while not self.stop_event.is_set(): + self._reply_wakeup.clear() + bot.message_batch_window_seconds = self.message_batch_window_seconds + bot.send_delay_seconds = self.send_delay_seconds + bot.send_mode = self.send_mode + bot.poll_once() + self.target_queue.put(("stats", {"replied": bot.reply_count, "false_pos": 0})) + if bot.needs_attention: + raise RuntimeError("协议发送结果待人工核对,已暂停,禁止自动重发。") + self._reply_wakeup.wait(max(.2, self.poll_seconds)) + finally: + bot.close() + def stop(self): self.stop_event.set() self._reply_wakeup.set() --- current/implementation_plan.md +++ protocol/implementation_plan.md @@ -1,4 +1,9 @@ +> 2026-09-14 实现进展:[本机协议分析与实现状态](WXWORK_PROTOCOL_ANALYSIS.md)。检测、SDK 通信层、通道配置、EXE 诊断入口及精确读取已落地;本机 SDK 运行时未连通,真实协议发送仍待验证。 + # 企业微信 GUI 自动化实施计划 + +> 新增功能规划(2026-09-14):[企业微信原账号协议自动回复功能规划](PROTOCOL_REPLY_FEATURE_PLAN.md)。包含客户端能力验证、首版功能、界面交互、数据迁移与验收要求;以下保留为早期 GUI 实施记录。 + ## 1. 环境准备 - **语言**:Python 3.8+ --- current/model_gateway.py +++ protocol/model_gateway.py @@ -44,8 +44,6 @@ import model_protocol import admin_backend -from knowledge_store import KnowledgeStore -from knowledge_retriever import KnowledgeRetriever, inject_references # ── 超时分层 ───────────────────────────────────────────────────────────────── # 整体必须小于桌面端的超时,否则桌面端先超时重发,网关还在跑,双倍扣费。 @@ -479,9 +477,6 @@ app = FastAPI(title="模型统一网关", version="1.0") database = admin_backend.Database(db_path) database.migrate() - knowledge_store = KnowledgeStore(database) - knowledge_store.initialize() - knowledge_retriever = KnowledgeRetriever(knowledge_store) catalog = Catalog(db_path) idempotency: dict[str, tuple[float, dict]] = {} log_queue: asyncio.Queue = asyncio.Queue(maxsize=2000) @@ -527,9 +522,6 @@ record = await log_queue.get() try: await asyncio.to_thread(_write_log, db_path, record) - if record.get("knowledge_retrieval") is not None: - await asyncio.to_thread(knowledge_retriever.record, record["tenant_id"], - record["task_id"], record["knowledge_retrieval"]) except Exception: pass finally: @@ -592,12 +584,7 @@ ) if idempotency_key and idempotency_key in idempotency: # 桌面端重发(超时后重试)不该重复扣费 - cached = idempotency[idempotency_key][1] - cached_hits = (cached.get("knowledge") or {}).get("hits", []) - if not cached_hits or await asyncio.to_thread( - knowledge_retriever.references_current, str(account["tenant_id"]), cached_hits): - return JSONResponse(cached) - idempotency.pop(idempotency_key, None) + return JSONResponse(idempotency[idempotency_key][1]) body = await request.json() answers, judge, mode, version = catalog.plan() @@ -619,32 +606,6 @@ tools = body.get("tools") or None started = time.monotonic() - knowledge_result = None - knowledge_trace = {"enabled": False, "hits": [], "mode": "disabled", "elapsed_ms": 0} - tenant = str(account["tenant_id"]) - if str(body.get("purpose") or "chat") == "chat": - settings = await asyncio.to_thread(knowledge_store.settings, tenant) - if settings["enabled"]: - from knowledge_processing import latest_query - query = str(body.get("customer_text") or "") - if not query: - for turn in reversed(messages): - if turn.get("role") == "user" and isinstance(turn.get("content"), str): - query = turn["content"] - break - query = latest_query(query) or query - try: - knowledge_result = await asyncio.wait_for( - asyncio.to_thread(knowledge_retriever.search, tenant, query), timeout=4.0) - knowledge_trace = {"enabled": True, "mode": knowledge_result["mode"], - "elapsed_ms": knowledge_result["elapsed_ms"], "hits": [ - {k: h[k] for k in ("id", "revision", "score")} for h in knowledge_result["hits"]]} - messages = inject_references(messages, knowledge_result["hits"]) - except Exception: - knowledge_trace = {"enabled": True, "hits": [], "mode": "unavailable", "elapsed_ms": 4000} - from knowledge_processing import redact - knowledge_result = {**knowledge_trace, "query": redact(query)[:1000]} - messages = inject_references(messages, []) if tools: # 工具轮:只问主出口,不并发也不评审。 @@ -673,24 +634,12 @@ "judge": dict(_EMPTY_VERDICT), "judge_mode": mode, "roles_version": version, - "knowledge": knowledge_trace, "total_ms": int((time.monotonic() - started) * 1000), } if single.get("error") and not payload["reply"] and not payload["tool_calls"]: return JSONResponse(payload, status_code=502) - if idempotency_key: - idempotency[idempotency_key] = (now, payload) - try: - log_queue.put_nowait({ - "desktop_account_id": int(account["id"]), "tenant_id": tenant, - "device_id": x_device_id, "task_id": str(body.get("task_id") or ""), - "roles_version": version, "judge_mode": mode, "chosen": payload["chosen"], - "judge": payload["judge"], "candidates": [single], "total_ms": payload["total_ms"], - "customer_text": str(body.get("customer_text") or ""), "reply_text": payload["reply"], - "purpose": str(body.get("purpose") or "chat"), "knowledge_retrieval": knowledge_result, - }) - except asyncio.QueueFull: - pass + if x_idempotency_key: + idempotency[x_idempotency_key] = (now, payload) return JSONResponse(payload) results = await asyncio.gather( @@ -734,7 +683,6 @@ payload = { "reply": chosen["text"], - "knowledge": knowledge_trace, "tool_calls": [], "chosen": chosen["provider"], "candidates": results, @@ -760,7 +708,6 @@ "customer_text": str(body.get("customer_text") or ""), "reply_text": payload["reply"], "purpose": str(body.get("purpose") or "chat"), - "knowledge_retrieval": knowledge_result, }) except asyncio.QueueFull: pass # 观测数据丢一条,不能因此拖慢回复 --- current/packaging_checks.py +++ protocol/packaging_checks.py @@ -531,3 +531,45 @@ hotkey.assert_not_called() press.assert_not_called() bot._begin_bot_mouse.assert_not_called() + + +def check_protocol_environment() -> None: + """Frozen imports and account checks with relocated Unicode paths, no live client.""" + import os + import sqlite3 + import wxwork_db as database + import reply_database + from wecom_environment import protocol_database_status + from wecom_native_builds import BUILDS, build_for_version + from wecom_native_stub import STUBS, relocate + from wecom_native_evidence import local_send_receipt + for version, argument_bytes in (("5.0.10.6023", 12), ("5.0.11.6018", 16)): + profile = build_for_version(version) + if profile is None or profile.send_argument_bytes != argument_bytes: + raise RuntimeError("Bundled client call layout missing: " + version) + values = {name: 0x100000 for name, _ in STUBS[profile.send_stub][1]} + if len(relocate(profile.send_stub, values)) < 200: + raise RuntimeError("Bundled client call stub incomplete: " + version) + with tempfile.TemporaryDirectory(prefix="wecom-protocol-env-") as temporary: + root = Path(temporary) + if local_send_receipt(build_for_version("5.0.11.6018"), "100", journal_path=root / "no-receipt.sqlite3"): + raise RuntimeError("Pending adapter enabled without actual send evidence") + source = root / "自选磁盘 中文 空格" / "WXWork" + message = source / "100" / "Data" / "message.db" + message.parent.mkdir(parents=True) + conn = sqlite3.connect(message) + conn.execute("CREATE TABLE message_table(sender_id TEXT,conversation_id TEXT,content_type INT,send_time INT,content TEXT)") + conn.commit(); conn.close() + data = root / "另一个用户" / "配置目录" + with mock.patch.object(database, "_SCRIPT_DIR", str(data)), \ + mock.patch.object(database, "KEYS_FILE", str(data / "wxwork_keys.json")), \ + mock.patch.object(reply_database, "application_data_dir", return_value=data), \ + mock.patch.dict(os.environ, {"WECOM_ARCHIVE_SOURCE_DIR": str(message.parent)}), \ + mock.patch("wxwork_local_setup.acquire_local_keys", side_effect=RuntimeError("plain DB needs no key worker")): + if database.detect_wxwork_dir(account="100") != str(source): + raise RuntimeError("Moved protocol database directory was not resolved") + report = protocol_database_status("100", initialize=True) + if not report["ready"]: + raise RuntimeError("Current-account protocol database check failed") + if not (data / "wxwork_reply_cache" / "100" / "message.db").is_file(): + raise RuntimeError("Protocol cache was not created in the current user data directory") --- current/rbac.py +++ protocol/rbac.py @@ -27,11 +27,6 @@ ("model:write", "增删改模型与编排", "模型"), ("agent:read", "查看智能体", "模型"), ("agent:write", "增删改智能体与协作方案", "模型"), - ("knowledge:read", "查看知识中心", "知识库"), - ("knowledge:write", "加工和编辑知识草稿", "知识库"), - ("knowledge:review", "审核知识", "知识库"), - ("knowledge:publish", "发布和停用知识", "知识库"), - ("knowledge:export", "导出已发布知识", "知识库"), ("review:read", "查看待审队列", "审核"), ("review:write", "通过/驳回待审回复", "审核"), ("stats:read", "查看调用统计", "运营"), --- current/reply_database.py +++ protocol/reply_database.py @@ -23,26 +23,28 @@ self._since = time.time() - 120.0 @staticmethod - def _open_database(): + def _open_database(*, account="", initialize=True): from wxwork_local_setup import acquire_local_keys, select_source_directory - source = detect_wxwork_dir() + source = detect_wxwork_dir(account=account) if account else detect_wxwork_dir() if not source: - raise DatabaseReadError("未找到企业微信数据目录,可暂用聊天内容复制") + raise DatabaseReadError("未找到当前账号的企业微信数据目录,请选择本机聊天数据所在目录") try: keys = load_keys() except ValueError: keys = {} cache = str(application_data_dir() / "wxwork_reply_cache") database = WXWorkDB(source, keys, cache_dir=cache) - if database.health_check(): + if database.health_check(account=account or None): return database database.close() + if not initialize: + raise DatabaseReadError("已找到当前账号消息库,尚未完成本机初始化;请点击检测本机协议,自动获取并校验本机密钥") select_source_directory(source) acquire_local_keys() database = WXWorkDB(source, load_keys(), cache_dir=cache) - if not database.health_check(): + if not database.health_check(account=account or None): database.close() - raise DatabaseReadError("自动解密尚未得到消息数据库,可暂用聊天内容复制") + raise DatabaseReadError("本机初始化后,当前账号消息库仍不可读取。请核对数据目录、当前登录账号及本机密钥") return database def _run(self): @@ -151,6 +153,16 @@ except DatabaseReadError: return None + def get_conversation_context_by_id(self, account, conv_id, *, limit=100): + if not isinstance(account, str) or not account.strip(): + raise ValueError("读取协议会话必须提供账号 ID") + if not isinstance(conv_id, str) or not conv_id.strip(): + raise ValueError("读取协议会话必须提供会话 ID") + limit = max(1, min(500, int(limit))) + key = ("context_by_id", account, conv_id, limit) + # Preserve errors/timeouts; protocol readers must never trigger clipboard fallback. + return self._request(key, "get_conversation_context_by_id", account, conv_id, limit=limit) + def health_check(self): return self._ready and not self._stop.is_set() --- current/run_backend.py +++ protocol/run_backend.py @@ -27,13 +27,6 @@ SCRIPT_DIR = Path(__file__).resolve().parent SERVICES = { - "knowledge": { - "label": "知识加工 Worker", - "default_port": None, - "argv": lambda db, host, port, _zyt_api_url: [ - sys.executable, "knowledge_worker.py", "--db", str(db), - ], - }, "api": { # 老的网页后台(console/8765)已整体退役:界面由 Vue 管理端承担, # 桌面端同步、调用留痕上报也都并进了这个服务。 @@ -116,13 +109,12 @@ "--only", nargs="*", choices=sorted(SERVICES), default=sorted(SERVICES) ) for name, spec in SERVICES.items(): - if spec["default_port"] is not None: - parser.add_argument(f"--{name}-port", type=int, default=spec["default_port"]) + parser.add_argument(f"--{name}-port", type=int, default=spec["default_port"]) args = parser.parse_args() db = Path(args.db).resolve() # 管理端负责建表和播种 admin,必须最先起来:网关和它读同一个库,表不在就只能报错 - order = [name for name in ("api", "gateway", "knowledge") if name in args.only] + order = [name for name in ("api", "gateway") if name in args.only] if not order: raise SystemExit("没有选中任何服务") @@ -132,7 +124,7 @@ busy = [ (name, getattr(args, f"{name}_port")) for name in order - if SERVICES[name]["default_port"] is not None and _port_busy(args.host, getattr(args, f"{name}_port")) + if _port_busy(args.host, getattr(args, f"{name}_port")) ] if busy: print("以下端口已被占用,服务没有启动:") @@ -156,7 +148,7 @@ print(f"数据库:{db}") for name in order: spec = SERVICES[name] - port = getattr(args, f"{name}_port", None) + port = getattr(args, f"{name}_port") proc = subprocess.Popen( spec["argv"](db, args.host, port, args.zyt_api_url), cwd=SCRIPT_DIR, @@ -171,8 +163,7 @@ threading.Thread( target=_pump, args=(name, proc.stdout), daemon=True ).start() - address = f"http://{args.host}:{port}" if port else "后台任务" - print(f"已启动 {name:8s} {address} {spec['label']}") + print(f"已启动 {name:8s} http://{args.host}:{port} {spec['label']}") if name == "api": # 让建表先跑完,网关起来时表就已经在了 time.sleep(1.5) --- current/sign_artifact.ps1 +++ protocol/sign_artifact.ps1 @@ -4,8 +4,8 @@ # 给构建产物加 Authenticode 签名。 # -# 下载提示"有风险/有病毒"的根源是 EXE 没有数字签名、也没有下载信誉, -# SmartScreen 和浏览器会拦截一切无签名的新程序。买到代码签名证书后: +# 签名用于验证发布者和文件完整性;它不证明程序无风险,不能消除行为检测。 +# 未知发布者/下载信誉提示与 Defender 病毒或行为告警必须分别调查。使用已有证书时: # - 证书在系统证书库里: 设 WECOM_CODESIGN_THUMBPRINT=<证书指纹> # - 证书是 PFX 文件: 设 WECOM_CODESIGN_PFX=, # 密码放 WECOM_CODESIGN_PFX_PASSWORD @@ -13,15 +13,15 @@ $ErrorActionPreference = "Stop" +if (-not (Test-Path -LiteralPath $Path)) { + throw ("Cannot sign, file not found: " + $Path) +} + $thumbprint = ([string]$env:WECOM_CODESIGN_THUMBPRINT).Trim() $pfxPath = ([string]$env:WECOM_CODESIGN_PFX).Trim() if (-not $thumbprint -and -not $pfxPath) { Write-Output ("Code signing skipped (no certificate configured): " + [System.IO.Path]::GetFileName($Path)) exit 0 -} - -if (-not (Test-Path -LiteralPath $Path)) { - throw ("Cannot sign, file not found: " + $Path) } function Find-SignTool { --- current/task.md +++ protocol/task.md @@ -1,4 +1,24 @@ # 企业微信 PC 端自动化 (RPA) 任务清单 + +## 当前新增:原账号协议自动回复(2026-09-14) + +完整范围与验收见 [协议自动回复功能规划](PROTOCOL_REPLY_FEATURE_PLAN.md)。保留原员工账号和现有客户会话,首版采用数据库接收与已登录客户端桥接发送。 + +- [x] 完成现有代码梳理、功能规划、页面交互和分阶段验收设计。 +- [x] 检测实际客户端 5.0.10.6023(x86)、静态分析内置代理与 SDK,输出诊断报告。 +- [x] 实现协议通道配置、后台检测、报告导出、启动拦截与按账号/会话 ID 读取上下文。 +- [x] 实现内置 SDK NDJSON 通信层、连接诊断与 EXE 只读诊断入口;通过本轮 118 项相关回归和 JavaScript 语法检查。 +- [x] 构建 1.4.9 EXE,包内自检与实际 EXE 协议诊断通过;协议发送仍明确不可用。 +- [ ] P0:验证在线原账号/会话映射、可调用文本发送入口及可靠结果确认。 +- [ ] P1-A:统一消息身份、持久化队列、游标提交、发送事务和旧数据迁移。 +- [ ] P1-B:实现协议收发闭环、AI 接入、结果核对、暂停恢复和人工接管。 +- [ ] P1-C:接入通道设置、托管客户、控制台、审核、日志与 EXE 打包。 +- [ ] P1-D:完成故障恢复、24 小时稳定验证和视觉模式回归。 +- [ ] P2:逐项扩展客户端事件、图片文件、多账号及受约束自动切换。 + +当前已实现 SDK 通信层与协议准备功能;本机 SDK 运行时未连通,真实发送入口、身份与回执验证仍未完成,不能启动协议自动回复。 +证据与限制见 [本机协议分析与实现状态](WXWORK_PROTOCOL_ANALYSIS.md)。以下为项目早期 GUI 任务记录。 + ## 目标 从零构建基于 Python 的无侵入式企业微信 PC 端 GUI 自动化脚本。 --- current/test_admin_api.py +++ protocol/test_admin_api.py @@ -1360,7 +1360,7 @@ _SHAPE = { "version", "updated_at", "updated_by", "config", "models", "roles", "account", - "release", "knowledge", + "release", # 网关地址由后台算好下发——桌面端只配一个后台地址,其余自动 "gateway", } @@ -1374,7 +1374,7 @@ 401, ) - def test_payload_preserves_legacy_fields_and_adds_tenant_knowledge_summary(self) -> None: + def test_the_payload_has_the_same_shape_as_the_legacy_endpoint(self) -> None: resp = self.client.get( "/api/v2/desktop/config", headers=self.desktop_login(), @@ -1399,9 +1399,6 @@ "updated_at", }, ) - self.assertEqual(set(data["knowledge"]), {"published", "enabled", "updated_at"}) - self.assertEqual(data["knowledge"]["published"], 0) - self.assertFalse(data["knowledge"]["enabled"]) self.assertEqual(set(data["gateway"]), {"enabled", "url"}) self.assertTrue( data["gateway"]["url"].startswith("http"), --- current/test_packaging_paths.py +++ protocol/test_packaging_paths.py @@ -17,7 +17,7 @@ def test_exporter_import_does_not_replace_desktop_crypto(self): import wxwork_crypto original_path = sys.path[:] - exporter_root = Path(__file__).resolve().parent.parent / backup.EXACT_EXPORTER_DIR + exporter_root = Path(__file__).resolve().parent / "archive_exporter" exporter, media = backup._load_exporter_modules(exporter_root) self.assertTrue(callable(exporter.decrypt_wxwork_database)) self.assertTrue(callable(media.export_media)) --- current/test_qt_capsule.py +++ protocol/test_qt_capsule.py @@ -122,6 +122,14 @@ self.capsule.set_status("running", "监听中") self.app.processEvents() self.assertEqual(self.capsule.progress.text(), "当前进程 · 等待新消息") + + def test_running_replaces_stale_connection_or_window_waiting_progress(self): + for state in ('connecting','waiting'): + with self.subTest(state=state): + self.capsule.set_progress('') + self.capsule.set_status(state,'连接中') + self.capsule.set_status('running','监听中') + self.assertEqual(self.capsule.progress.text(),'当前进程 · 等待新消息') def test_a_long_nickname_is_shortened_instead_of_overflowing(self): long_name = "正在回复 甄养堂医疗助理-盛灵莉客户服务专员@微信" --- current/wechat_bot.py +++ protocol/wechat_bot.py @@ -9091,6 +9091,8 @@ if sent: self._log_queue_event(fp, "已发送", reply_text) return + if self._session_is_unrepliable(fp): + return reason = self.last_send_failure_reason() state = self._pending_reply_state(fp) or {} if state.get("foreign_draft_at") is not None: @@ -9746,6 +9748,46 @@ return True return False + def _skip_contact_verification(self, fp: bytes, full=None) -> bool: + """Read-only OCR of the verified current chat; never click a verification link.""" + from contact_reply_guard import visual_contact_reason, CONTACT_REASON + key = bytes(fp or b"").hex() + if not key: + return False + attributes = ("_chat_rel_x", "_chat_rel_y", "_chat_rel_w", "_chat_rel_h") + if not all(hasattr(self, name) for name in attributes): + return False + try: + if full is None: + full = self._capture_full_window() + if full is None or getattr(full, "ndim", 0) != 3: + return False + x, y, w, h = (int(getattr(self, name)) for name in attributes) + if x < 0 or y < 0 or w < 20 or h < 20 or x+w > full.shape[1] or y+h > full.shape[0]: + return False + region = np.ascontiguousarray(full[y:y+h, x:x+w, :3]) + signature = (key, hashlib.sha256(region.tobytes()).digest()) + cached = getattr(self, "_contact_notice_cache", None) + if cached and cached[0] == signature and time.monotonic()-cached[2] < 2.0: + reason = cached[1] + else: + reader = getattr(self, "_name_reader_instance", None) or self._name_reader + reason = visual_contact_reason(region, reader.read_layout(region, max_results=120)) + self._contact_notice_cache = (signature, reason, time.monotonic()) + except Exception: + # A failed OCR read is not evidence that a customer is unreachable. + return False + if not reason: + return False + if not hasattr(self, "_unrepliable_sessions"): + self._unrepliable_sessions = {} + self._unrepliable_sessions[key] = time.time() + self._persist_unrepliable_sessions() + self._log_queue_event(fp, "跳过", CONTACT_REASON) + self._clear_reply_pending(fp) + print(f" [会话过滤] {CONTACT_REASON};6 小时后重新检查会话状态。") + return True + def _session_accepts_replies(self, fp: bytes) -> bool: """Confirm the open page has a composer before spending an AI call. @@ -9757,6 +9799,9 @@ if not key: return True if self._session_is_unrepliable(fp): + self._clear_reply_pending(fp) + return False + if self._skip_contact_verification(fp): return False strikes = getattr(self, "_composerless_strikes", None) if strikes is None: @@ -11294,6 +11339,8 @@ send_state = str(state.get("send_state") or "") if not send_state: return "unsent" + if self._skip_contact_verification(fp): + return "skipped" known_states = {"sending", "sent_uncommitted", "uncertain"} state_was_uncertain = send_state == "uncertain" if send_state not in known_states: @@ -12003,6 +12050,12 @@ print(" [发送保护] 当前聊天对象已经变化,已取消粘贴和发送。") self._run_ai_page_guard("发送前会话校验异常") return self._deny_send("粘贴前发现打开的已不是目标会话") + if expected_fp is not None: + if self._session_is_unrepliable(expected_fp): + self._clear_reply_pending(expected_fp) + return self._deny_send("该会话暂不可回复,已跳过") + if self._skip_contact_verification(expected_fp, full=full): + return self._deny_send("对方需要联系人验证,已取消待回复并跳过") reply_text = text or AUTO_REPLY_TEXT expected_reply = self._normalized_message_text(reply_text) if not expected_reply: @@ -13699,7 +13752,7 @@ ): continue send_reconciliation = self._reconcile_uncertain_send(target_fp, state) - if send_reconciliation == "sent": + if send_reconciliation in {"sent", "skipped"}: return True if send_reconciliation == "uncertain": # Isolate only this transaction. Keep walking the queue so @@ -13818,7 +13871,7 @@ state = migrated_state send_reconciliation = self._reconcile_uncertain_send(target_fp, state) - if send_reconciliation == "sent": + if send_reconciliation in {"sent", "skipped"}: return True if send_reconciliation == "uncertain": continue @@ -14013,6 +14066,10 @@ 判不出左右时(宽气泡、深色主题)返回 False,也就是照常回复——宁可多问 一句,不能因为看不清就把客户晾着。 """ + # A failed outgoing bubble can contain the exact draft text. Contact + # restrictions must win over every "already sent" heuristic, including review drafts. + if self._skip_contact_verification(fp): + return True database_text = self._database_chat_text(fp) database_direction = self._database_message_direction(fp, database_text) if database_direction is False: @@ -14209,7 +14266,7 @@ return True return False send_reconciliation = self._reconcile_uncertain_send(fp, pending_state) - if send_reconciliation == "sent": + if send_reconciliation in {"sent", "skipped"}: return True if send_reconciliation == "uncertain": return False @@ -15476,6 +15533,7 @@ break self._set_task_stage(target_fp, "reading", detail="会话已打开,正在读取新消息") if not self._session_accepts_replies(target_fp): + processed_fp.add(target_fp) continue who = self._session_label(target_fp) if self._reply_awaiting_review(target_fp): --- current/wechat_gui.py +++ protocol/wechat_gui.py @@ -196,6 +196,7 @@ ) +from reply_transport import VISUAL, normalize_reply_transport from gui_runtime import ( # noqa: F401 # 供旧代码与测试通过 wechat_gui 引用 MESSAGE_BATCH_WINDOW_MAX_SECONDS, MESSAGE_BATCH_WINDOW_MIN_SECONDS, @@ -497,6 +498,7 @@ "mouse_idle_seconds": MOUSE_IDLE_SECONDS, "send_delay_seconds": SEND_DELAY_SECONDS, "send_mode": SEND_MODE_AUTO, + "reply_transport": VISUAL, "message_batch_window_seconds": MESSAGE_BATCH_WINDOW_SECONDS, } @@ -509,6 +511,7 @@ return settings if not isinstance(saved, dict): return settings + settings["reply_transport"] = normalize_reply_transport(saved.get("reply_transport", VISUAL)) try: reply = str(saved.get("auto_reply_text", settings["auto_reply_text"])).strip() poll = float(saved.get("poll_interval", settings["poll_interval"])) @@ -545,7 +548,9 @@ "message_batch_window_seconds": batch_window, }) except (TypeError, ValueError): - return self._runtime_defaults() + defaults = self._runtime_defaults() + defaults["reply_transport"] = settings["reply_transport"] + return defaults return settings def _schedule_runtime_settings_save(self, *_args): @@ -605,6 +610,8 @@ return False settings = { + "reply_transport": normalize_reply_transport( + getattr(self, "_runtime_settings", {}).get("reply_transport", VISUAL)), "auto_reply_text": self._reply_var.get().strip() or AUTO_REPLY_TEXT, "poll_interval": poll, "mouse_idle_enabled": bool(self._mouse_idle_var.get()), @@ -3853,6 +3860,7 @@ send_mode=normalize_send_mode( self._runtime_settings.get("send_mode", SEND_MODE_AUTO) ), + reply_transport=self._runtime_settings.get("reply_transport", VISUAL), enable_engine_b=bool( self._runtime_settings.get("enable_engine_b", True) ), --- current/wechat_gui_qt.py +++ protocol/wechat_gui_qt.py @@ -2,6 +2,7 @@ """PySide6 desktop shell for the WeCom customer-service assistant.""" from __future__ import annotations +from reply_transport import protocol_ready, queue_path_for_transport import json import math @@ -94,6 +95,7 @@ # 直接用 GUI 无关的共享运行时;过去 import wechat_gui 会连带把整个 # tkinter/tcl 拖进 Qt 进程,冷启动白付一笔加载费。 +from reply_transport import VISUAL, PROTOCOL, normalize_reply_transport from gui_runtime import ( BotThread, LogQueue, @@ -5374,10 +5376,9 @@ knowledge_copy.addWidget(sync_knowledge) manage_knowledge = _button("▱ 管理知识") manage_knowledge.setFixedSize(140, 38) - def open_knowledge_center(): - import backend_client - QDesktopServices.openUrl(QUrl(backend_client.knowledge_management_url())) - manage_knowledge.clicked.connect(open_knowledge_center) + manage_knowledge.clicked.connect( + lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(SCRIPT_DIR))) + ) knowledge_copy.addWidget(manage_knowledge) knowledge_body.addLayout(knowledge_copy) knowledge_body.addStretch(1) @@ -6739,7 +6740,7 @@ def _refresh_queue(self) -> None: try: - with open(SCRIPT_DIR / "pending_replies.json", encoding="utf-8") as handle: + with open(queue_path_for_transport(root=SCRIPT_DIR), encoding="utf-8") as handle: raw = json.load(handle) except FileNotFoundError: raw = {} @@ -7775,7 +7776,9 @@ self.set_progress("正在连接企业微信") elif state == "waiting" and not self._progress_text: self.set_progress("等待企业微信窗口") - elif state == "running" and not self._progress_text: + elif state == "running" and self._progress_text in ( + "", "正在连接企业微信", "等待企业微信窗口" + ): self.set_progress("等待新消息") def set_progress(self, text: str) -> None: @@ -8507,7 +8510,10 @@ except (OSError, ValueError) as exc: QMessageBox.warning(self.host, "数据目录不可用", str(exc)) return - self.host._refresh_wechat_database(refresh_cache=True) + if getattr(self.host, "runtime_settings", {}).get("reply_transport") == PROTOCOL: + self.host._refresh_protocol_probe() + else: + self.host._refresh_wechat_database(refresh_cache=True) @Slot() def importWeChatDatabaseKeys(self) -> None: @@ -8742,8 +8748,18 @@ @Slot() def editKnowledge(self) -> None: - import backend_client - QDesktopServices.openUrl(QUrl(backend_client.knowledge_management_url())) + page = self.host.persona_page + text, accepted = QInputDialog.getMultiLineText( + self.host, + "管理知识", + "MCP 服务器 JSON(数组):", + page.mcp_json.toPlainText(), + ) + if not accepted: + return + page.mcp_json.setPlainText(text) + self.host._console_ai_synced = bool(page.save_config()) + self.host._push_console_state() @Slot() def syncKnowledge(self) -> None: @@ -8876,6 +8892,35 @@ self.host.settings_page.reply.setText(str(text or "").strip()[:120]) self.host.settings_page._emit_save() self.host._push_console_state() + + @Slot(str, result=str) + def setReplyTransport(self, value: str) -> str: + transport = normalize_reply_transport(value) + if transport not in (VISUAL, PROTOCOL): + return json.dumps({"ok": False, "message": "无效的回复通道"}, ensure_ascii=False) + thread = getattr(self.host, "_thread", None) + if thread is not None and thread.is_alive(): + return json.dumps({"ok": False, "message": "请先停止监听,再切换回复通道"}, ensure_ascii=False) + if self.host.save_runtime_settings({"reply_transport": transport}) is False: + return json.dumps({"ok": False, "message": "通道设置未保存,请查看运行日志"}, ensure_ascii=False) + if transport == PROTOCOL: + self.host._refresh_protocol_probe() + self.host._push_console_state(force=True) + message = "已选择协议模式;检测通过并完成发送适配后才可启动" if transport == PROTOCOL else "已选择视觉模式" + return json.dumps({"ok": True, "message": message}, ensure_ascii=False) + + @Slot() + def probeProtocol(self) -> None: + self.host._refresh_protocol_probe() + + @Slot() + def validateProtocolSend(self) -> None: + from wecom_protocol_validation_ui import start_validation + start_validation(self.host) + + @Slot() + def exportProtocolReport(self) -> None: + self.host._export_protocol_report() @Slot(str, result=str) def saveAutomationSettings(self, payload: str) -> str: @@ -9195,6 +9240,7 @@ class MainWindow(QMainWindow): desktopSessionChecked = Signal(str, object) wechatDatabaseLoaded = Signal(object) + protocolProbeLoaded = Signal(object) def _set_account_menu_title(self, account: dict | None = None) -> None: if account is None: @@ -9428,6 +9474,7 @@ self.capsule = CapsuleWindow() self._connect_signals() + QTimer.singleShot(250, self._maybe_refresh_protocol_probe) self.business_refresh_timer = QTimer(self) self.business_refresh_timer.setSingleShot(True) self.business_refresh_timer.setInterval(220) @@ -9526,6 +9573,7 @@ self.capsule.expandRequested.connect(self.expand_console) self.capsule.stopRequested.connect(self.stop_monitoring) self.wechatDatabaseLoaded.connect(self._wechat_database_loaded) + self.protocolProbeLoaded.connect(self._protocol_probe_loaded) @staticmethod def _runtime_defaults() -> dict: @@ -9540,6 +9588,7 @@ "mouse_idle_seconds": 5.0, "send_delay_seconds": SEND_DELAY_SECONDS, "send_mode": SEND_MODE_AUTO, + "reply_transport": VISUAL, "message_batch_window_seconds": MESSAGE_BATCH_WINDOW_SECONDS, "foreign_draft_autosend_minutes": FOREIGN_DRAFT_AUTOSEND_MINUTES, "auto_launch": False, @@ -9567,6 +9616,7 @@ settings.update({key: saved[key] for key in settings if key in saved}) except (OSError, ValueError, TypeError): pass + settings["reply_transport"] = normalize_reply_transport(settings["reply_transport"]) try: settings["poll_interval"] = max(0.2, float(settings["poll_interval"])) settings["mouse_idle_seconds"] = max(0.0, float(settings["mouse_idle_seconds"])) @@ -9596,7 +9646,9 @@ or "在的,您慢慢说,我这边看着呢。" ) except (TypeError, ValueError): - return self._runtime_defaults() + defaults = self._runtime_defaults() + defaults["reply_transport"] = settings["reply_transport"] + return defaults return settings def _on_dashboard_engine_settings(self, engine: dict) -> None: @@ -9609,13 +9661,20 @@ merged.update(engine or {}) self.save_runtime_settings(merged) - def save_runtime_settings(self, settings: dict | None = None) -> None: + def save_runtime_settings(self, settings: dict | None = None) -> bool: previous_auto_launch = bool( getattr(self, "runtime_settings", {}).get("auto_launch", False) ) merged = dict(self._runtime_defaults()) merged.update(getattr(self, "runtime_settings", {}) or {}) merged.update(settings or self.settings_page.values()) + merged["reply_transport"] = normalize_reply_transport(merged.get("reply_transport", VISUAL)) + thread = getattr(self, "_thread", None) + if (thread is not None and thread.is_alive() + and merged["reply_transport"] != getattr(self, "runtime_settings", {}).get("reply_transport", VISUAL)): + self._broadcast_save_result(False, "请先停止监听,再切换回复通道") + return False + previous_settings = getattr(self, "runtime_settings", {}) self.runtime_settings = merged try: temporary = APP_SETTINGS_FILE.with_suffix(".json.tmp") @@ -9629,7 +9688,9 @@ # 只回报前者的话,从系统设置页点保存的人盯着的是另一块屏幕区域, # 什么都不会看到——"点了没反应"的一半原因就在这里。 self._broadcast_save_result(False, f"保存失败:{exc}") + self.runtime_settings = previous_settings self.append_log(f"运行参数保存失败:{exc}", "err") + return False else: if previous_auto_launch != bool(self.runtime_settings.get("auto_launch", False)): self._sync_auto_launch(bool(self.runtime_settings.get("auto_launch", False))) @@ -9646,6 +9707,7 @@ self.runtime_settings["foreign_draft_autosend_minutes"] )) self._broadcast_save_result(True, "设置已保存并生效") + return True def _broadcast_save_result(self, ok: bool, message: str) -> None: """把保存结果同时告诉所有共用这份运行参数的页面。""" @@ -9706,6 +9768,10 @@ shell = getattr(self, "console_shell", None) if changed and shell is not None: shell.set_view(view) + if view == "automation": + refresh_protocol = getattr(self, "_maybe_refresh_protocol_probe", None) + if callable(refresh_protocol): + refresh_protocol() if view == "wechatdb": refresh_database = getattr(self, "_refresh_wechat_database", None) if callable(refresh_database): @@ -9732,6 +9798,10 @@ self._console_select_task(selected) if name == "archive" and getattr(self, "business_page", None) is not None: self.business_page.refresh_data() + if name == "automation": + refresh_protocol = getattr(self, "_maybe_refresh_protocol_probe", None) + if callable(refresh_protocol): + refresh_protocol() if name == "wechatdb": refresh_database = getattr(self, "_refresh_wechat_database", None) if callable(refresh_database): @@ -10076,6 +10146,9 @@ def _console_wecom_info(self) -> dict: info = {"connected": False, "account": "—", "dpi": "", "hwnd": 0} bot = getattr(getattr(self, "_thread", None), "bot", None) + if bot is not None and getattr(bot, "account", None) and self.runtime_settings.get("reply_transport") == PROTOCOL: + return {"connected": not bool(getattr(bot, "needs_attention", False)), + "account": str(bot.account), "dpi": "协议通道", "hwnd": 0} hwnd = int(getattr(bot, "hwnd", 0) or 0) if bot is not None else 0 if hwnd: info["connected"] = True @@ -10765,13 +10838,6 @@ servers = parsed except (json.JSONDecodeError, TypeError, AttributeError): servers = [] - try: - import backend_client - knowledge_summary = backend_client.load_settings().get("knowledge_summary") or {} - except Exception: - knowledge_summary = {} - knowledge_count = max(0, int(knowledge_summary.get("published") or 0)) - knowledge_enabled = bool(knowledge_summary.get("enabled")) return { "name": page.agent_name.text().strip() or "贴心管家", "prompt": page.persona_prompt.toPlainText(), @@ -10781,9 +10847,9 @@ "maxTokens": page.max_tokens.value(), "replyLengthIndex": length_index, "timeout": timeout, - "knowledge": str(knowledge_count), - "knowledgeCount": f"{knowledge_count:,}", - "knowledgeConnected": knowledge_enabled, + "knowledge": str(len(servers)), + "knowledgeCount": f"{len(servers):,}", + "knowledgeConnected": bool(servers), "syncAt": last_sync, "syncLabel": "尚未同步" if last_sync == "尚未同步" else "刚刚同步", "mcpRounds": page.mcp_rounds.value(), @@ -10814,6 +10880,91 @@ }) return cleaned + def _maybe_refresh_protocol_probe(self) -> None: + if getattr(self, "runtime_settings", {}).get("reply_transport") != PROTOCOL: + return + thread = getattr(self, "_thread", None) + if thread is not None and thread.is_alive(): + return + report = getattr(self, "_protocol_report", {}) or {} + if time.time() - float(report.get("checkedAt") or 0) >= 30: + self._refresh_protocol_probe() + + def _refresh_protocol_probe(self) -> None: + if getattr(self, "_protocol_probe_running", False): + return + self._protocol_probe_running = True + self._protocol_probe = { + "status": "checking", "label": "正在检测", "canSend": False, + "message": "正在识别企业微信实际安装位置、当前账号及聊天数据目录;首次初始化可能需要约 60 秒,不发送消息…", + } + self._push_console_state(force=True) + + thread = getattr(self, "_thread", None) + initialize = (getattr(self, "runtime_settings", {}).get("reply_transport") == PROTOCOL + and not (thread is not None and thread.is_alive())) + + def worker(): + try: + from wxwork_protocol_probe import probe_installed_client + report = probe_installed_client(initialize_database=initialize) + except Exception as exc: + report = { + "schemaVersion": 1, "checkedAt": time.time(), "readOnly": True, + "status": "probe_failed", "label": "检测失败", "canSend": False, + "message": "客户端检测失败:" + str(exc)[:400], + } + try: + self.protocolProbeLoaded.emit(report) + except RuntimeError: + pass + + threading.Thread(target=worker, name="protocol-client-probe", daemon=True).start() + + @Slot(object) + def _protocol_probe_loaded(self, report: object) -> None: + self._protocol_probe_running = False + self._protocol_report = dict(report) + self._protocol_probe = { + key: self._protocol_report.get(key) + for key in ("status", "label", "message", "checkedAt", "clientVersion", "architecture", "nativeAdapter", "nativeAccount", "adapterInstalled", "database", "databaseReady", "clientInstallPath", "supportedClient", "host", "verificationScope", "targetSendVerified", "clientSha256", "localSendVerified", "needsSendValidation", "lastSendValidation") + } + self._protocol_probe.update( + canSend=protocol_ready(self._protocol_report), exportable=True, + sdkRuntime=self._protocol_report.get("sdkRuntime") or {}, + nativeTrial=self._protocol_report.get("nativeTrial") or {}, + clientDetected=bool(self._protocol_report.get("clients")), + bundledAgentDetected=any(item.get("installed") for item in self._protocol_report.get("bundledAgents", [])), + loginVerified=bool(self._protocol_report.get("loginVerified")), + sendVerified=bool(self._protocol_report.get("sendVerified")), receiptVerified=bool(self._protocol_report.get("receiptVerified")), + ) + self.append_log("[协议检测] " + str(self._protocol_probe.get("message") or "已完成"), + "ok" if self._protocol_probe["canSend"] else "warn") + self._push_console_state(force=True) + if getattr(self, "_protocol_start_pending", False): + self._protocol_start_pending = False + if self._protocol_probe["canSend"] and self.runtime_settings.get("reply_transport") == PROTOCOL: + QTimer.singleShot(0, self.start_monitoring) + + def _export_protocol_report(self) -> None: + report = getattr(self, "_protocol_report", None) + if not report or getattr(self, "_protocol_probe_running", False): + self.append_log("请等待协议检测完成后导出报告。", "warn") + self._refresh_protocol_probe() + return + suggested = SCRIPT_DIR / f"wecom-protocol-{time.strftime('%Y%m%d-%H%M%S')}.json" + filename, _ = QFileDialog.getSaveFileName( + self, "导出协议诊断报告", str(suggested), "JSON 文件 (*.json)") + if not filename: + return + try: + from wxwork_protocol_probe import export_probe_report + export_probe_report(report, filename) + except (OSError, ValueError) as exc: + self.append_log(f"协议报告导出失败:{exc}", "err") + return + self.append_log(f"协议诊断报告已保存:{filename}", "ok") + def _console_automation_state(self, wecom) -> dict: settings = self.settings_page.values() connected = bool(wecom) if isinstance(wecom, bool) else bool((wecom or {}).get("connected")) @@ -10826,6 +10977,11 @@ except Exception: timeout = 120 return { + "transport": getattr(self, "runtime_settings", {}).get("reply_transport", VISUAL), + "protocol": getattr(self, "_protocol_probe", { + "status": "not_checked", "label": "尚未检测", "canSend": False, + "message": "选择协议模式后会自动检测本机环境;也可点击检测本机协议。", + }), "poll": settings.get("poll_interval"), "batch": settings.get("message_batch_window_seconds"), "delay": settings.get("send_delay_seconds"), @@ -10936,7 +11092,7 @@ archive = file_size(SCRIPT_DIR / "conversations.json") log = file_size(SCRIPT_DIR / "runtime.log") + file_size(SCRIPT_DIR / "wechat_rpa.log") cache = ( - file_size(SCRIPT_DIR / "pending_replies.json") + file_size(queue_path_for_transport(self.runtime_settings.get("reply_transport", VISUAL))) + file_size(SCRIPT_DIR / "false_pos_cache.json") + file_size(SCRIPT_DIR / "vision_status.json") ) @@ -10986,7 +11142,7 @@ else: result = delete_pending_reply_file( requested, - str(SCRIPT_DIR / "pending_replies.json"), + str(queue_path_for_transport(self.runtime_settings.get("reply_transport", VISUAL))), ) except Exception as exc: QMessageBox.warning(self, "删除失败", f"无法删除队列任务:{exc}") @@ -11143,7 +11299,20 @@ if self._thread is not None and self._thread.is_alive(): self.append_log("监听线程已经在运行", "warn") return - self.save_runtime_settings() + if self.save_runtime_settings() is False: + return + transport = normalize_reply_transport(self.runtime_settings.get("reply_transport", VISUAL)) + if transport not in (VISUAL, PROTOCOL): + self.append_log("回复通道配置无效,请在自动化设置中重新选择。", "err") + return + if transport == PROTOCOL: + report = getattr(self, "_protocol_report", {}) or {} + if (getattr(self, "_protocol_probe_running", False) or not protocol_ready(report) + or time.time() - float(report.get("checkedAt") or 0) >= 30): + self._protocol_start_pending = True + self.append_log("正在检查本机协议环境,检查通过后继续启动监听。", "notify") + self._refresh_protocol_probe() + return self.persona_page.save_config() self._stdout_proxy = LogQueue( self._queue, @@ -11161,6 +11330,7 @@ ), send_delay_seconds=float(self.runtime_settings["send_delay_seconds"]), send_mode=self.runtime_settings["send_mode"], + reply_transport=self.runtime_settings.get("reply_transport", VISUAL), foreign_draft_autosend_minutes=float( self.runtime_settings["foreign_draft_autosend_minutes"] ), @@ -11180,7 +11350,7 @@ self._running = True self._start_time = time.time() self.set_status("connecting", "连接中") - self.append_log("正在连接企业微信窗口…", "notify") + self.append_log("正在连接企业微信原账号协议…" if transport == PROTOCOL else "正在连接企业微信窗口…", "notify") self._thread.start() # 一旦开始监听就收进胶囊:控制台占着大半个屏幕,而机器人要操作的是 # 企业微信窗口——留着整块面板挡在前面既碍事,也容易被误点。胶囊上有 @@ -11188,6 +11358,7 @@ QTimer.singleShot(0, self.enter_capsule_mode) def stop_monitoring(self) -> None: + self._protocol_start_pending = False if self._thread is None or not self._thread.is_alive(): self._finish_thread("stopped") return @@ -11828,13 +11999,14 @@ import desktop_login import desktop_updater import wxwork_message_browser - from packaging_checks import check_database_initialization, check_https_runtime, check_login_routing, check_message_navigation + from packaging_checks import check_database_initialization, check_https_runtime, check_login_routing, check_message_navigation, check_protocol_environment import startup_update check_https_runtime() check_login_routing() check_login_routing(legacy_terminal=True) check_database_initialization() + check_protocol_environment() check_message_navigation() except Exception: import traceback @@ -11898,6 +12070,12 @@ def main() -> None: + if "--protocol-send-test" in sys.argv: + from wecom_native_verification import run_send_test + raise SystemExit(run_send_test(sys.argv[1:])) + if "--protocol-diagnostics" in sys.argv: + from wxwork_protocol_probe import run_packaged_diagnostics + raise SystemExit(run_packaged_diagnostics(sys.argv[1:])) _apply_saved_scale_preference() QApplication.setHighDpiScaleFactorRoundingPolicy( Qt.HighDpiScaleFactorRoundingPolicy.PassThrough --- current/wechat_rpa.spec +++ protocol/wechat_rpa.spec @@ -5,6 +5,7 @@ from pathlib import Path from packaging_native import pin_ssl_binaries, python_ssl_binaries +from packaging_exporter import exporter_data_files from PyInstaller.utils.hooks import collect_data_files, collect_submodules from PyInstaller.utils.win32.versioninfo import ( @@ -22,8 +23,7 @@ app_version = os.environ.get("WECOM_BUILD_VERSION", "1.0.0").strip() or "1.0.0" app_name = f"ZhenAI-WeCom-Assistant-v{app_version}" -# 完整的版本信息资源。没有它,EXE 在杀软启发式评分和 SmartScreen 提示里 -# 就是一个"来历不明"的匿名程序;有了它至少提示里能显示发布者与产品名。 +# 产品版本资源仅用于标识程序,不等同于可信发布者签名或杀毒软件审核。 _version_parts = [int(p) for p in (app_version.split(".") + ["0", "0", "0"])[:4]] version_resource = VSVersionInfo( ffi=FixedFileInfo( @@ -62,6 +62,17 @@ "conversation_store", "dsh_agent", "gui_runtime", + "reply_transport", + "wxwork_protocol_probe", + "wecom_sdk_client", + "wecom_native_protocol", + "wecom_native_sender", + "wecom_native_builds", + "wecom_native_evidence", + "wecom_protocol_validation_ui", + "wecom_native_stub", + "wecom_native_verification", + "protocol_engine", "mcp_bridge", "registration_store", "runtime_paths", @@ -76,18 +87,8 @@ ] hidden_imports += collect_submodules("mcp") -# Only ship exporter code, never local keys, account settings or chat databases. -exporter_root = project_root.parent / "2026-08-19-18-27-34" -exporter_files = [ - "wxwork_export_final.py", "wxwork_export_media.py", - "wxwork_voice2text.py", "wxwork_crypto.py", -] -exporter_data = [] -for filename in exporter_files: - source = exporter_root / filename - if not source.is_file(): - raise FileNotFoundError(f"Required archive exporter is missing: {source}") - exporter_data.append((str(source), "archive_exporter")) +# Explicit code allowlist: never include local keys, account settings or chat data. +exporter_data = exporter_data_files(project_root) hidden_imports += ["Crypto.Cipher.AES"] # Explicitly pin the SSL DLLs before and after dependency analysis. --- current/wxwork_db.py +++ protocol/wxwork_db.py @@ -407,10 +407,15 @@ return values -def detect_wxwork_dir(candidates=None) -> str | None: - """优先保存位置,再找当前用户文档、OneDrive 和其他用户的常见位置。""" +def detect_wxwork_dir(candidates=None, *, account="") -> str | None: + """Resolve moved folders and optionally require the currently logged-in account.""" + account = str(account or "") + if account and not account.isdecimal(): + raise ValueError("企业微信账号 ID 无效") def valid(base): try: + if account: + return os.path.isfile(os.path.join(base, account, "Data", "message.db")) return any(os.path.isfile(os.path.join(base, d, "Data", "message.db")) for d in os.listdir(base)) except OSError: @@ -427,6 +432,8 @@ configured.append(str(settings["db_dir"])) except (OSError, ValueError, TypeError): pass + from wecom_environment import registry_locations + configured.extend(registry_locations()["data"]) # 重定向后的文档先于旧的 Documents,避免误选迁移前遗留的数据。 configured.extend(os.path.join(folder, "WXWork") for folder in _windows_documents_dirs()) for variable in ("OneDrive", "OneDriveCommercial", "OneDriveConsumer"): @@ -435,20 +442,25 @@ configured.extend(os.path.join(cloud, folder, "WXWork") for folder in ("Documents", "文档")) configured.append(os.path.join(os.path.expanduser("~"), "Documents", "WXWork")) users_root = os.path.join(os.environ.get("SystemDrive", "C:") + os.sep, "Users") - try: - configured.extend(os.path.join(users_root, user, "Documents", "WXWork") - for user in sorted(os.listdir(users_root))) - except OSError: - pass + if not account: + try: + configured.extend(os.path.join(users_root, user, "Documents", "WXWork") + for user in sorted(os.listdir(users_root))) + except OSError: + pass seen = set() for candidate in configured: if not candidate: continue candidate = os.path.abspath(os.path.expanduser(os.path.expandvars(os.fspath(candidate)))) - identity = os.path.normcase(candidate) - if identity not in seen and valid(candidate): - return candidate - seen.add(identity) + # Accept a WXWork root, account folder, Data folder, or message.db path. + if os.path.basename(candidate).lower() == "message.db": + candidate = os.path.dirname(candidate) + for base in (candidate, os.path.dirname(candidate), os.path.dirname(os.path.dirname(candidate))): + identity = os.path.normcase(base) + if identity not in seen and valid(base): + return base + seen.add(identity) return None @@ -913,7 +925,27 @@ found_account, found_conv, name = matches[0] if (account and str(account) != found_account) or (conv_id and str(conv_id) != found_conv): return None - account, conv_id = found_account, found_conv + return self._read_conversation_context(found_account, found_conv, name, limit) + + @_db_locked + def get_conversation_context_by_id(self, account: str, conv_id: str, *, limit=100): + """Protocol-only ID lookup; duplicate display names never affect routing. + + This verifies local DB identity only, not the client's active login. + The legacy screen-title lookup intentionally retains its ambiguity check. + """ + if not isinstance(account, str) or not account.strip(): + raise ValueError("读取协议会话必须提供账号 ID") + if not isinstance(conv_id, str) or not conv_id.strip(): + raise ValueError("读取协议会话必须提供会话 ID") + self._refresh_if_needed() + if account not in self._conns or not is_personal_chat(conv_id): + return None + name = self._conv_display_name(account, conv_id) + return self._read_conversation_context(account, conv_id, name, limit) + + def _read_conversation_context(self, account, conv_id, name, limit): + # Caller holds _db_locked for both refresh and query. connection = self._conns[account] columns = [row[1] for row in connection.execute("PRAGMA table_info(message_table)")] rows = connection.execute( @@ -982,11 +1014,17 @@ } @_db_locked - def health_check(self) -> bool: + def health_check(self, account=None) -> bool: """连接与密钥有效性自检。""" if not self._conns: return False try: + if account is not None: + conn = self._conns.get(str(account)) + if conn is None: + return False + conn.execute("SELECT sender_id, conversation_id, content_type, send_time, content FROM message_table LIMIT 0") + return True for conn in self._conns.values(): cur = conn.cursor() cur.execute("SELECT COUNT(*) FROM sqlite_master") --- current/打包说明.md +++ protocol/打包说明.md @@ -5,7 +5,7 @@ 也可以在 PowerShell 运行: ```powershell -cd C:\kefu\wechat_rpa +cd C:\wechat_rpa powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\build_installer.ps1 ``` @@ -21,4 +21,6 @@ 软件每次打开登录窗口时,先异步查询公开版本清单,不需要先登录。无更新时继续登录;普通更新可以稍后处理;强制更新必须完成升级才能登录。检查超时不阻塞登录,但已缓存的强制升级策略仍然有效。选择更新后复用 HTTPS 下载、SHA256 校验和独立更新器,安装完自动重新打开软件。 -发布时请在管理端填写更高版本号、HTTPS 安装包地址及 SHA256;仅本地打包不会让其他电脑自动收到更新。当前新包版本为 1.4.1,后续云端需发布高于 1.4.1 的版本才会提示更新。 +发布时请在管理端填写更高版本号、HTTPS 安装包地址及 SHA256;仅本地打包不会让其他电脑自动收到更新。本次修复版本为 1.4.12;版本号以 app_version.py 为准。安全复核未完成前不要发布为已验收版本。 + +归档源码已随项目保存在 archive_exporter/,不再需要旧电脑目录。构建成功与安全验收是两件事;2026-09-15 的 Defender 行为告警和复核状态见 SECURITY_REVIEW_20260915.md。