feat(通知): 加钉钉 / 飞书两种格式(含各自的加签算法)

## 两家签名算法不一样,照抄必错
| | 钉钉 | 飞书 |
|---|---|---|
| HMAC key | `secret` | `"{timestamp}\n{secret}"` |
| 被签内容 | `"{timestamp}\n{secret}"` | 空 |
| timestamp | 毫秒 | 秒 |
| 拼在哪 | URL query | JSON body |
| 结果 | Base64 再 urlencode | Base64 |
| 出错码 | `errcode 310000` | `code 19021` |

实现为 `BaseAdapter.sign_request()` 钩子(默认不动),两家各写各的;
`core/notifier.py` 的 `_post_once` 在发请求前调用它。

## 另外
- 钉钉:markdown 消息(title+text);成功码 0;默认限流 15/分(官方 20/分,**超限会被限 10 分钟**,留余量)
- 飞书:交互式卡片(header 颜色按事件级别 + markdown 元素);成功码 0;默认 60/分
- `PLANNED_FORMATS` 清空(下拉里不再有置灰项)
- **修一个泄漏**:`mask_url` 原来只打码 query 参数——**飞书的 token 在 URL 路径里**(`/hook/<token>`)
  → 接口回显会漏出去。现在同时处理路径 token(≥20 位随机串)与 Slack 的 `/services/T…/B…/X…` 三段。

## 验证(都跑过)
- **签名对拍官方示例**:钉钉逐字节一致(含 urlencode,`%2B` 级别)、飞书的 key/message 口径一致;
  时间戳钉住后比对,不靠"自己跟自己一致"
- 成功码按格式:wecom/dingtalk/feishu 的 0 与 bark 的 200 分别判成功,各自的错误码判失败
- 端到端 15 项:钉钉/飞书在假接收端上真发(URL 带签名、body 带 timestamp/sign、卡片是 markdown 元素、
  毫秒 vs 秒的时间戳),签名错时正确判失败(310000 / 19021),token 在回显里被打码
This commit is contained in:
2026-09-15 14:29:11 +08:00
parent 515cb5d580
commit 3b6ec8c98f
3 changed files with 156 additions and 14 deletions
+1 -1
View File
@@ -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)) |
---
+128 -6
View File
@@ -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/<token>)。
"""把 URL 里的凭据打码。
回显、日志、发送记录一律用它——**URL 本身就是可直接发消息的凭据**。
两类都要处理(**URL 本身就是可直接发消息的凭据**):
- query 参数:企微 `?key=`、钉钉 `?access_token=` → 值换成 `***`
- **路径段的 token**:飞书 `https://open.feishu.cn/open-apis/bot/v2/hook/<token>`
(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)
+27 -7
View File
@@ -88,8 +88,10 @@ daemon 线程。所以:
上限(超了**拒绝保存**,不静默截断):webhook ≤ 20 条、整体 JSON ≤ 60000 字符、URL ≤ 2048。
**安全**:URL 里有凭据(企微 `?key=`)→ 接口回显、发送记录、日志一律打码
(`mask_url`/`scrub`);编辑时**留空即不修改**;`secret` 永不回显(只回"已配置")。
**安全**:URL 里带凭据(企微 `?key=`、钉钉 `?access_token=`、飞书 `/hook/<token>`、
Bark `/…/<key>`、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/<token>`) | **`code==0`**;请求体 ≤20KB;官方 100 条/分(这里默认 60);开了「签名校验」时密钥必填 |
| `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` 传纯文本兜底 |
| `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` 上——加新格式时别忘了一起定。
> 成功码**按格式**判定(企微/钉钉/飞书 = 0,Bark = 200),写在适配器的 `ok_codes` 上——
> 加新格式时别忘了一起定。
### 加签:钉钉和飞书**算法不一样**,别互相照抄
| | 钉钉 | 飞书 |
|---|---|---|
| key(HMAC 的密钥) | `secret` | `"{timestamp}\n{secret}"` |
| message(被签内容) | `"{timestamp}\n{secret}"` | **空** |
| timestamp 单位 | **毫秒** | **秒** |
| 拼在哪 | **URL query**(`&timestamp=…&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` 是"还没实现、下拉里置灰"的占位表(目前为空)。
---