From a47a7799e77e1234c57d13541d1968b2cc3ecc85 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 7 Jul 2026 11:26:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor(wecom):=20=E6=92=A4=E9=99=A4=E6=AC=A2?= =?UTF-8?q?=E8=BF=8E=E6=8E=A8=E9=80=81=E6=9C=BA=E5=88=B6,=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E4=BC=98=E5=85=88=E5=B8=83=E5=B1=80=E4=B8=8B=E4=B8=8D?= =?UTF-8?q?=E9=9C=80=E8=A6=81(bump=200.46.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实际部署改为聊天优先布局(不配应用主页 + 自定义菜单「工作台」按钮指向 /v1/wecom/entry),点应用直进会话,冷启动问题不复存在,0.46.0 的欢迎 推送成了死重: - 删 wecom_welcomes 表(migration 0021 撤下;DB 已 downgrade 回 0020, 表建后无一行写入)、maybe_send_wecom_welcome、免登回调欢迎钩子、 scripts/wecom_welcome_broadcast.py、wecom.list_visible_userids - 保留:未绑定入站回绑定指引(聊天优先下必需)、core/branding.py 品牌单一源 - RUN.md 企微段只留推荐布局 + 未绑定指引;PROGRESS 同步 Co-Authored-By: Claude Fable 5 --- PROGRESS.md | 3 +- RUN.md | 5 +- core/__init__.py | 2 +- core/storage/models.py | 16 ---- core/wechat/service.py | 61 +------------- core/wechat/wecom.py | 38 --------- .../20260707_1000_0021_wecom_welcomes.py | 38 --------- scripts/wecom_welcome_broadcast.py | 80 ------------------- web/app.py | 6 -- 9 files changed, 7 insertions(+), 242 deletions(-) delete mode 100644 db/migrations/versions/20260707_1000_0021_wecom_welcomes.py delete mode 100644 scripts/wecom_welcome_broadcast.py diff --git a/PROGRESS.md b/PROGRESS.md index 17c8531..ba1a6c5 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-07-07(企业微信欢迎消息种会话入口,bump 0.46.0) +最后更新:2026-07-07(撤除企微欢迎推送机制,聊天优先布局下不需要,bump 0.46.1) --- @@ -23,6 +23,7 @@ ### 2026-07 +- **07-07 / 0.46.1**:撤除企微欢迎推送机制——实际部署采用聊天优先布局(不配应用主页 + 菜单「工作台」按钮)后,点应用直进会话,冷启动问题不复存在,0.46.0 的欢迎推送成了死重:删 `wecom_welcomes` 表(migration 0021 撤下,DB 已 downgrade 回 0020,表建后无一行写入)、`maybe_send_wecom_welcome`、免登回调欢迎钩子、群发脚本、`list_visible_userids`。**保留**:未绑定入站回绑定指引(聊天优先下必需)、`core/branding.py` 品牌单一源。RUN.md 企微段只留推荐布局 + 未绑定指引两条。 - **07-07 / 0.46.0**:企业微信会话冷启动 + 未绑定入站回指引——背景:应用配了「应用主页」后点应用只进主页,应用会话要等应用发过第一条消息才出现在成员消息列表(首次登录的员工没入口和 bot 打字)。最终采用**聊天优先布局**(企微后台不配主页、菜单放「工作台」按钮指向 `/v1/wecom/entry`,点应用直进会话),代码侧:① 回调对**未绑定成员**的 text/图片/文件消息不再静默,每条回一句绑定指引(聊天优先下新员工上来就打字,静默会被当成 bot 坏了;事件不回);② 新表 `wecom_welcomes`(0021,wecom_userid PK,未绑定成员也能去重)+ `service.maybe_send_wecom_welcome`(两层去重:已推过 / 已绑定且有 chat task;claim-first INSERT 并发安全,发送失败释放占坑),免登入口回调对首次进入的成员自动推欢迎语(菜单「工作台」/主页两条路都触发);③ 可选工具 `scripts/wecom_welcome_broadcast.py`:配主页布局下给存量成员批量"种"会话入口(`--dry-run` 先看名单;可见范围枚举 `wecom.list_visible_userids`:agent/get + 部门/标签展开);④ 品牌名单一事实源挪到 `core/branding.py`(web 层外的欢迎语也要用,app.py BRAND 引它)。RUN.md 企微段同步推荐布局。 - **07-07 / 0.45.0**:移动端显示修复 + 对外品牌名 env——① 浮层菜单(⋯ 任务菜单 `dom.js`、⚙ 媒体模型弹层 `chat.js`)原只做右对齐,手机上触发钮靠左时菜单冲出屏幕左缘盖住正文,改成"右对齐放不下→左对齐 + 双向钳视口";手机断点补:菜单项加大触控目标、模型下拉限宽 48vw、「管理」入口统一挪到 任务/对话/文件 tabs 行右侧小按钮(embed 与普通模式同款,靠 CSS order 实现不动 DOM)、header 品牌名单行省略。② 新 env `ZCBOT_BRAND_NAME`(默认「总院科研辅助助手」,zcbot 只是内部代号):`/healthz` 返 brand 字段,前端 boot 拉取覆盖页面标题/顶栏/登录卡(静态页默认值也已改为默认品牌);微信/企微测试推送、OAuth 提示页、绑定页等用户可见文案全部改用品牌名或中性"助手",不再露 zcbot。RUN.md env 段同步。 - **07-07 / 0.44.0**:企业微信应用主页免登——企微后台「应用主页」填 `/v1/wecom/entry`,成员点开应用即静默授权(snsapi_base)登进 embed 控制台。后端两端点(entry 302 授权、callback code→userid→反查绑定→签 JWT→fragment 带 token 302 进 `dev.html?embed=1&wecom=1`),UA 分支让外部浏览器退 wwlogin 扫码;前端 wecom 变体(fragment 吃 token 即清 hash,401 回 entry 静默重签,不需要 parent_origin);未绑定成员提示先在控制台绑定(不自动建号,避免重复账号)。EMBED.md §9 + RUN.md 企微段同步。 diff --git a/RUN.md b/RUN.md index 7ea6911..dcf5c50 100644 --- a/RUN.md +++ b/RUN.md @@ -98,9 +98,8 @@ - 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达。 - **入站对话(可选,要公网 HTTPS)**:企微后台「应用 → 接收消息 → 设置 API 接收」填回调 URL `<公网 base>/v1/wecom/callback` + 自动生成的 Token / EncodingAESKey → 写进 env `WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY` → 保存时企微 GET 验 URL(`/v1/wecom/callback` GET 自动回 echostr)。配好后用户在企业微信里直接给应用发消息即走 zcbot 对话(与个人微信各一张会话上下文)。agent 跑完走 message/send 主动推回(非被动同步,故无 5s 限制)。**支持文本 + 图片 + 文件**(图片/文件走 media/get 下载,落盘进会话目录 inbound/);语音/视频/位置等暂不处理;未绑定/空消息静默。 - **应用主页免登(可选,要公网 HTTPS)**:企微后台应用「应用主页」填 `<公网 base>/v1/wecom/entry`,并在**「网页授权及 JS-SDK 可信域名」**登记 zcbot 域名(与上面扫码的「企业微信授权登录」可信域名是**两项不同设置**,都要配才能两端通;域名校验文件放 `ZCBOT_WECOM_VERIFY_DIR`,同扫码路径)。之后成员在企业微信里点开应用 → snsapi_base 静默授权 → 已绑定者直接进 embed 控制台(`dev.html?embed=1&wecom=1`,token 走 URL fragment,401 自动回 entry 静默重签);**未绑定者提示先在电脑端控制台完成绑定**(不自动建号,避免重复账号)。外部浏览器打开同一 URL 会退到 wwlogin 扫码。 - - **推荐布局(聊天优先)**:企微后台**不配「应用主页」**(点应用直进会话,新员工上来就能打字),应用「自定义菜单」放「工作台」view 按钮指向 `<公网 base>/v1/wecom/entry`——免登链路照常用,控制台从菜单进。配了主页则点应用只进主页,会话要等应用发过第一条消息才出现在消息列表(冷启动)。 - - **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 text/图片/文件消息每条回一句"先去控制台绑定"(事件不回)。 - - **欢迎消息(会话冷启动兜底,0021)**:免登入口回调会给**首次进入**的成员自动推一条欢迎语(`wecom_welcomes` 表去重,每人一次;未绑定文案带绑定指引)——菜单「工作台」/主页两条路都会触发。可选工具 `scripts/wecom_welcome_broadcast.py`:给可见范围内还没会话的存量成员批量"种"会话入口(仅配了应用主页的布局需要;先 `--dry-run` 看名单,去重同一套重复跑安全;报 60011/48002 = secret 无通讯录读权限,把可见范围改成按成员勾选再跑)。需 `main.py db upgrade head` 带上 `0021`。 + - **推荐布局(聊天优先)**:企微后台**不配「应用主页」**(点应用直进会话,新员工上来就能打字),应用「自定义菜单」放「工作台」view 按钮指向 `<公网 base>/v1/wecom/entry`——免登链路照常用,控制台从菜单进。配了主页则点应用只进主页,会话要等应用发过第一条消息才出现在消息列表(冷启动),不推荐。 + - **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 text/图片/文件消息每条回一句"先去控制台绑定"(事件不回)。未绑定成员点菜单「工作台」则落在绑定提示页(不自动建号)。 - **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json` 的 `channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019`。 - **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。 - **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。 diff --git a/core/__init__.py b/core/__init__.py index b32ad9b..737ac27 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.46.0" +__version__ = "0.46.1" diff --git a/core/storage/models.py b/core/storage/models.py index 9e4e74b..ac4c7a2 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -288,19 +288,3 @@ class ChannelBinding(Base): ) -class WecomWelcome(Base): - """企业微信欢迎消息去重标记(0021)。 - - 会话冷启动问题:应用配了「应用主页」后点应用只进主页,应用会话要等应用发过 - 第一条消息才出现在成员的消息列表 —— 欢迎消息把会话入口"种"进去,本表保证 - 每人只推一次。按 wecom_userid(而非 user_id)建键:未绑定成员也要去重。 - """ - - __tablename__ = "wecom_welcomes" - - wecom_userid: Mapped[str] = mapped_column(Text, primary_key=True) - welcomed_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - - diff --git a/core/wechat/service.py b/core/wechat/service.py index ab93488..79746f4 100644 --- a/core/wechat/service.py +++ b/core/wechat/service.py @@ -17,11 +17,10 @@ from datetime import datetime, timedelta, timezone from typing import Optional from uuid import UUID -from sqlalchemy import delete, func, select, update -from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy import func, select, update from core.storage import session_scope -from core.storage.models import ChannelBinding, Message, Task, WecomWelcome +from core.storage.models import ChannelBinding, Message, Task from core.wechat import crypto from core.wechat.ilink import DEFAULT_BASE, ILinkClient @@ -280,62 +279,6 @@ def push_wecom(user_id: UUID, text: str = "", file_path: Optional[str] = None) - return PushResult(True, channel="wecom", reason="sent") -def maybe_send_wecom_welcome( - wecom_userid: str, user_id: Optional[UUID] = None, *, dry_run: bool = False -) -> str: - """首次见到该企业成员时推一条欢迎消息,把应用会话"种"进其消息列表(冷启动)。 - - 背景:应用配了「应用主页」后点应用只进主页;应用会话要等应用发过第一条消息 - 才出现在成员的消息列表 —— 没有会话入口就没法打字和 bot 聊。调用点:免登入口 - 回调(增量新成员)+ scripts/wecom_welcome_broadcast.py(存量群发),两处共用 - 本函数的去重,重复调用安全。 - - 去重两层:wecom_welcomes 表(已推过)→ 已绑定且有 chat task(已有对话,只补 - 标记不推)。真正发送前先 INSERT 占坑(ON CONFLICT DO NOTHING),并发下只有 - claim 成功的一方发;发送失败释放占坑,下次进入再试。 - 返回 reason: sent | already_welcomed | has_conversation | would_send(dry_run) - | error:<...> - """ - if not wecom_userid: - return "error: empty wecom_userid" - with session_scope() as s: - if s.get(WecomWelcome, wecom_userid) is not None: - return "already_welcomed" - if user_id is not None and get_wecom_chat_task(user_id) is not None: - if not dry_run: - with session_scope() as s: - s.execute(pg_insert(WecomWelcome) - .values(wecom_userid=wecom_userid).on_conflict_do_nothing()) - return "has_conversation" - if dry_run: - return "would_send" - with session_scope() as s: - claimed = s.execute( - pg_insert(WecomWelcome) - .values(wecom_userid=wecom_userid).on_conflict_do_nothing() - ).rowcount == 1 - if not claimed: - return "already_welcomed" - from core.branding import brand_name - from core.wechat import wecom - brand = brand_name() - if user_id is not None: - text = (f"你好,我是{brand}。之后直接回复本条消息即可随时对话;" - "在工作台点应用图标可进入控制台。") - else: - text = (f"你好,我是{brand}。你还未绑定{brand}账号:请先在电脑端浏览器登录" - f"{brand}控制台,点左栏「企业微信」渠道卡片完成绑定;" - "绑定后直接回复本条消息即可对话。") - try: - wecom.send_text(wecom_userid, text) - except Exception as e: # noqa: BLE001 —— 释放占坑,下次进入再试 - with session_scope() as s: - s.execute(delete(WecomWelcome) - .where(WecomWelcome.wecom_userid == wecom_userid)) - return f"error: {str(e)[:200]}" - return "sent" - - @dataclass class DeliveryReport: results: list[PushResult] = field(default_factory=list) diff --git a/core/wechat/wecom.py b/core/wechat/wecom.py index ca12105..a2f1837 100644 --- a/core/wechat/wecom.py +++ b/core/wechat/wecom.py @@ -186,44 +186,6 @@ def get_user_id(code: str) -> Optional[str]: return d.get("userid") # 外部联系人/非成员只有 openid → None -def list_visible_userids() -> list[str]: - """应用可见范围内全部成员 userid(agent/get → 部门/标签展开,去重保序)。 - - 群发欢迎消息用(scripts/wecom_welcome_broadcast.py)。自建应用 secret 默认可读 - 可见范围内的通讯录;任一环节 errcode 非 0 直接抛 RuntimeError(带 errcode), - 调用方决定兜底,不静默漏人。 - """ - d = _api_get("agent/get", {"agentid": _agentid()}) - if d.get("errcode", 0) != 0: - raise RuntimeError(f"agent/get 失败: {d.get('errcode')} {d.get('errmsg')}") - out: list[str] = [] - seen: set[str] = set() - - def _add(uid: Optional[str]) -> None: - if uid and uid not in seen: - seen.add(uid) - out.append(uid) - - for u in (d.get("allow_userinfos") or {}).get("user") or []: - _add(u.get("userid")) - partys: list[int] = list((d.get("allow_partys") or {}).get("partyid") or []) - for tagid in (d.get("allow_tags") or {}).get("tagid") or []: - t = _api_get("tag/get", {"tagid": tagid}) - if t.get("errcode", 0) != 0: - raise RuntimeError(f"tag/get({tagid}) 失败: {t.get('errcode')} {t.get('errmsg')}") - for u in t.get("userlist") or []: - _add(u.get("userid")) - partys.extend(t.get("partylist") or []) - for pid in partys: - r = _api_get("user/simplelist", {"department_id": pid, "fetch_child": 1}) - if r.get("errcode", 0) != 0: - raise RuntimeError( - f"user/simplelist({pid}) 失败: {r.get('errcode')} {r.get('errmsg')}") - for u in r.get("userlist") or []: - _add(u.get("userid")) - return out - - # ─────────────────────────── 发送 ─────────────────────────── def _send(touser: str, msgtype: str, body_field: dict) -> None: diff --git a/db/migrations/versions/20260707_1000_0021_wecom_welcomes.py b/db/migrations/versions/20260707_1000_0021_wecom_welcomes.py deleted file mode 100644 index 6b81fe3..0000000 --- a/db/migrations/versions/20260707_1000_0021_wecom_welcomes.py +++ /dev/null @@ -1,38 +0,0 @@ -"""wecom_welcomes 表(企业微信欢迎消息去重). - -Revision ID: 0021 -Revises: 0020 -Create Date: 2026-07-07 - -会话冷启动:应用配了「应用主页」后点应用只进主页,应用会话要等应用发过第一条 -消息才出现在成员消息列表。免登入口 / 群发脚本会给首次见到的成员推一条欢迎消息 -"种"会话入口,本表按 wecom_userid 记录"已推过",保证每人只推一次(未绑定成员 -没有 user_id,所以键是 wecom_userid 而非绑定表)。additive,无数据迁移。 -""" -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - - -revision: str = "0021" -down_revision: Union[str, None] = "0020" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "wecom_welcomes", - sa.Column("wecom_userid", sa.Text(), primary_key=True), - sa.Column( - "welcomed_at", - sa.DateTime(timezone=True), - server_default=sa.func.now(), - nullable=False, - ), - ) - - -def downgrade() -> None: - op.drop_table("wecom_welcomes") diff --git a/scripts/wecom_welcome_broadcast.py b/scripts/wecom_welcome_broadcast.py deleted file mode 100644 index 2c9b0cc..0000000 --- a/scripts/wecom_welcome_broadcast.py +++ /dev/null @@ -1,80 +0,0 @@ -"""企业微信欢迎消息一次性群发:给可见范围内还没有应用会话的成员各推一条欢迎语。 - -背景(会话冷启动):应用配了「应用主页」后,点应用只进主页;应用会话要等应用发过 -第一条消息才会出现在成员的消息列表里。本脚本给**存量成员**"种"会话入口;增量新 -成员由免登入口(/v1/wecom/entry 回调)自动补推,不需要重复跑本脚本。 - -用法(服务器上,.env 同目录,需 WECOM_CORPID/AGENTID/SECRET + ZCBOT_DB_URL 可达): - .venv/Scripts/python.exe scripts/wecom_welcome_broadcast.py --dry-run # 只看名单不发送 - .venv/Scripts/python.exe scripts/wecom_welcome_broadcast.py # 实际发送 - -去重(已推过 / 已有对话的跳过)由 service.maybe_send_wecom_welcome + wecom_welcomes -表保证,重复跑安全。ASCII 输出(Windows 控制台 GBK)。 -""" -import os -import sys - -# 仓库根加入 sys.path(脚本在 scripts/ 下,直跑时 core 在上一级) -_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if _ROOT not in sys.path: - sys.path.insert(0, _ROOT) - - -def _load_env(path: str) -> None: - """加载 .env:优先 python-dotenv,没装则手动解析(只填未设置的 key)。""" - try: - from dotenv import load_dotenv - load_dotenv(path) - return - except Exception: - pass - try: - with open(path, encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - k, v = line.split("=", 1) - os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) - except FileNotFoundError: - pass - - -_load_env(os.path.join(_ROOT, ".env")) - -from core.wechat import service, wecom # noqa: E402 —— 须在 _load_env 之后 import - - -def main() -> int: - dry_run = "--dry-run" in sys.argv[1:] - if not wecom.wecom_configured(): - print("[FAIL] WECOM_CORPID/AGENTID/SECRET 没读到(确认 .env 在仓库根、值已填)") - return 1 - - print("[step1] list visible userids (agent/get + expand) ...") - try: - userids = wecom.list_visible_userids() - except Exception as e: - print(f"[step1] FAIL: {e}") - print(" -> 60011/48002 = 应用 secret 无通讯录读权限;可在企微后台把可见" - "范围改成按成员勾选(agent/get 直接给 userid,不用展开部门)后重跑") - return 2 - print(f"[step1] OK: {len(userids)} member(s) in visible range") - - counts: dict[str, int] = {} - for u in userids: - uid = service.get_user_by_wecom_userid(u) - reason = service.maybe_send_wecom_welcome(u, uid, dry_run=dry_run) - key = reason.split(":", 1)[0] - counts[key] = counts.get(key, 0) + 1 - bound = "bound" if uid else "unbound" - print(f" [{reason}] userid={u} ({bound})") - - print("[done]", " | ".join(f"{k}={v}" for k, v in sorted(counts.items()))) - if dry_run: - print("[note] dry-run: nothing sent. Re-run without --dry-run to send.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/web/app.py b/web/app.py index b4fc2bd..5ff55f8 100644 --- a/web/app.py +++ b/web/app.py @@ -1493,12 +1493,6 @@ def create_app() -> FastAPI: if not wecom_userid: return _wecom_page("登录失败:你不是该企业成员(只支持企业内成员)。", False) uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wecom_userid) - # 首次进入的成员消息列表里还没有本应用会话(点应用只会进主页)——推一条欢迎 - # 消息把会话入口"种"进消息列表;已推过 / 已有对话的自动跳过(wecom_welcomes 去重)。 - try: - await asyncio.to_thread(_wx.maybe_send_wecom_welcome, wecom_userid, uid) - except Exception as e: # noqa: BLE001 —— 欢迎推送失败不影响登录主流程 - print(f"[wecom] welcome push failed: {type(e).__name__}: {e}") if uid is None: return _wecom_page( f"该企业微信还未绑定{BRAND}账号:请先在电脑端{BRAND}控制台登录,"