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:
parent
320f428dd3
commit
193b545b75
13
DESIGN.md
13
DESIGN.md
|
|
@ -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 统一名)。
|
||||||
|
|
||||||
**取舍(不选)**:
|
**取舍(不选)**:
|
||||||
|
|
|
||||||
|
|
@ -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
6
RUN.md
|
|
@ -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(见故障兜底)。
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}})
|
||||||
|
|
@ -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")
|
||||||
69
web/app.py
69
web/app.py
|
|
@ -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)。
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue