diff --git a/README.md b/README.md index d6963f6..2c90cca 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ | **AI 控制台** | 用自然语言驱动 AI 操作指定设备(MCP 工具 + 截图),流式输出、Markdown 渲染、推理链折叠、token 统计;成功操作自动沉淀「经验库 / 动作库」并在相似任务中召回 | | **MCP 接入** | 20 个 `de_*` 工具,把手机控制开放给外部 AI;写操作有开关、设备忙时拒绝、全量审计 | | **备份导出/导入** | 一键导出 zip(库快照 + manifest + 可选 APK),导入前校验预览、自动预备份、重启生效 | -| **通知 / Webhook** | 任务成功失败、设备上下线、安装完成、备份恢复等事件推送到企业微信 / Bark / 自建服务;多条 webhook 各自订阅;聚合+限流防刷屏(见 [doc/NOTIFY.md](doc/NOTIFY.md)) | +| **通知 / Webhook** | 任务成功失败、设备上下线、安装完成、备份恢复等事件推送到企业微信 / 钉钉 / 飞书 / Bark / 自建服务(Slack 用通用 JSON);多条 webhook 各自订阅;聚合+限流防刷屏(见 [doc/NOTIFY.md](doc/NOTIFY.md)) | --- diff --git a/core/notifier.py b/core/notifier.py index 0170c5e..1caa6d9 100644 --- a/core/notifier.py +++ b/core/notifier.py @@ -67,8 +67,8 @@ _DEFAULT_SETTINGS = { "http_timeout": 5, } -# 目前实现不了的格式:前端置灰,但用「通用 JSON」能手搓 -PLANNED_FORMATS = {"dingtalk": "钉钉", "feishu": "飞书"} +# 计划中但还没实现的格式(前端置灰)——钉钉/飞书已于 2026-09-15 实现,这里是留给后续的 +PLANNED_FORMATS = {} # ================== URL / 文本脱敏 ================== @@ -76,17 +76,30 @@ _SECRET_KEYS = ("key", "access_token", "token", "secret", "sign", "apikey", "api def mask_url(url): - """把 URL 里的凭据打码(企微 ?key=、钉钉 ?access_token=、飞书 /hook/)。 + """把 URL 里的凭据打码。 - 回显、日志、发送记录一律用它——**URL 本身就是可直接发消息的凭据**。 + 两类都要处理(**URL 本身就是可直接发消息的凭据**): + - query 参数:企微 `?key=`、钉钉 `?access_token=` → 值换成 `***` + - **路径段的 token**:飞书 `https://open.feishu.cn/open-apis/bot/v2/hook/` + (token 在路径最后一段,只处理 query 会漏掉它) + 回显、发送记录、日志一律用它。 """ if not url: return "" try: from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode sp = urlsplit(url) + segs = sp.path.split("/") if sp.path else [] + # 最后一段是长随机串(飞书 hook token 那种)→ 打码;普通路径(/robot/send)不动 + if segs and len(segs[-1]) >= 20 and segs[-1].replace("-", "").replace("_", "").isalnum(): + segs[-1] = "***" + sp = sp._replace(path="/".join(segs)) + # Slack 的凭据在路径三连(/services/T…/B…/X…)——整段打码 + if sp.netloc in ("hooks.slack.com", "hooks.slack-gov.com") and "services" in segs: + i = segs.index("services") + 1 + sp = sp._replace(path="/".join(segs[:i] + ["***"] * (len(segs) - i))) if not sp.query: - return url + return urlunsplit((sp.scheme, sp.netloc, sp.path, "", sp.fragment)) q = [(k, ("***" if k.lower() in _SECRET_KEYS else v)) for k, v in parse_qsl(sp.query, keep_blank_values=True)] # safe='*' 让打码后的 *** 保持原样(否则会被编码成 %2A%2A%2A,看着像乱码) @@ -435,6 +448,15 @@ class BaseAdapter: """→ requests.post 的参数 dict(url/json/headers)。""" raise NotImplementedError + @classmethod + def sign_request(cls, url, secret, body): + """加签钩子:返回 (url, body)。 + + 默认不改动;钉钉(拼 URL,毫秒)和飞书(放 body,秒)各写各的 —— **两家的 + 签名算法不一样**,照抄另一家的会静默失败(钉钉 errcode 310000、飞书 code 19021)。 + """ + return url, body + @classmethod def meta(cls): """给前端用的格式元信息(下拉项 + 随格式变化的提示文案)。""" @@ -581,8 +603,96 @@ class BarkAdapter(BaseAdapter): "headers": dict(hook.get("headers") or {})} +class DingtalkAdapter(BaseAdapter): + """钉钉群机器人:markdown 消息 + 加签。 + + 加签口径(官方,**和飞书不一样,别照抄**): + timestamp 是**毫秒**;`sign = urlencode(base64(HMAC-SHA256(key=secret, + message=f"{timestamp}\\n{secret}")))`;**timestamp/sign 拼到 URL 上**(不是 body)。 + 错了会返回 `errcode 310000 invalid signature`。 + 机器人若用"自定义关键词/IP 白名单"做安全设置,则**不需要** secret(留空即可)。 + """ + + name = "dingtalk" + label = "钉钉" + byte_limit = 20000 # markdown 正文上限 20000 字节 + limit_default = 15 # 官方 20/分,但超限会被限流 10 分钟 —— 留足余量 + ok_codes = (0,) + url_hint = "https://oapi.dingtalk.com/robot/send?access_token=…" + url_help = "钉钉:群设置 → 智能群助手 → 添加机器人 → 自定义 → 复制 Webhook 地址" + secret_label = "加签密钥(可选)" + secret_help = ("机器人安全设置选「加签」时,填 SEC 开头的那串;" + "选「自定义关键词/IP 白名单」则留空") + limit_help = "官方 20 条/分,超限会被限流 10 分钟(这里默认 15 留余量)" + + @classmethod + def render(cls, msg, hook): + return {"url": hook["url"], + "json": {"msgtype": "markdown", + "markdown": {"title": msg["title"], + "text": cls._cut(msg["markdown"])}}, + "headers": dict(hook.get("headers") or {})} + + @classmethod + def sign_request(cls, url, secret, body): + from urllib.parse import quote_plus + ts = str(int(time.time() * 1000)) # 毫秒 + sign = quote_plus(_hmac_b64(secret.encode("utf-8"), + f"{ts}\n{secret}".encode("utf-8"))) + sep = "&" if "?" in url else "?" + return f"{url}{sep}timestamp={ts}&sign={sign}", body + + +class FeishuAdapter(BaseAdapter): + """飞书群机器人:交互式卡片(markdown 元素)+ 加签。 + + 加签口径(官方,**和钉钉不一样**): + timestamp 是**秒**;`sign = base64(HMAC-SHA256(key=f"{timestamp}\\n{secret}", + message=空))`;**timestamp/sign 放在 JSON body 里**(拼 URL 通不过校验)。 + 错了会返回 `code 19021`。 + """ + + name = "feishu" + label = "飞书" + byte_limit = 20000 # 请求体上限 20KB + limit_default = 60 + ok_codes = (0,) + url_hint = "https://open.feishu.cn/open-apis/bot/v2/hook/…" + url_help = "飞书:群设置 → 群机器人 → 添加机器人 → 自定义机器人 → 复制 Webhook 地址" + secret_label = "签名校验密钥(可选)" + secret_help = "机器人安全设置勾了「签名校验」才需要填;否则留空" + limit_help = "飞书官方 100 条/分;这里默认 60 留余量" + + @classmethod + def render(cls, msg, hook): + body = { + "msg_type": "interactive", + "card": { + "config": {"wide_screen_mode": True}, + "header": { + "title": {"tag": "plain_text", "content": msg["title"][:120]}, + "template": {"error": "red", "warning": "orange", + "success": "green"}.get(msg["level"], "blue"), + }, + "elements": [{"tag": "markdown", + "content": cls._cut(msg["markdown"])}], + }, + } + return {"url": hook["url"], "json": body, + "headers": dict(hook.get("headers") or {})} + + @classmethod + def sign_request(cls, url, secret, body): + ts = str(int(time.time())) # 秒 + body = dict(body) + body["timestamp"] = ts + body["sign"] = _hmac_b64(f"{ts}\n{secret}".encode("utf-8"), b"") + return url, body + + ADAPTERS = {WecomAdapter.name: WecomAdapter, JsonAdapter.name: JsonAdapter, - BarkAdapter.name: BarkAdapter} + BarkAdapter.name: BarkAdapter, DingtalkAdapter.name: DingtalkAdapter, + FeishuAdapter.name: FeishuAdapter} # ================== 发送 ================== @@ -597,8 +707,20 @@ def _record(rec): ("" if rec.get("ok") else " | " + scrub(rec.get("error", "")))) +def _hmac_b64(key: bytes, message: bytes) -> str: + """HMAC-SHA256 → Base64(钉钉/飞书加签共用这一步)。""" + import base64 + import hashlib + import hmac as _hmac + return base64.b64encode( + _hmac.new(key, message, digestmod=hashlib.sha256).digest()).decode() + + def _post_once(adapter, msg, hook, timeout): kw = adapter.render(msg, hook) + if hook.get("secret"): + kw["url"], kw["json"] = adapter.sign_request(kw["url"], hook["secret"], + kw.get("json") or {}) kw["timeout"] = timeout kw["allow_redirects"] = False # 防重定向把消息带到别处 r = requests.post(**kw) diff --git a/doc/NOTIFY.md b/doc/NOTIFY.md index eee487c..0c59fbc 100644 --- a/doc/NOTIFY.md +++ b/doc/NOTIFY.md @@ -88,8 +88,10 @@ daemon 线程。所以: 上限(超了**拒绝保存**,不静默截断):webhook ≤ 20 条、整体 JSON ≤ 60000 字符、URL ≤ 2048。 -**安全**:URL 里有凭据(企微 `?key=`)→ 接口回显、发送记录、日志一律打码 -(`mask_url`/`scrub`);编辑时**留空即不修改**;`secret` 永不回显(只回"已配置")。 +**安全**:URL 里带凭据(企微 `?key=`、钉钉 `?access_token=`、飞书 `/hook/`、 +Bark `/…/`、Slack `/services/T…/B…/X…`)→ 接口回显、发送记录、日志一律打码 +(`mask_url` 同时处理 query 与**路径里的 token**;异常消息过 `scrub`); +编辑时**留空即不修改**;`secret` 永不回显(只回"已配置")。 --- @@ -98,10 +100,27 @@ daemon 线程。所以: | 格式 | 请求体 | URL 怎么填 | 成功判定 / 关键约束 | |---|---|---|---| | `wecom` 企业微信 | `{"msgtype":"markdown","markdown":{"content":"…"}}` | 群机器人 → 复制的 Webhook 地址(含 `?key=`) | **`errcode==0`**;content **≤4096 字节**(按字节截断、不会截出半个汉字);**每机器人每分钟 20 条**,超限 `45009` | +| `dingtalk` 钉钉 | `{"msgtype":"markdown","markdown":{"title","text"}}` | 群设置 → 智能群助手 → 添加机器人 → 自定义 → 复制的 Webhook 地址(含 `?access_token=`) | **`errcode==0`**;正文 ≤20000 字节;官方 20 条/分,**超限会被限流 10 分钟**(这里默认 15 留余量);机器人安全设置选「加签」时密钥必填 | +| `feishu` 飞书 | `{"msg_type":"interactive","card":{header + markdown 元素[,"timestamp","sign"]}}` | 群设置 → 群机器人 → 添加 → 自定义机器人 → 复制的 Webhook 地址(`…/bot/v2/hook/`) | **`code==0`**;请求体 ≤20KB;官方 100 条/分(这里默认 60);开了「签名校验」时密钥必填 | +| `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` 传纯文本兜底 | | `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` 上——加新格式时别忘了一起定。 +> 成功码**按格式**判定(企微/钉钉/飞书 = 0,Bark = 200),写在适配器的 `ok_codes` 上—— +> 加新格式时别忘了一起定。 + +### 加签:钉钉和飞书**算法不一样**,别互相照抄 + +| | 钉钉 | 飞书 | +|---|---|---| +| key(HMAC 的密钥) | `secret` | `"{timestamp}\n{secret}"` | +| message(被签内容) | `"{timestamp}\n{secret}"` | **空** | +| timestamp 单位 | **毫秒** | **秒** | +| 拼在哪 | **URL query**(`×tamp=…&sign=…`) | **JSON body** 的 `timestamp`/`sign` | +| 结果编码 | Base64 后再 **urlencode** | Base64 | +| 出错码 | `errcode 310000 invalid signature` | `code 19021 sign match fail` | + +实现见 `core/notifier.py` 的 `DingtalkAdapter.sign_request` / `FeishuAdapter.sign_request`; +两家的算法都用官方示例代码对拍过(钉钉逐字节一致,含 urlencode)。 **字段一律渲染成 `> **字段**:值` 引用行,不用 Markdown 表格**——企业微信/钉钉的 markdown 子集不支持表格,表格会原样吐出来。 @@ -115,9 +134,10 @@ daemon 线程。所以: (`BaseAdapter.url_hint/url_help/secret_label/secret_help/limit_help`),界面按当前格式渲染、 切格式即时更新——新增格式时把这些一起填上,别把某个平台的说明写死在页面上。 -`dingtalk`/`feishu` 在界面上是**置灰**的:适配器留了插槽(`BaseAdapter._sign/_auth_fields/ -_byte_limit/_ok_codes`),要接的时候加一个类 + 注册进 `ADAPTERS` 即可;急用可以拿通用 JSON 手搓 -(飞书的 text 格式就是 `{"msg_type":"text","content":{"text":"{{markdown}}"}}`)。 +**再加新格式**:写一个 `BaseAdapter` 子类(`render` + 可选 `sign_request`)并把元信息 +(`byte_limit`/`ok_codes`/`limit_default`/`url_hint`/`url_help`/`secret_*`/`limit_help`)填全, +然后注册进 `ADAPTERS` —— 界面上的下拉项与提示文案会自动跟着出来,**不用改前端**。 +`PLANNED_FORMATS` 是"还没实现、下拉里置灰"的占位表(目前为空)。 ---