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
+1 -1
View File
@@ -53,7 +53,7 @@
| **AI 控制台** | 用自然语言驱动 AI 操作指定设备(MCP 工具 + 截图),流式输出、Markdown 渲染、推理链折叠、token 统计;成功操作自动沉淀「经验库 / 动作库」并在相似任务中召回 | | **AI 控制台** | 用自然语言驱动 AI 操作指定设备(MCP 工具 + 截图),流式输出、Markdown 渲染、推理链折叠、token 统计;成功操作自动沉淀「经验库 / 动作库」并在相似任务中召回 |
| **MCP 接入** | 20 个 `de_*` 工具,把手机控制开放给外部 AI;写操作有开关、设备忙时拒绝、全量审计 | | **MCP 接入** | 20 个 `de_*` 工具,把手机控制开放给外部 AI;写操作有开关、设备忙时拒绝、全量审计 |
| **备份导出/导入** | 一键导出 zip(库快照 + manifest + 可选 APK),导入前校验预览、自动预备份、重启生效 | | **备份导出/导入** | 一键导出 zip(库快照 + manifest + 可选 APK),导入前校验预览、自动预备份、重启生效 |
| **通知 / Webhook** | 任务成功失败、设备上下线、安装完成、备份恢复等事件推送到企业微信/自建服务;多条 webhook 各自订阅;聚合+限流防刷屏(见 [doc/NOTIFY.md](doc/NOTIFY.md)) | | **通知 / Webhook** | 任务成功失败、设备上下线、安装完成、备份恢复等事件推送到企业微信 / Bark / 自建服务;多条 webhook 各自订阅;聚合+限流防刷屏(见 [doc/NOTIFY.md](doc/NOTIFY.md)) |
--- ---
+81 -6
View File
@@ -411,19 +411,40 @@ def _apply_template(tpl, msg, hook_name=""):
# ================== 适配器 ================== # ================== 适配器 ==================
class BaseAdapter: class BaseAdapter:
"""格式适配器基类(可插拔:新增格式 = 加一个类 + 注册进 ADAPTERS)。""" """格式适配器基类(可插拔:新增格式 = 加一个类 + 注册进 ADAPTERS)。
界面上的提示文案也挂在这里(`url_hint`/`url_help`/`secret_*`/`limit_help`)——
**格式一换,界面上的说明要跟着换**,别把企业微信的说明写死在页面上。
"""
name = "base" name = "base"
label = "基类" label = "基类"
byte_limit = 4096 # 单个消息体的字节上限(0=不限) byte_limit = 4096 # 单个消息体的字节上限(0=不限)
limit_default = 20 # 该格式每机器人每分钟的官方上限 limit_default = 20 # 该格式每机器人每分钟的官方上限
ok_codes = (0,) # 响应体里表示成功的错误码(None=只看 HTTP 状态)
truncated_note = "\n…(已截断)" truncated_note = "\n…(已截断)"
needs_template = False # 是否需要用户自定义请求体模板
secret_label = "签名密钥(可选)"
secret_help = ""
url_hint = "https://…"
url_help = ""
limit_help = ""
@classmethod @classmethod
def render(cls, msg, hook): def render(cls, msg, hook):
"""→ requests.post 的参数 dict(url/json/headers)。""" """→ requests.post 的参数 dict(url/json/headers)。"""
raise NotImplementedError 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 @classmethod
def _cut(cls, text): def _cut(cls, text):
"""按 **UTF-8 字节**截断(企业微信限 4096 字节,不是字符)。""" """按 **UTF-8 字节**截断(企业微信限 4096 字节,不是字符)。"""
@@ -449,6 +470,11 @@ class WecomAdapter(BaseAdapter):
label = "企业微信" label = "企业微信"
byte_limit = 4096 byte_limit = 4096
limit_default = 20 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 @classmethod
def render(cls, msg, hook): def render(cls, msg, hook):
@@ -471,6 +497,12 @@ class JsonAdapter(BaseAdapter):
label = "通用 JSON / Slack" label = "通用 JSON / Slack"
byte_limit = 0 byte_limit = 0
limit_default = 60 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({ DEFAULT_TEMPLATE = json.dumps({
"event": "{{event}}", "event": "{{event}}",
@@ -489,8 +521,10 @@ class JsonAdapter(BaseAdapter):
parsed = json.loads(body) parsed = json.loads(body)
except ValueError as e: except ValueError as e:
raise ValueError(f"模板渲染结果不是合法 JSON: {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 @classmethod
def validate_template(cls, tpl): def validate_template(cls, tpl):
@@ -508,7 +542,47 @@ class JsonAdapter(BaseAdapter):
return True, "" 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] body = json.dumps(data, ensure_ascii=False)[:200]
except ValueError: except ValueError:
body = (r.text or "")[:200] body = (r.text or "")[:200]
# HTTP 200 不等于成功:企微/钉钉/飞书都用 body 里的错误码 # HTTP 200 不等于成功:各平台都用 body 里的错误码,但**成功码不一样**
ok = r.status_code == 200 and (code in (0, None)) # (企业微信 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 return ok, r.status_code, code, body
+12 -5
View File
@@ -95,10 +95,13 @@ daemon 线程。所以:
## 5. 推送格式 ## 5. 推送格式
| 格式 | 请求体 | 关键约束 | | 格式 | 请求体 | URL 怎么填 | 成功判定 / 关键约束 |
|---|---|---| |---|---|---|---|
| `wecom` 企业微信 | `{"msgtype":"markdown","markdown":{"content":"…"}}` | content **≤4096 字节**(超了按字节截断并加「…(已截断)」,不会截出半个汉字);**每机器人每分钟 20 条**,超限 `errcode 45009`;成功必须 `errcode==0` | | `wecom` 企业微信 | `{"msgtype":"markdown","markdown":{"content":"…"}}` | 群机器人 → 复制的 Webhook 地址(含 `?key=`) | **`errcode==0`**;content **≤4096 字节**(按字节截断、不会截出半个汉字);**每机器人每分钟 20 条**,超限 `45009` |
| `json` 通用 / Slack | 由 `body_template` 决定 | 保存前**干跑校验**(渲染后必须是合法 JSON) | | `json` 通用 / Slack | 由 `body_template` 决定 | 你自己的接收端;Slack 填它的 Incoming Webhook URL | HTTP 200(响应体里有 `code`/`errcode` 时必须为 0);模板保存前**干跑校验**;`secret` 会作为 `X-Webhook-Secret` 头发出 |
| `bark` iOS 推送 | `{"title","body","markdown","group","level"[,"device_key"]}` | Bark App 里复制的那串(`https://api.day.app/<key>`)**或** `https://api.day.app/push` + 设备 Key 填到「设备 Key」 | **`code==200`**(注意和企业微信不一样!);走 APNs,正文按 2048 字节截断;`markdown` 字段传富文本、`body` 传纯文本兜底 |
> 成功码**按格式**判定(企微 0、Bark 200),写在适配器的 `ok_codes` 上——加新格式时别忘了一起定。
**字段一律渲染成 `> **字段**:值` 引用行,不用 Markdown 表格**——企业微信/钉钉的 markdown **字段一律渲染成 `> **字段**:值` 引用行,不用 Markdown 表格**——企业微信/钉钉的 markdown
子集不支持表格,表格会原样吐出来。 子集不支持表格,表格会原样吐出来。
@@ -108,8 +111,12 @@ daemon 线程。所以:
替换值按 JSON 字符串转义,所以标题里带引号/换行也不会打坏请求体。Slack 直接写 替换值按 JSON 字符串转义,所以标题里带引号/换行也不会打坏请求体。Slack 直接写
`{"text":"{{markdown}}"}` 就行。 `{"text":"{{markdown}}"}` 就行。
**格式相关的提示文案(URL 示例/说明、密钥叫什么、限流上限)都挂在适配器上**
(`BaseAdapter.url_hint/url_help/secret_label/secret_help/limit_help`),界面按当前格式渲染、
切格式即时更新——新增格式时把这些一起填上,别把某个平台的说明写死在页面上。
`dingtalk`/`feishu` 在界面上是**置灰**的:适配器留了插槽(`BaseAdapter._sign/_auth_fields/ `dingtalk`/`feishu` 在界面上是**置灰**的:适配器留了插槽(`BaseAdapter._sign/_auth_fields/
_byte_limit`),要接的时候加一个类 + 注册进 `ADAPTERS` 即可;急用可以拿通用 JSON 手搓 _byte_limit/_ok_codes`),要接的时候加一个类 + 注册进 `ADAPTERS` 即可;急用可以拿通用 JSON 手搓
(飞书的 text 格式就是 `{"msg_type":"text","content":{"text":"{{markdown}}"}}`)。 (飞书的 text 格式就是 `{"msg_type":"text","content":{"text":"{{markdown}}"}}`)。
--- ---
+52 -11
View File
@@ -7,6 +7,8 @@ let _notifyFormats = [];
let _notifySettings = {}; let _notifySettings = {};
let _notifyEditingId = null; // null=新建 let _notifyEditingId = null; // null=新建
let _notifyPatterns = []; // 编辑中的通配订阅(如 task.*) let _notifyPatterns = []; // 编辑中的通配订阅(如 task.*)
let _notifyPrevFormat = ''; // 上一个选中格式(切换时判断限流值要不要跟着走)
let _notifyRateTouched = false; // 用户是否手动改过限流值(没改过就跟随格式的推荐值)
// ================== 加载与渲染 ================== // ================== 加载与渲染 ==================
function loadNotifyPanel(){ function loadNotifyPanel(){
@@ -119,20 +121,25 @@ function openNotifyModal(id){
+ '<select id="nf-format" class="form-control" onchange="_notifyFormatChanged()">' + fmtOpts + '</select></div>' + '<select id="nf-format" class="form-control" onchange="_notifyFormatChanged()">' + fmtOpts + '</select></div>'
+ '</div>' + '</div>'
+ '<div class="form-group"><label>Webhook URL</label>' + '<div class="form-group"><label>Webhook URL</label>'
+ '<input id="nf-url" class="form-control" placeholder="' + (h.url ? esc(h.url) : 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…') + '" ' // 占位与说明都由**当前格式**决定(见 _notifyFormatChanged),切换格式会跟着变
+ 'value="' + (id ? '' : '') + '">' + '<input id="nf-url" class="form-control" value="">'
+ '<div class="help">' + (h.url ? '已配置:' + esc(h.url) + '(留空=不修改)' : '企业微信:群机器人 → 复制 Webhook 地址') + '</div></div>' + '<div class="help" id="nf-url-help"></div></div>'
+ '<div class="form-row">' + '<div class="form-row">'
+ '<div class="form-group"><label>签名密钥(可选)</label>' + '<div class="form-group"><label id="nf-secret-label">签名密钥(可选)</label>'
+ '<input id="nf-secret" type="password" class="form-control" placeholder="' + (h.secret_set ? '已配置,留空不修改' : '钉钉/飞书加签用,可留空') + '"></div>' + '<input id="nf-secret" type="password" class="form-control" placeholder="'
+ (h.secret_set ? '已配置,留空不修改' : '留空即可') + '">'
+ '<div class="help" id="nf-secret-help"></div></div>'
+ '<div class="form-group" style="max-width:150px"><label>聚合窗口(秒)</label>' + '<div class="form-group" style="max-width:150px"><label>聚合窗口(秒)</label>'
+ '<input id="nf-agg" type="number" class="form-control" min="0" max="600" value="' + '<input id="nf-agg" type="number" class="form-control" min="0" max="600" value="'
+ (h.agg_window != null ? h.agg_window : (_notifySettings.default_agg_window || 30)) + '">' + (h.agg_window != null ? h.agg_window : (_notifySettings.default_agg_window || 30)) + '">'
+ '<div class="help">0=不聚合</div></div>' + '<div class="help">0=不聚合</div></div>'
+ '<div class="form-group" style="max-width:150px"><label>限流(条/分)</label>' + '<div class="form-group" style="max-width:150px"><label>限流(条/分)</label>'
+ '<input id="nf-rate" type="number" class="form-control" min="0" max="60" value="' + '<input id="nf-rate" type="number" class="form-control" min="0" max="60" '
+ (h.rate_limit_per_min != null ? h.rate_limit_per_min : (_notifySettings.default_rate_limit || 18)) + '">' + 'oninput="_notifyRateTouched=true" value="'
+ '<div class="help">企微硬限 20</div></div>' + (h.rate_limit_per_min != null ? h.rate_limit_per_min
: ((_notifyFormats.find(x => x.name === (h.format || 'wecom')) || {}).limit_default
|| _notifySettings.default_rate_limit || 18)) + '">'
+ '<div class="help" id="nf-limit-help"></div></div>'
+ '</div>' + '</div>'
+ '<div class="form-group"><label>订阅事件(不勾就不推)</label>' + '<div class="form-group"><label>订阅事件(不勾就不推)</label>'
+ '<div class="help" style="margin-bottom:6px">按类别分的完整事件目录;' + '<div class="help" style="margin-bottom:6px">按类别分的完整事件目录;'
@@ -161,15 +168,49 @@ function openNotifyModal(id){
box.querySelector('#nf-body').value = h.body_template || ''; box.querySelector('#nf-body').value = h.body_template || '';
renderEventTree(checked); renderEventTree(checked);
renderNotifyPatterns(); renderNotifyPatterns();
_notifyPrevFormat = (h.format || 'wecom');
_notifyRateTouched = (h.rate_limit_per_min != null); // 编辑已有配置时视为"已定"
_notifyFormatChanged(); _notifyFormatChanged();
document.getElementById('notify-modal-overlay').style.display = 'flex'; document.getElementById('notify-modal-overlay').style.display = 'flex';
} }
function _notifyFormatChanged(){ function _notifyFormatChanged(){
const f = (document.getElementById('nf-format') || {}).value; const name = (document.getElementById('nf-format') || {}).value;
// 只有「通用 JSON」才需要自定义请求体模板 const meta = _notifyFormats.find(x => x.name === name) || {};
const h = _notifyEditingId
? (_notifyHooks.find(x => x.id === _notifyEditingId) || {}) : {};
// URL:编辑时占位显示现有(打码)地址,新建时显示该格式的示例
const url = document.getElementById('nf-url');
if(url){
url.placeholder = (h.url && h.url !== undefined) ? h.url : (meta.url_hint || 'https://…');
}
const urlHelp = document.getElementById('nf-url-help');
if(urlHelp){
urlHelp.innerHTML = (h.url ? ('已配置:' + esc(h.url) + '(留空=不修改)<br>') : '')
+ esc(meta.url_help || '');
}
// 密钥:不同格式叫法/用途不同(企业微信不需要、Bark 是设备 Key、通用 JSON 是自定义头)
const sl = document.getElementById('nf-secret-label');
if(sl) sl.textContent = (h.secret_set ? '🔑 ' : '') + (meta.secret_label || '密钥(可选)');
const sh = document.getElementById('nf-secret-help');
if(sh) sh.innerHTML = esc(meta.secret_help || '')
+ (h.secret_set ? '<br>已配置过:留空=不修改,想清除请点上面的 🔑 后填写新值' : '');
// 限流:上限因格式而异(企微 20/分、Bark 无硬限)
const lh = document.getElementById('nf-limit-help');
if(lh) lh.textContent = meta.limit_help || '';
// 用户没手动改过限流值时,跟着格式的推荐值走(企微 20 / 通用 JSON 60 / Bark 60)
const rate = document.getElementById('nf-rate');
if(rate && !_notifyRateTouched && meta.limit_default){
rate.value = meta.limit_default;
}
// 请求体模板:只有需要模板的格式(通用 JSON)才显示
const bodyWrap = document.getElementById('nf-body-wrap'); const bodyWrap = document.getElementById('nf-body-wrap');
if(bodyWrap) bodyWrap.style.display = (f === 'json') ? 'block' : 'none'; if(bodyWrap) bodyWrap.style.display = meta.needs_template ? 'block' : 'none';
_notifyPrevFormat = name;
} }
// Esc 关闭弹窗(点空白处关闭在 overlay 的 onclick 上) // Esc 关闭弹窗(点空白处关闭在 overlay 的 onclick 上)
+10 -6
View File
@@ -30,14 +30,18 @@ def _operator():
def _formats(): def _formats():
"""可用格式(含未实现的,前端据此置灰)。""" """可用格式(含未实现的,前端据此置灰)。
out = []
for name, cls in notifier.ADAPTERS.items(): 每项带**该格式自己的提示文案**(URL 占位/说明、密钥标签、限流说明)——
out.append({"name": name, "label": cls.label, "implemented": True, 这些必须跟着格式走,不能把企业微信的说明写死在页面上。
"byte_limit": cls.byte_limit, "limit_default": cls.limit_default}) """
out = [cls.meta() for _, cls in notifier.ADAPTERS.items()]
for name, label in notifier.PLANNED_FORMATS.items(): for name, label in notifier.PLANNED_FORMATS.items():
out.append({"name": name, "label": label + "(未实现)", "implemented": False, out.append({"name": name, "label": label + "(未实现)", "implemented": False,
"byte_limit": 0, "limit_default": 20}) "byte_limit": 0, "limit_default": 20, "needs_template": False,
"secret_label": "签名密钥(可选)", "secret_help": "",
"url_hint": "", "url_help": "该格式尚未实现,可先用「通用 JSON」手搓",
"limit_help": ""})
return out return out