feat: 通知发送层切换到群机器人 webhook, 排版精简
- wecom.py 重写: HTTP POST webhook(替代 aibot WebSocket 按需连接), 接口 send_markdown/send_text 不变, Secret Block wecom-webhook 存 URL - webhook markdown 支持标题/引用/font 三色, 报告改为 '## 标题 + 单行结论 + 引用详情' 紧凑排版, 结论行用 info/warning 色 - alert_failure 改标题式排版; device_check 结论用 font 色, 去掉负载行 - 移除 wecom-aibot-sdk 依赖与 wecom_capture_chatid.py(镜像下次构建生效, 无需立即重建)
This commit is contained in:
+52
-56
@@ -1,58 +1,44 @@
|
||||
"""企业微信智能机器人通知层(wecom-aibot-sdk,WebSocket 按需连接)。
|
||||
"""企微群机器人通知层(消息推送 webhook,HTTP POST,无状态)。
|
||||
|
||||
连接模式:每次发消息 建立→等认证→发送→断开,无状态,契合 flow run 级部署模型。
|
||||
凭据存 Prefect Secret Block `wecom-aibot-credentials`,JSON:
|
||||
{"bot_id": "...", "secret": "...", "chat_id": "..."}
|
||||
chat_id 为目标群会话 ID(用 scripts/wecom_capture_chatid.py 一次性捕获)。
|
||||
文档:https://developer.work.weixin.qq.com/document/path/99110
|
||||
凭据存 Prefect Secret Block `wecom-webhook`(值为完整 webhook URL,含 key);
|
||||
本地调试可用环境变量 WECOM_WEBHOOK_URL 覆盖。
|
||||
markdown 支持:1-6 级标题/加粗/链接/引用/<font color="info|comment|warning">(不支持表格);
|
||||
单条 ≤4096 字节,超长自动按行分段;频率限制 20 条/分钟。
|
||||
|
||||
所有函数容错——发送失败只 print 警告并返回 False,绝不中断 flow(对齐 artifacts.py 风格)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
from prefect.blocks.system import Secret
|
||||
import httpx
|
||||
|
||||
SECRET_BLOCK = "wecom-aibot-credentials"
|
||||
MAX_CONTENT_BYTES = 4000 # WeCom markdown 单条约 4096 字节上限,留余量
|
||||
WEBHOOK_BLOCK = "wecom-webhook"
|
||||
MAX_MARKDOWN_BYTES = 4000 # 官方上限 4096,留余量
|
||||
MAX_TEXT_BYTES = 2000 # text 类型上限 2048,留余量
|
||||
|
||||
|
||||
def load_credentials() -> dict[str, str]:
|
||||
value = Secret.load(SECRET_BLOCK).get()
|
||||
if isinstance(value, str):
|
||||
value = json.loads(value)
|
||||
missing = [k for k in ("bot_id", "secret", "chat_id") if not value.get(k)]
|
||||
if missing:
|
||||
raise ValueError(f"Secret Block {SECRET_BLOCK} 缺少字段: {missing}")
|
||||
return value
|
||||
def load_webhook_url() -> str:
|
||||
url = os.environ.get("WECOM_WEBHOOK_URL", "").strip()
|
||||
if not url:
|
||||
from prefect.blocks.system import Secret
|
||||
|
||||
url = (Secret.load(WEBHOOK_BLOCK).get() or "").strip()
|
||||
if not url:
|
||||
raise ValueError(f"webhook 地址为空(Secret Block {WEBHOOK_BLOCK} 或 WECOM_WEBHOOK_URL)")
|
||||
return url
|
||||
|
||||
|
||||
async def _send_bodies(creds: dict[str, str], bodies: list[dict]) -> None:
|
||||
from wecom_aibot_sdk import WSClient
|
||||
|
||||
ws = WSClient(bot_id=creds["bot_id"], secret=creds["secret"])
|
||||
auth_ok = asyncio.Event()
|
||||
ws.on("authenticated", lambda: auth_ok.set())
|
||||
await asyncio.wait_for(ws.connect(), timeout=15)
|
||||
try:
|
||||
# connect() 只等到认证帧发出;认证结果由 authenticated 事件确认
|
||||
await asyncio.wait_for(auth_ok.wait(), timeout=10)
|
||||
for body in bodies:
|
||||
await ws.send_message(creds["chat_id"], body)
|
||||
finally:
|
||||
await ws.disconnect()
|
||||
|
||||
|
||||
def _split_markdown(content: str) -> list[str]:
|
||||
"""按字节预算切段,优先在换行处切,避免拆开表格行。"""
|
||||
if len(content.encode("utf-8")) <= MAX_CONTENT_BYTES:
|
||||
def _split_bytes(content: str, limit: int) -> list[str]:
|
||||
"""按字节预算切段,优先在换行处切。"""
|
||||
if len(content.encode("utf-8")) <= limit:
|
||||
return [content]
|
||||
parts: list[str] = []
|
||||
cur = ""
|
||||
for line in content.splitlines(keepends=True):
|
||||
if cur and len((cur + line).encode("utf-8")) > MAX_CONTENT_BYTES:
|
||||
if cur and len((cur + line).encode("utf-8")) > limit:
|
||||
parts.append(cur)
|
||||
cur = line
|
||||
else:
|
||||
@@ -62,26 +48,36 @@ def _split_markdown(content: str) -> list[str]:
|
||||
return parts
|
||||
|
||||
|
||||
def send_markdown(content: str) -> bool:
|
||||
"""发送 markdown 消息到目标群(超长自动分段)。失败返回 False,不抛异常。"""
|
||||
def _post(url: str, payload: dict) -> bool:
|
||||
resp = httpx.post(url, json=payload, timeout=15)
|
||||
try:
|
||||
creds = load_credentials()
|
||||
parts = _split_markdown(content)
|
||||
asyncio.run(_send_bodies(creds, [{"msgtype": "markdown", "markdown": {"content": p}} for p in parts]))
|
||||
if len(parts) > 1:
|
||||
print(f"[wecom] markdown 已发送 ({len(parts)} 段)")
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f"[wecom] markdown 发送失败: {err}")
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
data = {}
|
||||
if resp.status_code != 200 or data.get("errcode") != 0:
|
||||
print(f"[wecom] 发送失败: HTTP {resp.status_code} {data or resp.text[:100]}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _send(content: str, msgtype: str, limit: int) -> bool:
|
||||
try:
|
||||
url = load_webhook_url()
|
||||
parts = _split_bytes(content, limit)
|
||||
ok = all(_post(url, {"msgtype": msgtype, msgtype: {"content": part}}) for part in parts)
|
||||
if ok and len(parts) > 1:
|
||||
print(f"[wecom] {msgtype} 已发送 ({len(parts)} 段)")
|
||||
return ok
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f"[wecom] {msgtype} 发送失败: {err}")
|
||||
return False
|
||||
|
||||
|
||||
def send_markdown(content: str) -> bool:
|
||||
"""发送 markdown 到群(超长自动分段)。失败返回 False,不抛异常。"""
|
||||
return _send(content, "markdown", MAX_MARKDOWN_BYTES)
|
||||
|
||||
|
||||
def send_text(content: str) -> bool:
|
||||
"""发送纯文本消息到目标群。失败返回 False,不抛异常。"""
|
||||
try:
|
||||
creds = load_credentials()
|
||||
asyncio.run(_send_bodies(creds, [{"msgtype": "text", "text": {"content": content}}]))
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f"[wecom] text 发送失败: {err}")
|
||||
return False
|
||||
"""发送纯文本到群。失败返回 False,不抛异常。"""
|
||||
return _send(content, "text", MAX_TEXT_BYTES)
|
||||
|
||||
Reference in New Issue
Block a user