feat(wecom): 企业微信渠道 B 纯推送 + OAuth 扫码绑 userid + bump 0.24.0

企业微信只做推送、不做对话(省回调 + AES + 5s ACK):无条件主动推(不挑活跃度、
无 24h 窗口),补 ClawBot 短板,定时简报必达首选。touser 经 OAuth 网页授权扫码拿成员 userid。

- core/wechat/wecom.py:access_token 2h 缓存(线程安全 + 失效重取)、OAuth getuserinfo、
  message/send text/file、media/upload、state HMAC 签名
- WeComBinding 模型 + migration 0014(0013 被 task_channel 占);service 加 wecom CRUD
  + push_wecom + send_to_user 接 wecom 一路(scheduler deliver_notify 经它自动带上)
- web/app.py 5 端点(/v1/wecom/oauth/url、callback 公开-身份从 state 验、bind GET/DELETE、test)
- 前端 rail「微信」modal 加企业微信段(wechat.js + dev.html)

激活(管理员):建自建应用 → WECOM_CORPID/AGENTID/SECRET + 配「网页授权可信域名」;
db upgrade head(带 0014)。redirect 主机取 ZCBOT_PUBLIC_BASE_URL 或请求 base。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-24 13:44:23 +08:00
parent 320f428dd3
commit 193b545b75
11 changed files with 488 additions and 10 deletions

View File

