feat(通知): 加 Bark 格式 + 格式切换时界面提示全部跟着变

## 用户报的第二件事:格式改了,提示没跟着改
弹窗里的 URL 示例/说明、密钥标签、限流说明都写死成企业微信了——选「通用 JSON」时
占位还是 `qyapi.weixin.qq.com`,限流还写着「企微硬限 20」。现在这些文案挂在**适配器**上
(`url_hint/url_help/secret_label/secret_help/limit_help`),由接口随格式下发,切格式即时更新;
限流值在用户没手动改过时也跟着格式的推荐值走(企微 20 / 通用 JSON 60 / Bark 60)。

## 新格式:Bark(iOS 推送)
- `POST {url}`,body `{title, body, markdown, group, level[, device_key]}`
  —— `markdown` 传富文本、`body` 传纯文本兜底(老版本 App 不认 markdown 字段时也能看清)
- URL 两种填法都支持:直接粘 Bark 复制的那串(`https://api.day.app/<key>`,key 在路径里),
  或填 `https://api.day.app/push` + 把 key 填到「设备 Key」(作为 device_key 发送)
- **成功判定按格式**:Bark 是 `code==200`(企业微信是 `errcode==0`)→ 新增
  `BaseAdapter.ok_codes`,`_post_once` 用它判定。少了这一步 Bark 的每次成功都会被误判成失败
- 正文按 2048 字节截断(走 APNs,体量有限)

## 顺带
- 通用 JSON 的 `secret` 现在会作为 `X-Webhook-Secret` 请求头发出(原先填了没用)
- `_formats()` 改为序列化适配器元信息,新增格式只改一处

## 验证
- 格式切换 14 项断言全绿:三种格式的 URL 示例/密钥标签/密钥说明/限流说明/模板区/限流默认值
  全部跟着切换;下拉里 Bark 出现在已实现区
- Bark 端到端 7 项:`code=200` 判成功、`code=400` 判失败(含重试)、请求体带
  device_key/title/body/markdown/group、URL 里的 key 在接口回显里被打码
- 文档:NOTIFY.md §5 的格式表补 Bark 列(URL 怎么填 / 成功码 / 约束),并说明
  "提示文案挂在适配器上,别写死在页面里"
