diff --git a/README.md b/README.md index edfacb2..da33ed2 100644 --- a/README.md +++ b/README.md @@ -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)) | --- diff --git a/core/notifier.py b/core/notifier.py index e7e340c..0170c5e 100644 --- a/core/notifier.py +++ b/core/notifier.py @@ -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 diff --git a/doc/NOTIFY.md b/doc/NOTIFY.md index f1436ad..eee487c 100644 --- a/doc/NOTIFY.md +++ b/doc/NOTIFY.md @@ -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/`)**或** `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}}"}}`)。 --- diff --git a/static/admin/notify.js b/static/admin/notify.js index 12985ab..d542e33 100644 --- a/static/admin/notify.js +++ b/static/admin/notify.js @@ -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){ + '' + '' + '
' - + '' - + '
' + (h.url ? '已配置:' + esc(h.url) + '(留空=不修改)' : '企业微信:群机器人 → 复制 Webhook 地址') + '
' + // 占位与说明都由**当前格式**决定(见 _notifyFormatChanged),切换格式会跟着变 + + '' + + '
' + '
' - + '
' - + '
' + + '
' + + '' + + '
' + '
' + '' + '
0=不聚合
' + '
' - + '' - + '
企微硬限 20
' + + '' + + '
' + '' + '
' + '
按类别分的完整事件目录;' @@ -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) + '(留空=不修改)
') : '') + + 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 ? '
已配置过:留空=不修改,想清除请点上面的 🔑 后填写新值' : ''); + + // 限流:上限因格式而异(企微 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 上) diff --git a/web/notify_api.py b/web/notify_api.py index 90a6a8c..567f0a7 100644 --- a/web/notify_api.py +++ b/web/notify_api.py @@ -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