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:
@@ -53,7 +53,7 @@
|
||||
| **AI 控制台** | 用自然语言驱动 AI 操作指定设备(MCP 工具 + 截图),流式输出、Markdown 渲染、推理链折叠、token 统计;成功操作自动沉淀「经验库 / 动作库」并在相似任务中召回 |
|
||||
| **MCP 接入** | 20 个 `de_*` 工具,把手机控制开放给外部 AI;写操作有开关、设备忙时拒绝、全量审计 |
|
||||
| **备份导出/导入** | 一键导出 zip(库快照 + manifest + 可选 APK),导入前校验预览、自动预备份、重启生效 |
|
||||
| **通知 / Webhook** | 任务成功失败、设备上下线、安装完成、备份恢复等事件推送到企业微信/自建服务;多条 webhook 各自订阅;聚合+限流防刷屏(见 [doc/NOTIFY.md](doc/NOTIFY.md)) |
|
||||
| **通知 / Webhook** | 任务成功失败、设备上下线、安装完成、备份恢复等事件推送到企业微信 / Bark / 自建服务;多条 webhook 各自订阅;聚合+限流防刷屏(见 [doc/NOTIFY.md](doc/NOTIFY.md)) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+81
-6
@@ -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
|
||||
|
||||
|
||||
|
||||
+12
-5
@@ -95,10 +95,13 @@ daemon 线程。所以:
|
||||
|
||||
## 5. 推送格式
|
||||
|
||||
| 格式 | 请求体 | 关键约束 |
|
||||
|---|---|---|
|
||||
| `wecom` 企业微信 | `{"msgtype":"markdown","markdown":{"content":"…"}}` | content **≤4096 字节**(超了按字节截断并加「…(已截断)」,不会截出半个汉字);**每机器人每分钟 20 条**,超限 `errcode 45009`;成功必须 `errcode==0` |
|
||||
| `json` 通用 / Slack | 由 `body_template` 决定 | 保存前**干跑校验**(渲染后必须是合法 JSON) |
|
||||
| 格式 | 请求体 | URL 怎么填 | 成功判定 / 关键约束 |
|
||||
|---|---|---|---|
|
||||
| `wecom` 企业微信 | `{"msgtype":"markdown","markdown":{"content":"…"}}` | 群机器人 → 复制的 Webhook 地址(含 `?key=`) | **`errcode==0`**;content **≤4096 字节**(按字节截断、不会截出半个汉字);**每机器人每分钟 20 条**,超限 `45009` |
|
||||
| `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
|
||||
子集不支持表格,表格会原样吐出来。
|
||||
@@ -108,8 +111,12 @@ daemon 线程。所以:
|
||||
替换值按 JSON 字符串转义,所以标题里带引号/换行也不会打坏请求体。Slack 直接写
|
||||
`{"text":"{{markdown}}"}` 就行。
|
||||
|
||||
**格式相关的提示文案(URL 示例/说明、密钥叫什么、限流上限)都挂在适配器上**
|
||||
(`BaseAdapter.url_hint/url_help/secret_label/secret_help/limit_help`),界面按当前格式渲染、
|
||||
切格式即时更新——新增格式时把这些一起填上,别把某个平台的说明写死在页面上。
|
||||
|
||||
`dingtalk`/`feishu` 在界面上是**置灰**的:适配器留了插槽(`BaseAdapter._sign/_auth_fields/
|
||||
_byte_limit`),要接的时候加一个类 + 注册进 `ADAPTERS` 即可;急用可以拿通用 JSON 手搓
|
||||
_byte_limit/_ok_codes`),要接的时候加一个类 + 注册进 `ADAPTERS` 即可;急用可以拿通用 JSON 手搓
|
||||
(飞书的 text 格式就是 `{"msg_type":"text","content":{"text":"{{markdown}}"}}`)。
|
||||
|
||||
---
|
||||
|
||||
+52
-11
@@ -7,6 +7,8 @@ let _notifyFormats = [];
|
||||
let _notifySettings = {};
|
||||
let _notifyEditingId = null; // null=新建
|
||||
let _notifyPatterns = []; // 编辑中的通配订阅(如 task.*)
|
||||
let _notifyPrevFormat = ''; // 上一个选中格式(切换时判断限流值要不要跟着走)
|
||||
let _notifyRateTouched = false; // 用户是否手动改过限流值(没改过就跟随格式的推荐值)
|
||||
|
||||
// ================== 加载与渲染 ==================
|
||||
function loadNotifyPanel(){
|
||||
@@ -119,20 +121,25 @@ function openNotifyModal(id){
|
||||
+ '<select id="nf-format" class="form-control" onchange="_notifyFormatChanged()">' + fmtOpts + '</select></div>'
|
||||
+ '</div>'
|
||||
+ '<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=…') + '" '
|
||||
+ 'value="' + (id ? '' : '') + '">'
|
||||
+ '<div class="help">' + (h.url ? '已配置:' + esc(h.url) + '(留空=不修改)' : '企业微信:群机器人 → 复制 Webhook 地址') + '</div></div>'
|
||||
// 占位与说明都由**当前格式**决定(见 _notifyFormatChanged),切换格式会跟着变
|
||||
+ '<input id="nf-url" class="form-control" value="">'
|
||||
+ '<div class="help" id="nf-url-help"></div></div>'
|
||||
+ '<div class="form-row">'
|
||||
+ '<div class="form-group"><label>签名密钥(可选)</label>'
|
||||
+ '<input id="nf-secret" type="password" class="form-control" placeholder="' + (h.secret_set ? '已配置,留空不修改' : '钉钉/飞书加签用,可留空') + '"></div>'
|
||||
+ '<div class="form-group"><label id="nf-secret-label">签名密钥(可选)</label>'
|
||||
+ '<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>'
|
||||
+ '<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)) + '">'
|
||||
+ '<div class="help">0=不聚合</div></div>'
|
||||
+ '<div class="form-group" style="max-width:150px"><label>限流(条/分)</label>'
|
||||
+ '<input id="nf-rate" type="number" class="form-control" min="0" max="60" value="'
|
||||
+ (h.rate_limit_per_min != null ? h.rate_limit_per_min : (_notifySettings.default_rate_limit || 18)) + '">'
|
||||
+ '<div class="help">企微硬限 20</div></div>'
|
||||
+ '<input id="nf-rate" type="number" class="form-control" min="0" max="60" '
|
||||
+ 'oninput="_notifyRateTouched=true" value="'
|
||||
+ (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 class="form-group"><label>订阅事件(不勾就不推)</label>'
|
||||
+ '<div class="help" style="margin-bottom:6px">按类别分的完整事件目录;'
|
||||
@@ -161,15 +168,49 @@ function openNotifyModal(id){
|
||||
box.querySelector('#nf-body').value = h.body_template || '';
|
||||
renderEventTree(checked);
|
||||
renderNotifyPatterns();
|
||||
_notifyPrevFormat = (h.format || 'wecom');
|
||||
_notifyRateTouched = (h.rate_limit_per_min != null); // 编辑已有配置时视为"已定"
|
||||
_notifyFormatChanged();
|
||||
document.getElementById('notify-modal-overlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
function _notifyFormatChanged(){
|
||||
const f = (document.getElementById('nf-format') || {}).value;
|
||||
// 只有「通用 JSON」才需要自定义请求体模板
|
||||
const name = (document.getElementById('nf-format') || {}).value;
|
||||
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');
|
||||
if(bodyWrap) bodyWrap.style.display = (f === 'json') ? 'block' : 'none';
|
||||
if(bodyWrap) bodyWrap.style.display = meta.needs_template ? 'block' : 'none';
|
||||
|
||||
_notifyPrevFormat = name;
|
||||
}
|
||||
|
||||
// Esc 关闭弹窗(点空白处关闭在 overlay 的 onclick 上)
|
||||
|
||||
+10
-6
@@ -30,14 +30,18 @@ def _operator():
|
||||
|
||||
|
||||
def _formats():
|
||||
"""可用格式(含未实现的,前端据此置灰)。"""
|
||||
out = []
|
||||
for name, cls in notifier.ADAPTERS.items():
|
||||
out.append({"name": name, "label": cls.label, "implemented": True,
|
||||
"byte_limit": cls.byte_limit, "limit_default": cls.limit_default})
|
||||
"""可用格式(含未实现的,前端据此置灰)。
|
||||
|
||||
每项带**该格式自己的提示文案**(URL 占位/说明、密钥标签、限流说明)——
|
||||
这些必须跟着格式走,不能把企业微信的说明写死在页面上。
|
||||
"""
|
||||
out = [cls.meta() for _, cls in notifier.ADAPTERS.items()]
|
||||
for name, label in notifier.PLANNED_FORMATS.items():
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user