This commit is contained in:
2026-09-15 14:05:33 +08:00
parent 1e223e847b
commit 24ea289c15
5 changed files with 156 additions and 29 deletions
+81 -6
View File
@@ -411,19 +411,40 @@ def _apply_template(tpl, msg, hook_name=""):
# ================== 适配器 ==================
class BaseAdapter:
"""格式适配器基类(可插拔:新增格式 = 加一个类 + 注册进 ADAPTERS)。"""
"""格式适配器基类(可插拔:新增格式 = 加一个类 + 注册进 ADAPTERS)。
界面上的提示文案也挂在这里(`url_hint`/`url_help`/`secret_*`/`limit_help`)——
**格式一换,界面上的说明要跟着换**,别把企业微信的说明写死在页面上。
"""
name = "base"
label = "基类"
byte_limit = 4096 # 单个消息体的字节上限(0=不限)
limit_default = 20 # 该格式每机器人每分钟的官方上限
ok_codes = (0,) # 响应体里表示成功的错误码(None=只看 HTTP 状态)
truncated_note = "\n…(已截断)"
needs_template = False # 是否需要用户自定义请求体模板
secret_label = "签名密钥(可选)"
secret_help = ""
url_hint = "https://…"
url_help = ""
limit_help = ""
@classmethod
def render(cls, msg, hook):
"""→ requests.post 的参数 dict(url/json/headers)。"""
raise NotImplementedError
@classmethod
def meta(cls):
"""给前端用的格式元信息(下拉项 + 随格式变化的提示文案)。"""
return {"name": cls.name, "label": cls.label, "implemented": True,
"byte_limit": cls.byte_limit, "limit_default": cls.limit_default,
"needs_template": cls.needs_template,
"secret_label": cls.secret_label, "secret_help": cls.secret_help,
"url_hint": cls.url_hint, "url_help": cls.url_help,
"limit_help": cls.limit_help}
@classmethod
def _cut(cls, text):
"""按 **UTF-8 字节**截断(企业微信限 4096 字节,不是字符)。"""
@@ -449,6 +470,11 @@ class WecomAdapter(BaseAdapter):
label = "企业微信"
byte_limit = 4096
limit_default = 20
ok_codes = (0,)
url_hint = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…"
url_help = "企业微信:群设置 → 群机器人 → 添加 → 复制 Webhook 地址"
secret_help = "企业微信不需要密钥,留空即可"
limit_help = "企业微信硬限 20 条/分"
@classmethod
def render(cls, msg, hook):
@@ -471,6 +497,12 @@ class JsonAdapter(BaseAdapter):
label = "通用 JSON / Slack"
byte_limit = 0
limit_default = 60
needs_template = True
url_hint = "https://your-service/hook"
url_help = "你自己的接收端地址;Slack 填它的 Incoming Webhook URL"
secret_label = "自定义密钥(可选)"
secret_help = "填了会作为请求头 X-Webhook-Secret 一并发出,供你的服务校验"
limit_help = "由你自己的服务决定;默认 60 条/分"
DEFAULT_TEMPLATE = json.dumps({
"event": "{{event}}",
@@ -489,8 +521,10 @@ class JsonAdapter(BaseAdapter):
parsed = json.loads(body)
except ValueError as e:
raise ValueError(f"模板渲染结果不是合法 JSON: {e}")
return {"url": hook["url"], "json": parsed,
"headers": dict(hook.get("headers") or {})}
headers = dict(hook.get("headers") or {})
if hook.get("secret"):
headers.setdefault("X-Webhook-Secret", hook["secret"])
return {"url": hook["url"], "json": parsed, "headers": headers}
@classmethod
def validate_template(cls, tpl):
@@ -508,7 +542,47 @@ class JsonAdapter(BaseAdapter):
return True, ""
ADAPTERS = {WecomAdapter.name: WecomAdapter, JsonAdapter.name: JsonAdapter}
class BarkAdapter(BaseAdapter):
"""Bark(iOS 推送 App):`POST https://api.day.app/push`。
成功判定**和企业微信不一样**:Bark 返回 `{"code":200,"message":"success"}` ——
200 才是成功(企微是 errcode 0)。所以成功码挂在适配器上(ok_codes),不能写死。
设备 key 两种填法都支持:
① URL 直接粘贴 Bark 里复制的那串(`https://api.day.app/<你的key>`)——key 在路径里;
② URL 填 `https://api.day.app/push`,把 key 填到「设备 Key」里(作为 device_key 字段发)。
"""
name = "bark"
label = "Bark(iOS 推送)"
byte_limit = 2048 # 走 APNs,单条体量有限,超了截断
limit_default = 60
ok_codes = (200,)
url_hint = "https://api.day.app/push"
url_help = ("Bark App 里复制的那串地址:可直接粘 https://api.day.app/<你的key>,"
"或填 https://api.day.app/push 并把 key 填到下面的「设备 Key」")
secret_label = "设备 Key(可选)"
secret_help = "地址里已含 key 就留空;否则填 Bark App 里的那串 key(会以 device_key 发送)"
limit_help = "Bark 官方没有明确的每分钟上限;默认 60 条/分"
@classmethod
def render(cls, msg, hook):
body = {
"title": msg["title"],
# body 给纯文本(老版本 App 不认 markdown 字段时也能看清),markdown 给富文本
"body": cls._cut(msg["summary"]),
"markdown": cls._cut(msg["markdown"]),
"group": "auto_control",
"level": "active",
}
if hook.get("secret"):
body["device_key"] = hook["secret"]
return {"url": hook["url"], "json": body,
"headers": dict(hook.get("headers") or {})}
ADAPTERS = {WecomAdapter.name: WecomAdapter, JsonAdapter.name: JsonAdapter,
BarkAdapter.name: BarkAdapter}
# ================== 发送 ==================
@@ -540,8 +614,9 @@ def _post_once(adapter, msg, hook, timeout):
body = json.dumps(data, ensure_ascii=False)[:200]
except ValueError:
body = (r.text or "")[:200]
# HTTP 200 不等于成功:企微/钉钉/飞书都用 body 里的错误码
ok = r.status_code == 200 and (code in (0, None))
# HTTP 200 不等于成功:各平台都用 body 里的错误码,但**成功码不一样**
# (企业微信 errcode=0、Bark code=200),所以按适配器声明的 ok_codes 判
ok = r.status_code == 200 and (code is None or code in adapter.ok_codes)
return ok, r.status_code, code, body