@ -717,12 +717,13 @@ create index on usage_events (model_profile, created_at);
2. `core/scheduler.py` `deliver_notify``channel=="wechat"` 分支,与 email 并列 → 定时简报**把最新产物文件直推**本人微信(取 `_newest_artifact`,≤上限 `sendmessage` 文件、超限退"点此下载"链接;**不改 job schema**——通道是 notify 字段的值)。 2. `core/scheduler.py` `deliver_notify``channel=="wechat"` 分支,与 email 并列 → 定时简报**把最新产物文件直推**本人微信(取 `_newest_artifact`,≤上限 `sendmessage` 文件、超限退"点此下载"链接;**不改 job schema**——通道是 notify 字段的值)。
3. `web/app.py`:`POST /v1/wechat/bind/qrcode`(起二维码)、`GET /v1/wechat/bind/status`(轮询绑定结果)、`DELETE /v1/wechat/bind`(解绑)、`POST /v1/wechat/test`(自检发一条);**lifespan 起入站长轮询管理器**(见上"架构");前端设置加"绑定微信"扫码 UI。 3. `web/app.py`:`POST /v1/wechat/bind/qrcode`(起二维码)、`GET /v1/wechat/bind/status`(轮询绑定结果)、`DELETE /v1/wechat/bind`(解绑)、`POST /v1/wechat/test`(自检发一条);**lifespan 起入站长轮询管理器**(见上"架构");前端设置加"绑定微信"扫码 UI。
**渠道 B:企业微信自建应用(紧随 ClawBot 实现,共用渠道抽象)** **渠道 B:企业微信自建应用(✅ 2026-06-24 实现,纯推送 / 不做对话,共用渠道抽象)**
- **应用凭据(全局 env,需管理员建应用)**:`WECOM_CORPID / WECOM_AGENTID / WECOM_SECRET`;secret 仅 host 进程读、不进沙箱(同 ClawBot / `send_email`)。host 直连 `qyapi.weixin.qq.com` - **决策:只做推送、不做入站对话**(刻意简化,省回调 + AES `WXBizMsgCrypt` + 5s ACK + agent 回推那一整套;要对话用 ClawBot)。企业微信因此≈"和邮件一个量级"的纯出站通道,其**无条件主动推**正补 ClawBot 的 24h 窗口短板,定时简报必达首选。
- **扫码绑定(OAuth 网页授权)**:网页"绑定企业微信" → `open.weixin.qq.com/connect/oauth2/authorize?...scope=snsapi_base&agentid=&state=<HMAC签+短TTL>#wechat_redirect` → 桌面出二维码扫 / 企业微信内静默 → 回调 `cgi-bin/auth/getuserinfo?code=``wecom_userid` → 写绑定。**需管理员另配「网页授权可信域名」指向 zcbot 域名**。 - **应用凭据(全局 env,需管理员建应用)**:`WECOM_CORPID / WECOM_AGENTID / WECOM_SECRET`;secret 仅 host 进程读、不进沙箱(同 ClawBot / `send_email`)。host 直连 `qyapi.weixin.qq.com`(`core/wechat/wecom.py`)。
- **推送(无条件主动推,核心优势)**:`gettoken(corpid,secret)` → `access_token`(2h 进程内缓存 + 提前刷新 + 线程安全锁)→ `message/send` 发 text/markdown/**file**(file 先 `media/upload?type=file` 换临时 `media_id`,≤20MB)。**不需用户先开口、无 24h 窗口** → 定时简报必达首选。 - **扫码绑定(OAuth 网页授权)**:网页 rail「微信」modal「绑定企业微信」→ `oauth2/authorize?...scope=snsapi_base&agentid=&state=<HMAC签+短TTL>` → 桌面出二维码扫 / 企业微信内静默 → 回调 `GET /v1/wecom/oauth/callback`(公开端点,身份从 state 验,非 JWT)→ `cgi-bin/auth/getuserinfo?code=``wecom_userid` → 写绑定。**需管理员另配「网页授权可信域名」指向 zcbot 域名**;redirect 主机取 `ZCBOT_PUBLIC_BASE_URL` 或请求 base。
- **入站对话(第二期)**:自建应用配「接收消息回调」+ AES 解密(`WXBizMsgCrypt`)+ 5s ACK + 异步跑 agent + 主动回推;复杂度高,后置。 - **推送**:`gettoken` → `access_token`(2h 缓存 + 提前刷新 + 线程安全锁 + 40014/42001 失效重取)→ `message/send` text/file(file 先 `media/upload?type=file``media_id`,≤20MB)。
- **数据**:绑定抽象加企业微信侧(`wecom_userid`;多企业留 nullable `corpid/permanent_code` 走服务商 ISV,additive);migration `0013_wecom_binding`(或并入同表多列)。 - **数据**:独立表 `wecom_bindings(user_id PK, wecom_userid 明文非密钥, status)`,migration `0014`(0013 被 task_channel 占)。多企业留 nullable `corpid/permanent_code` 走服务商 ISV(additive,YAGNI)。
- **接入**:`service.push_wecom` + `send_to_user` 加 wecom 一路(已绑则推);scheduler `deliver_notify``wechat` 通道经 `send_to_user` 自动带上企业微信。端点 `/v1/wecom/oauth/url|callback`、`/v1/wecom/bind` GET/DELETE、`/v1/wecom/test`;前端 rail modal 企业微信段。
- **触达**:仅企业成员;**品牌可自定义**(应用名/头像,区别于 ClawBot 统一名)。 - **触达**:仅企业成员;**品牌可自定义**(应用名/头像,区别于 ClawBot 统一名)。
**取舍(不选)**: **取舍(不选)**:

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-24(微信绑定 UI 并入主 SPA:左栏 rail「微信」按钮 + 扫码 modal + bump 0.22.2) 最后更新:2026-06-24(企业微信渠道 B:纯推送 + OAuth 扫码绑 userid,接入 send_to_user + bump 0.24.0)
--- ---
@ -21,6 +21,13 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-06-24 / 企业微信渠道 B:纯推送 + OAuth 扫码绑定(bump 0.24.0)
- 决策:**企业微信只做推送、不做对话**(用户拍板"和邮箱似的")——省掉入站回调 + AES + 5s ACK + agent 回推一整套;要对话走 ClawBot。企业微信的**无条件主动推**(不挑活跃度、无 24h 窗口)正补 ClawBot 短板,定时简报必达首选。
- 定位 touser:**OAuth 网页授权扫码**拿企业成员 `userid`(用户拍板,优于手填 opaque id)。前提:管理员建自建应用给 `WECOM_CORPID/AGENTID/SECRET` + 配「网页授权可信域名」。
- 文件(后端 import/编译 + 前端 node --check 自测过):`core/wechat/wecom.py`(access_token 2h 缓存+线程安全+失效重取、OAuth getuserinfo、message/send text/file、media/upload、state HMAC 签名);`WeComBinding` 模型 + migration `0014_wecom_bindings`(0013 被 task_channel 占);`service.py` 加 wecom CRUD + `push_wecom` + `send_to_user` 接 wecom 一路;`web/app.py` 5 端点(`/v1/wecom/oauth/url`、`/v1/wecom/oauth/callback` 公开-身份从 state 验、`/v1/wecom/bind` GET/DELETE、`/v1/wecom/test`);前端 rail「微信」modal 加企业微信段(`wechat.js` + dev.html)。
- env:`WECOM_CORPID/AGENTID/SECRET` + 可选 `ZCBOT_PUBLIC_BASE_URL`(OAuth redirect 主机,须在可信域名内)。**待办**:管理员就绪后端到端验(扫码绑 → test → 简报推);**回调端点须公开**(已不挂 require_user)且 redirect 主机匹配可信域名。
### 2026-06-24 / 配置 QQ/foxmail SMTP 发信 + 发件人显示名品牌化(bump 0.23.2) ### 2026-06-24 / 配置 QQ/foxmail SMTP 发信 + 发件人显示名品牌化(bump 0.23.2)
- `.env` 填入 foxmail SMTP(smtp.qq.com:25 / STARTTLS / 授权码),`send_email` tool 与定时任务 notify 兜底投递就此生效;自检发信链路通过。 - `.env` 填入 foxmail SMTP(smtp.qq.com:25 / STARTTLS / 授权码),`send_email` tool 与定时任务 notify 兜底投递就此生效;自检发信链路通过。

6
RUN.md
View File

@ -60,10 +60,16 @@
# ZCBOT_WECHAT_BOT_ENABLED=1 # 渠道总开关;开启后 lifespan 起入站管理器,用户可扫码绑定 # ZCBOT_WECHAT_BOT_ENABLED=1 # 渠道总开关;开启后 lifespan 起入站管理器,用户可扫码绑定
# ZCBOT_WECHAT_SECRET_KEY=<随机串> # 凭据(bot_token/context_token)列加密密钥;缺则退明文标记(公测兜底) # ZCBOT_WECHAT_SECRET_KEY=<随机串> # 凭据(bot_token/context_token)列加密密钥;缺则退明文标记(公测兜底)
# ZCBOT_WECHAT_BASE_URL=... # 可选,覆盖 iLink base(默 https://ilinkai.weixin.qq.com) # ZCBOT_WECHAT_BASE_URL=... # 可选,覆盖 iLink base(默 https://ilinkai.weixin.qq.com)
# 企业微信(渠道 B,纯推送,§8.7):三件套齐才挂。无条件主动推,补 ClawBot 24h 窗口短板。
# WECOM_CORPID=ww... # 企业 ID(管理员:我的企业→企业信息)
# WECOM_AGENTID=1000002 # 自建应用 AgentId
# WECOM_SECRET=... # 自建应用 Secret
# ZCBOT_PUBLIC_BASE_URL=https://zcbot.example.com # 可选,OAuth 回调主机(须在应用「网页授权可信域名」内;缺则取请求 base)
``` ```
> litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...` > litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`、`segno`、`cryptography`)。 - **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`、`segno`、`cryptography`)。
- **微信接入(ClawBot,§8.7)**:① `main.py db upgrade head` 带上 migration `0012`;② `.env``ZCBOT_WECHAT_BOT_ENABLED=1` + `ZCBOT_WECHAT_SECRET_KEY=<串>`;③ 用户登录后点**左栏 rail「微信」按钮**(`/static/wechat_bind.html` 仍保留作独立/嵌入入口)扫码绑定(需个人微信 8.0.70+ 且灰度到 ClawBot 插件)。绑定后在微信「微信 ClawBot」对话即走 zcbot;**主动推送需用户近 24h 在微信开口过一次**(冷启动/超期推不出,退邮件兜底)。 - **微信接入(ClawBot,§8.7)**:① `main.py db upgrade head` 带上 migration `0012`;② `.env``ZCBOT_WECHAT_BOT_ENABLED=1` + `ZCBOT_WECHAT_SECRET_KEY=<串>`;③ 用户登录后点**左栏 rail「微信」按钮**(`/static/wechat_bind.html` 仍保留作独立/嵌入入口)扫码绑定(需个人微信 8.0.70+ 且灰度到 ClawBot 插件)。绑定后在微信「微信 ClawBot」对话即走 zcbot;**主动推送需用户近 24h 在微信开口过一次**(冷启动/超期推不出,退邮件兜底)。
- **企业微信(渠道 B,纯推送,§8.7)**:① 管理员建自建应用 → 填 `WECOM_CORPID/AGENTID/SECRET` + 在应用配「网页授权可信域名」指向 zcbot 域名;② `main.py db upgrade head` 带 migration `0014`;③ 用户在 rail「微信」modal 点「绑定企业微信」扫码授权(OAuth 拿 userid)。之后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达;不做对话(要对话用 ClawBot)。回调端点 `/v1/wecom/oauth/callback` 公开(身份从 HMAC state 验)。
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。 - **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))"`。 - **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
- **用户管理**(`users.email/password_hash/role`,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令/角色)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。 - **用户管理**(`users.email/password_hash/role`,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令/角色)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.23.2" __version__ = "0.24.0"

View File

@ -269,3 +269,28 @@ class WeChatBotBinding(Base):
) )
class WeComBinding(Base):
"""企业微信绑定(0014,DESIGN §8.7 渠道 B,纯推送)。
一行 = 一个 zcbot 用户的企业微信成员 `userid`(OAuth 扫码拿, core/wechat/wecom.py)
应用凭据(corpid/agentid/secret)走全局 env,不入库`wecom_userid` 是企业内成员 id
非密钥 明文存推送**无条件**(不挑活跃度 24h 窗口),正补 ClawBot 短板
"""
__tablename__ = "wecom_bindings"
user_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("users.user_id", ondelete="CASCADE"),
primary_key=True,
)
wecom_userid: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(Text, nullable=False, server_default="active") # active|revoked
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View File

@ -174,6 +174,59 @@ def push_clawbot(
return PushResult(True, reason="sent") return PushResult(True, reason="sent")
# ─────────────── 企业微信(渠道 B,纯推送;无 24h 窗口约束)───────────────
def get_wecom_userid(user_id: UUID) -> Optional[str]:
from core.storage.models import WeComBinding
with session_scope() as s:
row = s.get(WeComBinding, user_id)
if row is None or row.status != "active":
return None
return row.wecom_userid
def upsert_wecom_binding(user_id: UUID, wecom_userid: str) -> None:
"""OAuth 拿到 userid 后写/更新绑定。"""
from core.storage.models import WeComBinding
now = datetime.now(timezone.utc)
with session_scope() as s:
row = s.get(WeComBinding, user_id)
if row is None:
s.add(WeComBinding(user_id=user_id, wecom_userid=wecom_userid))
else:
row.wecom_userid = wecom_userid
row.status = "active"
row.updated_at = now
def unbind_wecom(user_id: UUID) -> bool:
from core.storage.models import WeComBinding
now = datetime.now(timezone.utc)
with session_scope() as s:
row = s.get(WeComBinding, user_id)
if row is None:
return False
row.status = "revoked"
row.updated_at = now
return True
def push_wecom(user_id: UUID, text: str = "", file_path: Optional[str] = None) -> PushResult:
"""企业微信主动推一条(无条件,不挑活跃度)。"""
from core.wechat import wecom
wuid = get_wecom_userid(user_id)
if not wuid:
return PushResult(False, channel="wecom", reason="no_binding")
try:
if text:
wecom.send_text(wuid, text)
if file_path:
wecom.send_file(wuid, file_path)
except Exception as e: # noqa: BLE001
return PushResult(False, channel="wecom", reason=f"error:{type(e).__name__}")
return PushResult(True, channel="wecom", reason="sent")
@dataclass @dataclass
class DeliveryReport: class DeliveryReport:
results: list[PushResult] = field(default_factory=list) results: list[PushResult] = field(default_factory=list)
@ -190,5 +243,7 @@ def send_to_user(
report = DeliveryReport() report = DeliveryReport()
if clawbot_enabled(): if clawbot_enabled():
report.results.append(push_clawbot(user_id, text, file_path)) report.results.append(push_clawbot(user_id, text, file_path))
# TODO 渠道 B:if wecom_configured(): report.results.append(push_wecom(user_id, text, file_path)) from core.wechat.wecom import wecom_configured
if wecom_configured():
report.results.append(push_wecom(user_id, text, file_path))
return report return report

198
core/wechat/wecom.py Normal file
View File

@ -0,0 +1,198 @@
"""企业微信自建应用客户端(DESIGN §8.7 渠道 B,纯推送)。
只做**出站推送**(不做入站对话):
- `access_token`:`gettoken(corpid,secret)`,进程内缓存 ~2h线程安全errcode 失效即重取
- OAuth 网页授权:`oauth_authorize_url()` 造扫码链接;`get_user_id(code)` 拿成员 userid
(绑定用,一次性)需管理员在应用配网页授权可信域名
- 发送:`send_text / send_markdown / send_file`(file `media/upload` media_id,20MB)
- `state` HMAC 签名( user_id + TTL, CSRF):回调无 JWT,用户身份从 state
凭据(secret)只在 host 进程读,绝不进沙箱 / run_python( ClawBot / send_email,§3.4)
阻塞 IO(httpx 同步),调用方放 to_thread / executor
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import os
import threading
import time
from pathlib import Path
from typing import Optional
import httpx
QYAPI = "https://qyapi.weixin.qq.com/cgi-bin"
OAUTH_AUTHORIZE = "https://open.weixin.qq.com/connect/oauth2/authorize"
MAX_FILE_BYTES = 20 * 1024 * 1024
# access_token 进程内缓存
_tok_lock = threading.Lock()
_tok_val: Optional[str] = None
_tok_exp: float = 0.0
def wecom_configured() -> bool:
"""三件套齐才算配好(沿用「有 key 才挂」§3.4)。"""
return bool(
os.getenv("WECOM_CORPID", "").strip()
and os.getenv("WECOM_AGENTID", "").strip()
and os.getenv("WECOM_SECRET", "").strip()
)
def _corpid() -> str:
return os.getenv("WECOM_CORPID", "").strip()
def _agentid() -> str:
return os.getenv("WECOM_AGENTID", "").strip()
def _secret() -> str:
return os.getenv("WECOM_SECRET", "").strip()
def _state_secret() -> bytes:
# OAuth state 签名密钥:复用凭据加密 key,退 JWT_SECRET
key = (os.getenv("ZCBOT_WECHAT_SECRET_KEY", "").strip()
or os.getenv("JWT_SECRET", "").strip() or "zcbot-wecom")
return key.encode("utf-8")
# ─────────────────────────── access_token ───────────────────────────
def get_access_token(*, force: bool = False) -> str:
"""缓存的 app access_token;过期/force 时重取。线程安全。"""
global _tok_val, _tok_exp
with _tok_lock:
if not force and _tok_val and time.time() < _tok_exp:
return _tok_val
with httpx.Client(timeout=15) as c:
r = c.get(f"{QYAPI}/gettoken",
params={"corpid": _corpid(), "corpsecret": _secret()})
r.raise_for_status()
d = r.json()
if d.get("errcode", 0) != 0 or not d.get("access_token"):
raise RuntimeError(f"gettoken 失败: {d.get('errcode')} {d.get('errmsg')}")
_tok_val = d["access_token"]
_tok_exp = time.time() + int(d.get("expires_in", 7200)) - 300 # 提前 5min 续
return _tok_val
def _api_get(path: str, params: dict) -> dict:
"""带 access_token 的 GET;40014/42001(token 失效)自动重取一次。"""
for attempt in (1, 2):
tok = get_access_token(force=(attempt == 2))
with httpx.Client(timeout=15) as c:
r = c.get(f"{QYAPI}/{path}", params={"access_token": tok, **params})
r.raise_for_status()
d = r.json()
if d.get("errcode") in (40014, 42001) and attempt == 1:
continue
return d
return d
def _api_post(path: str, json_body: dict) -> dict:
for attempt in (1, 2):
tok = get_access_token(force=(attempt == 2))
with httpx.Client(timeout=20) as c:
r = c.post(f"{QYAPI}/{path}", params={"access_token": tok}, json=json_body)
r.raise_for_status()
d = r.json()
if d.get("errcode") in (40014, 42001) and attempt == 1:
continue
return d
return d
# ─────────────────────────── OAuth 绑定 ───────────────────────────
def sign_state(user_id: str, *, ttl: int = 600) -> str:
"""state = base64(user_id.exp).hmac —— 绑 user_id + 短 TTL,防 CSRF。"""
exp = int(time.time()) + ttl
payload = f"{user_id}.{exp}"
sig = hmac.new(_state_secret(), payload.encode(), hashlib.sha256).hexdigest()[:32]
raw = f"{payload}.{sig}"
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
def verify_state(state: str) -> Optional[str]:
"""校验 state,返回 user_id;失败/过期返回 None。"""
try:
pad = "=" * (-len(state) % 4)
raw = base64.urlsafe_b64decode(state + pad).decode()
user_id, exp_s, sig = raw.rsplit(".", 2)
payload = f"{user_id}.{exp_s}"
good = hmac.new(_state_secret(), payload.encode(), hashlib.sha256).hexdigest()[:32]
if not hmac.compare_digest(sig, good):
return None
if int(exp_s) < int(time.time()):
return None
return user_id
except Exception:
return None
def oauth_authorize_url(redirect_uri: str, state: str) -> str:
"""造网页授权链接。桌面浏览器打开 = 出二维码扫;企业微信内 = 静默授权。"""
from urllib.parse import quote
return (
f"{OAUTH_AUTHORIZE}?appid={_corpid()}"
f"&redirect_uri={quote(redirect_uri, safe='')}"
f"&response_type=code&scope=snsapi_base&agentid={_agentid()}"
f"&state={quote(state, safe='')}#wechat_redirect"
)
def get_user_id(code: str) -> Optional[str]:
"""OAuth 回调用 code 换企业成员 userid(非成员返回 None)。"""
d = _api_get("auth/getuserinfo", {"code": code})
if d.get("errcode", 0) != 0:
raise RuntimeError(f"getuserinfo 失败: {d.get('errcode')} {d.get('errmsg')}")
return d.get("userid") # 外部联系人/非成员只有 openid → None
# ─────────────────────────── 发送 ───────────────────────────
def _send(touser: str, msgtype: str, body_field: dict) -> None:
payload = {"touser": touser, "msgtype": msgtype, "agentid": _agentid(), **body_field}
d = _api_post("message/send", payload)
if d.get("errcode", 0) != 0:
raise RuntimeError(f"message/send 失败: {d.get('errcode')} {d.get('errmsg')}")
def send_text(touser: str, content: str) -> None:
_send(touser, "text", {"text": {"content": content or ""}})
def send_markdown(touser: str, content: str) -> None:
_send(touser, "markdown", {"markdown": {"content": content or ""}})
def upload_media(file_path: str | os.PathLike, *, media_type: str = "file") -> str:
"""上传临时素材(3 天有效)→ media_id。"""
p = Path(file_path)
if p.stat().st_size > MAX_FILE_BYTES:
raise ValueError(f"文件超过 {MAX_FILE_BYTES // (1024*1024)}MB 上限")
for attempt in (1, 2):
tok = get_access_token(force=(attempt == 2))
with httpx.Client(timeout=30) as c, open(p, "rb") as f:
r = c.post(f"{QYAPI}/media/upload",
params={"access_token": tok, "type": media_type},
files={"media": (p.name, f)})
r.raise_for_status()
d = r.json()
if d.get("errcode") in (40014, 42001) and attempt == 1:
continue
break
if d.get("errcode", 0) != 0 or not d.get("media_id"):
raise RuntimeError(f"media/upload 失败: {d.get('errcode')} {d.get('errmsg')}")
return d["media_id"]
def send_file(touser: str, file_path: str | os.PathLike) -> None:
media_id = upload_media(file_path, media_type="file")
_send(touser, "file", {"file": {"media_id": media_id}})

View File

@ -0,0 +1,44 @@
"""wecom_bindings 表(企业微信绑定,DESIGN §8.7 渠道 B,纯推送).
Revision ID: 0014
Revises: 0013
Create Date: 2026-06-24
新增独立表 wecom_bindings 不碰现有 schema(公测兼容)一行 = 一个用户的企业微信成员
userid(OAuth 扫码拿)应用凭据走全局 env不入库;userid 非密钥明文存 DESIGN §8.7
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
revision: str = "0014"
down_revision: Union[str, None] = "0013"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"wecom_bindings",
sa.Column(
"user_id", PG_UUID(as_uuid=True),
sa.ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True,
),
sa.Column("wecom_userid", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
)
def downgrade() -> None:
op.drop_table("wecom_bindings")

View File

@ -28,7 +28,7 @@ try:
except ImportError: # pragma: no cover - Windows except ImportError: # pragma: no cover - Windows
resource = None resource = None
from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
@ -1150,6 +1150,73 @@ def create_app() -> FastAPI:
) )
return {"ok": res.ok, "reason": res.reason} return {"ok": res.ok, "reason": res.reason}
# ───────────── 企业微信接入(渠道 B,纯推送,§8.7)─────────────
@app.get("/v1/wecom/oauth/url", tags=["wecom"])
def wecom_oauth_url(request: Request, user_id: UUID = Depends(require_user)):
"""生成企业微信 OAuth 网页授权链接(前端打开 → 扫码授权)。回调用 state 带回身份。
redirect 主机须在应用网页授权可信域名;默认取 ZCBOT_PUBLIC_BASE_URL 或请求 base"""
from core.wechat import wecom
if not wecom.wecom_configured():
raise HTTPException(400, "企业微信未配置(需 WECOM_CORPID/AGENTID/SECRET)")
base = (os.getenv("ZCBOT_PUBLIC_BASE_URL", "").strip()
or str(request.base_url).rstrip("/"))
redirect_uri = f"{base}/v1/wecom/oauth/callback"
state = wecom.sign_state(str(user_id))
return {"authorize_url": wecom.oauth_authorize_url(redirect_uri, state)}
@app.get("/v1/wecom/oauth/callback", include_in_schema=False)
async def wecom_oauth_callback(code: str = "", state: str = ""):
"""企业微信授权后浏览器回跳到这(无 JWT;身份从 state 验)。换 userid → 写绑定 → 回提示页。"""
from fastapi.responses import HTMLResponse
from core.wechat import service as _wx
from core.wechat import wecom
def _page(msg: str, ok: bool) -> HTMLResponse:
color = "#1a7f37" if ok else "#cf222e"
return HTMLResponse(
f"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>"
f"<div style='font:16px system-ui;max-width:420px;margin:80px auto;text-align:center;color:{color}'>"
f"{msg}</div><div style='text-align:center;color:#888;font:13px system-ui'>可关闭本页返回 zcbot</div>"
)
uid = wecom.verify_state(state)
if not uid:
return _page("绑定失败:授权已过期或无效,请回 zcbot 重试。", False)
if not code:
return _page("绑定失败:未拿到授权码。", False)
try:
wecom_userid = await asyncio.to_thread(wecom.get_user_id, code)
except Exception as e:
return _page(f"绑定失败:{type(e).__name__}", False)
if not wecom_userid:
return _page("绑定失败:你不是该企业成员(只支持企业内成员)。", False)
await asyncio.to_thread(_wx.upsert_wecom_binding, UUID(uid), wecom_userid)
return _page("✅ 企业微信绑定成功!以后简报 / 结果会推到你的企业微信。", True)
@app.get("/v1/wecom/bind", tags=["wecom"])
def wecom_bind_get(user_id: UUID = Depends(require_user)):
"""当前用户企业微信绑定状态。"""
from core.wechat import service as _wx
from core.wechat import wecom
wuid = _wx.get_wecom_userid(user_id)
return {"configured": wecom.wecom_configured(), "bound": bool(wuid), "wecom_userid": wuid}
@app.delete("/v1/wecom/bind", status_code=204, tags=["wecom"])
def wecom_unbind(user_id: UUID = Depends(require_user)):
from core.wechat import service as _wx
_wx.unbind_wecom(user_id)
return
@app.post("/v1/wecom/test", tags=["wecom"])
async def wecom_test(user_id: UUID = Depends(require_user)):
"""自检:给已绑用户推一条企业微信测试消息(无 24h 窗口约束)。"""
from core.wechat import service as _wx
res = await asyncio.to_thread(
_wx.push_wecom, user_id, "zcbot 测试消息:企业微信绑定成功,这条来自你的 zcbot。"
)
return {"ok": res.ok, "reason": res.reason}
@app.get("/v1/models", tags=["misc"]) @app.get("/v1/models", tags=["misc"])
def list_models(user_id: UUID = Depends(require_user)): def list_models(user_id: UUID = Depends(require_user)):
"""列出所有可用 LLM 模型(扫 config/models/*.yaml)。 """列出所有可用 LLM 模型(扫 config/models/*.yaml)。

View File

@ -1328,6 +1328,15 @@
<li>需个人微信 8.0.70+ 且已灰度到「ClawBot」插件(设置→插件)。</li> <li>需个人微信 8.0.70+ 且已灰度到「ClawBot」插件(设置→插件)。</li>
<li>绑定后先在微信给「微信 ClawBot」发句话,主动推送才开启(24h 窗口)。</li> <li>绑定后先在微信给「微信 ClawBot」发句话,主动推送才开启(24h 窗口)。</li>
</ul> </ul>
<hr style="border:none;border-top:1px solid var(--line,#d0d7de);margin:18px 0;">
<div style="font-weight:600;font-size:14px;margin-bottom:6px;">企业微信(仅推送)</div>
<div class="muted" style="font-size:12px;margin-bottom:10px;">无条件主动推(不挑活跃度、无 24h 窗口),适合定时简报必达。扫码授权一次拿成员身份。</div>
<div id="wc-state" class="wx-status wait">加载中…</div>
<div class="wx-acts">
<button id="wc-bind" class="small">绑定企业微信</button>
<button id="wc-test" class="small" disabled>发送测试</button>
<button id="wc-unbind" class="small danger" disabled>解绑</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -38,6 +38,56 @@ function openWechatModal() {
$("wechat-modal").classList.add("show"); $("wechat-modal").classList.add("show");
$("wx-qrbox").hidden = true; $("wx-qrbox").hidden = true;
refresh(); refresh();
refreshWecom();
}
// ───── 企业微信(仅推送)─────
function setWc(cls, msg) {
const el = $("wc-state");
el.className = "wx-status " + cls;
el.textContent = msg;
}
async function refreshWecom() {
try {
const b = await api("GET", "/v1/wecom/bind");
if (!b.configured) {
setWc("wait", "未配置:管理员需建自建应用 + 配 WECOM_CORPID/AGENTID/SECRET。");
$("wc-bind").disabled = true; $("wc-test").disabled = true; $("wc-unbind").disabled = true;
return;
}
$("wc-bind").disabled = false;
if (b.bound) {
setWc("ok", "已绑定 · 成员 " + (b.wecom_userid || ""));
$("wc-test").disabled = false; $("wc-unbind").disabled = false;
} else {
setWc("wait", "未绑定。点「绑定企业微信」扫码授权。");
$("wc-test").disabled = true; $("wc-unbind").disabled = true;
}
} catch (e) {
setWc("err", "查询失败: " + e.message);
}
}
async function wecomBind() {
$("wc-bind").disabled = true;
try {
const r = await api("GET", "/v1/wecom/oauth/url");
window.open(r.authorize_url, "_blank");
setWc("wait", "已打开授权页…扫码 / 确认后会自动刷新(也可手动重开本面板)。");
for (let i = 0; i < 30; i++) { // 轮询绑定结果 ~60s
await sleep(2000);
try {
const b = await api("GET", "/v1/wecom/bind");
if (b.bound) { await refreshWecom(); return; }
} catch (e) { /* 继续轮询 */ }
}
await refreshWecom();
} catch (e) {
setWc("err", "绑定出错: " + e.message);
} finally {
$("wc-bind").disabled = false;
}
} }
export function closeWechatModal() { export function closeWechatModal() {
_polling = false; _polling = false;
@ -107,3 +157,19 @@ $("wx-test").onclick = async () => {
setState("err", "测试失败: " + e.message); setState("err", "测试失败: " + e.message);
} finally { $("wx-test").disabled = false; } } finally { $("wx-test").disabled = false; }
}; };
$("wc-bind").onclick = wecomBind;
$("wc-unbind").onclick = async () => {
if (!confirm("确定解绑企业微信?")) return;
try { await api("DELETE", "/v1/wecom/bind"); await refreshWecom(); }
catch (e) { setWc("err", "解绑失败: " + e.message); }
};
$("wc-test").onclick = async () => {
$("wc-test").disabled = true;
try {
const r = await api("POST", "/v1/wecom/test");
if (r.ok) setWc("ok", "测试消息已发送,去企业微信查收。");
else setWc("err", "推送未送达(" + r.reason + ")。");
} catch (e) { setWc("err", "测试失败: " + e.message); }
finally { $("wc-test").disabled = false; }
};