Skip to content

Commit 7b908d1

Browse files
committed
feat(incomingwebhook): add GitLab + Feishu platform adapters (#297 Phase 4) (#423)
Adds the GitLab and Feishu incoming-webhook platform adapters on the shared pushAdapter pipeline (#330), completing #297 Phase 4. GitLab (.../:token/gitlab): render Push/Tag/Merge Request/Issue/Note/ Pipeline by X-Gitlab-Event; X-Gitlab-Token re-checked constant-time after URL auth (mismatch -> 401, audited reason=token, no IP-budget burn); dedicated 1MiB body cap (clamped 25MiB); SHA1/SHA256 zero-sentinel. Feishu (.../:token/feishu): custom-bot format -> markdown text path; href scheme-allowlisted; actor/at/project names escaped via mdInertText; media types rejected 400; code=0/msg=success for SDK compatibility. Migration converges the audit adapter/reason column comments; README documents both shapes. Closes #297. (cherry picked from commit 3999da7)
1 parent 532dbbb commit 7b908d1

11 files changed

Lines changed: 1386 additions & 21 deletions

modules/incomingwebhook/README.md

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ POST /v1/incoming-webhooks/:webhook_id/:token # native(本文主体
4848
POST /v1/incoming-webhooks/:webhook_id/:token/github # GitHub 事件适配器
4949
POST /v1/incoming-webhooks/:webhook_id/:token/wecom # 企业微信群机器人格式适配器
5050
POST /v1/incoming-webhooks/:webhook_id/:token/multica # Multica 出站 webhook 适配器
51+
POST /v1/incoming-webhooks/:webhook_id/:token/gitlab # GitLab 事件适配器
52+
POST /v1/incoming-webhooks/:webhook_id/:token/feishu # 飞书自定义机器人格式适配器
5153
Content-Type: application/json
5254
```
5355

@@ -120,10 +122,11 @@ Content-Type: application/json
120122
- 服务端另有 1MB 的 RichText 硬上限(octo-lib 契约)兜底,但在默认 8KB body cap 下不会
121123
先触达——它是上调 body cap 后才会成为约束的二级护栏。
122124

123-
## 平台适配器(#297 Phase 3)
125+
## 平台适配器(#297 Phase 3 / 4
124126

125127
适配器把第三方平台的原生格式翻译成上面的 native 消息,鉴权/限流/审计与 native 完全
126128
一致。适配器消息不支持 `username`/`avatar_url` 覆盖(展示身份固定为 webhook 配置)。
129+
GitHub / 企业微信为 Phase 3,GitLab / 飞书为 Phase 4。
127130

128131
### GitHub
129132

@@ -224,6 +227,58 @@ curl -X POST "$BASE/v1/incoming-webhooks/$WEBHOOK_ID/$TOKEN/multica" \
224227
"previous_status":"todo"}'
225228
```
226229

230+
### GitLab(#297 Phase 4)
231+
232+
```
233+
POST /v1/incoming-webhooks/:webhook_id/:token/gitlab
234+
```
235+
236+
在 GitLab 项目 **Settings → Webhooks** 把 URL 配成上述地址。**鉴权除 URL 内的 token 外,
237+
还须把该 webhook 的「Secret token」字段也设为同一个 token**——GitLab 以 `X-Gitlab-Token`
238+
头回传,服务端在 URL token 校验通过后再常量时间比对一次;不一致返回 401(落审计
239+
`reason=token`,便于在 deliveries 里定位配置错误)。
240+
241+
`X-Gitlab-Event` 渲染为 markdown,当前渲染子集:
242+
243+
| 事件 | 渲染的动作 | 说明 |
244+
|------|-----------|------|
245+
| `Push Hook` || 分支 push、建/删分支;最多列 5 条提交 |
246+
| `Tag Push Hook` || 建/删标签 |
247+
| `Merge Request Hook` | `open` / `merge` / `close` / `reopen` | `update`/`approved` 等刷屏动作跳过 |
248+
| `Issue Hook` | `open` / `close` / `reopen` | `update` 跳过 |
249+
| `Note Hook` | 评论(MR / Issue / Commit) | 评论摘要压成单行、截断 300 rune |
250+
| `Pipeline Hook` | `success` / `failed` / `canceled` | `running`/`pending` 等非终态跳过 |
251+
252+
子集之外的事件/动作返回 200 + `{"skipped":"event"}`(GitLab 侧投递成功、不标红),缺
253+
`X-Gitlab-Event` 头按 400 `reason=no_event` 拒绝(与 github 同口径,可在 deliveries 里
254+
与「不在渲染子集」的 200 skip 分开看)。**body 上限独立于 native**:事件 JSON 由平台
255+
生成,默认 **1MiB**`DM_INCOMINGWEBHOOK_GITLAB_MAX_BYTES`)。
256+
257+
### 飞书(自定义机器人格式,#297 Phase 4)
258+
259+
```
260+
POST /v1/incoming-webhooks/:webhook_id/:token/feishu
261+
```
262+
263+
接受飞书「自定义机器人」的出站消息格式——已配置向飞书机器人推送的工具只需**换 URL**
264+
即可迁移。成功响应附带 `code=0`/`msg=success`(多数飞书 SDK 以此判定成功)。
265+
266+
> **鉴权说明**:飞书自定义机器人原生的 `timestamp`/`sign`(基于 secret 的防重放 HMAC)
267+
> 字段被**忽略**,鉴权一律走 URL 内的 token(与 native/wecom 一致,经 #297 确认)。这意味着
268+
> URL token 是**唯一凭证**——不像 GitLab 还有 `X-Gitlab-Token` 二道闸,URL 泄漏即失防护,
269+
> 请按密码强度妥善保管、必要时用 regenerate 轮换。
270+
271+
| `msg_type` | 处理 |
272+
|-----------|------|
273+
| `text` | → 文本消息(客户端按 markdown 渲染) |
274+
| `post`(富文本) | 降级 markdown:标题加粗,每行 `text`/`a`(链接)/`at`(@) 内联拼接;`img` 丢弃(image_key 无法转存) |
275+
| `interactive`(卡片) | 降级 markdown:标题 + `div`/`markdown` 元素文本逐行拼接;按钮/图片等交互元素丢弃 |
276+
| `image` / `share_chat` 等素材类 | **400 `reason=msg_type`**:素材无法转存,显式失败优于静默丢弃 |
277+
278+
> 高保真卡片渲染不可行,降级策略经 #297 确认(与 WeCom 同一契约)。`post` 刻意走文本
279+
> 路径而非 RichText:富文本 `text` 块不渲染 markdown,链接会失去可点击性,文本路径反而
280+
> 更保真,且飞书图文用 image_key 无法转为 RichText 的 URL 图片块。
281+
227282
## 通用字段与安全
228283

229284
- `username` / `avatar_url`:两种形态通用,服务端裁剪到字节上限(名 64B / 头像 255B)。
@@ -236,7 +291,7 @@ curl -X POST "$BASE/v1/incoming-webhooks/$WEBHOOK_ID/$TOKEN/multica" \
236291
|------|------|------|
237292
| 成功 | 200 | `{"status":0,"message_id":<int>}`(wecom 路由额外带 `errcode`/`errmsg`|
238293
| 已接收、刻意不投递 | 200 | `{"status":0,"message_id":0,"skipped":"ping"\|"event"}`(仅适配器路由) |
239-
| 鉴权失败 | 401 | 统一响应,不区分原因(反枚举) |
294+
| 鉴权失败 | 401 | 统一响应,不区分原因(反枚举);含 GitLab `X-Gitlab-Token` 与 URL token 不匹配(落审计 `reason=token` |
240295
| 限流 | 429 |`Retry-After` |
241296
| 请求非法 | 400 | `details.reason``body`/`json`/`content`/`blocks`/`msg_type`/`no_event`(缺 `X-GitHub-Event` 头) |
242297
| 体积过大 | 413 | 超 body cap 或富文本 >1MB |
@@ -248,8 +303,8 @@ curl -X POST "$BASE/v1/incoming-webhooks/$WEBHOOK_ID/$TOKEN/multica" \
248303
需登录态 + 群管理员权限,路径前缀 `/v1/groups/:group_no/incoming-webhooks`
249304

250305
创建 / 重置(regenerate)响应除历史的 `url`(native 路径)外,还带 `urls` 对象,
251-
按推送形态给出全部路径(`native` / `github` / `wecom` / `multica`,不含 host,由前端拼接)。
252-
token 仅在这两处出现一次,list 不回显 token、也不回推送 URL。
306+
按推送形态给出全部路径(`native` / `github` / `wecom` / `multica` / `gitlab` / `feishu`
307+
不含 host,由前端拼接)。token 仅在这两处出现一次,list 不回显 token、也不回推送 URL。
253308

254309
除创建/列出/更新/删除/重置外,Phase 2 新增两个:
255310

modules/incomingwebhook/adapter.go

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,19 @@ type pushAdapter struct {
3232
// - invalid 非空:解析失败原因码,映射 400 invalid(reason=...) 并落审计。
3333
parse func(header http.Header, body []byte) (req *pushPayloadReq, skip string, invalid string)
3434
// successExtra 合并进成功 / skip 响应体的平台兼容字段(如企业微信的 errcode /
35-
// errmsg),让按平台 SDK 校验响应的既有工具不改代码即可迁移。key 与 native 的
36-
// status / message_id 不重叠,纯追加。
35+
// errmsg、飞书的 code / msg),让按平台 SDK 校验响应的既有工具不改代码即可迁移。
36+
// key 与 native 的 status / message_id 不重叠,纯追加。
3737
successExtra map[string]interface{}
38-
// bodyLimit 该形态的请求体字节上限。native / wecom 的 body 由调用方编写,沿用
39-
// 8KiB 的 maxBytes()——上限本就是约束调用方的;github 的 body 是平台生成的事件
40-
// JSON,真实 push / PR 事件普遍 >8KiB 且发送方无法修短,必须用更宽的专属上限
41-
//(githubMaxBytes,见 adapter_github.go;PR #330 review 阻断项)。
38+
// bodyLimit 该形态的请求体字节上限。native / wecom / feishu 的 body 由调用方编写,
39+
// 沿用 8KiB 的 maxBytes()——上限本就是约束调用方的;github / gitlab 的 body 是平台
40+
// 生成的事件 JSON,真实事件普遍 >8KiB 且发送方无法修短,必须用更宽的专属上限
41+
//(githubMaxBytes / gitlabMaxBytes)。
4242
bodyLimit func() int
43+
// verifyToken(可选)在 URL token 已校验通过后,对平台在 header 里回传的 token 再做
44+
// 一次常量时间比对(目前仅 GitLab 的 X-Gitlab-Token)。返回 false → 401。能走到这里
45+
// 说明 URL token 已验证、调用方已持有 webhook 真正密钥,故不匹配是配置错误而非枚举
46+
// 探测(见 handlePush)。nil 表示该形态无需 header token 二次校验。
47+
verifyToken func(header http.Header, urlToken string) bool
4348
}
4449

4550
var (
@@ -56,6 +61,20 @@ var (
5661
// 的固定 JSON envelope)。multica envelope 比 GitHub 事件紧凑(不嵌入
5762
// repository 对象),8 KiB 足够,沿用 native 的 bodyLimit。
5863
multicaAdapter = pushAdapter{name: adapterMultica, parse: parseMulticaPush, bodyLimit: maxBytes}
64+
gitlabAdapter = pushAdapter{
65+
name: adapterGitLab,
66+
parse: parseGitLabPush,
67+
bodyLimit: gitlabMaxBytes,
68+
// GitLab 额外要求把项目 Secret token 设为 URL token,经 X-Gitlab-Token 回传。
69+
verifyToken: verifyGitLabToken,
70+
}
71+
feishuAdapter = pushAdapter{
72+
name: adapterFeishu,
73+
parse: parseFeishuPush,
74+
bodyLimit: maxBytes,
75+
// 飞书调用方普遍校验 code==0,附带平台习惯字段降低迁移摩擦。
76+
successExtra: map[string]interface{}{"code": 0, "msg": "success"},
77+
}
5978
)
6079

6180
// parseNativePush 是 native 形态的 parse:body 即 pushPayloadReq JSON 本身。
@@ -109,6 +128,14 @@ func firstLine(s string) string {
109128
return strings.TrimSpace(s)
110129
}
111130

131+
// isHTTPURL 判断字符串是否是 http(s) URL(大小写不敏感)。用于把外部提供的链接
132+
// 目标限制到安全 scheme——非 http(s)(如 `javascript:` / `data:`)的「链接」会被降级
133+
// 为纯文本,杜绝 scheme 注入(#423 review)。
134+
func isHTTPURL(s string) bool {
135+
l := strings.ToLower(strings.TrimSpace(s))
136+
return strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://")
137+
}
138+
112139
// oneLine 把多行文本压成单行,避免标题 / 评论里的换行破坏 markdown 链接结构。
113140
func oneLine(s string) string {
114141
s = strings.ReplaceAll(s, "\r\n", " ")
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package incomingwebhook
2+
3+
// 飞书自定义机器人格式适配器(#297 Phase 4)。
4+
//
5+
// 路由:POST /v1/incoming-webhooks/:webhook_id/:token/feishu
6+
// 接受飞书「自定义机器人」的出站消息格式:已配置向飞书机器人推送的工具,只需把
7+
// webhook URL 换成上述地址即可迁移,消息体零改动。
8+
//
9+
// 形态映射(高保真卡片渲染不可行,经 #297 确认接受降级并在 README 写明,与 WeCom
10+
// 适配器同一契约):
11+
//
12+
// - text → native 纯文本路径(客户端按 markdown 渲染)。
13+
// - post(富文本)→ 降级 markdown:标题加粗,每行的 text/a/at 标签内联拼接(a 渲染为
14+
// markdown 链接,at 渲染为 @用户名);img 标签丢弃——飞书图文用 image_key 引用平台
15+
// 素材,无法转存为 URL(与 WeCom 图片同理)。走文本路径而非 RichText:text 块不渲染
16+
// markdown,链接会失去可点击性,文本路径反而更保真(#297 确认)。
17+
// - interactive(卡片)→ 降级 markdown:标题 + div/markdown 元素文本逐行拼接;按钮 /
18+
// 图片 / 分隔等交互或素材元素丢弃。
19+
// - image / share_chat 等依赖平台素材的类型 → 400 invalid(reason=msg_type):素材无法
20+
// 转存,静默丢弃会让调用方误以为已送达,显式失败 + deliveries 可见才诚实。
21+
//
22+
// 成功响应在 native 字段基础上附带 code=0 / msg=success(见 adapter.go
23+
// feishuAdapter.successExtra):多数飞书 SDK 以 code==0 判定成功。
24+
//
25+
// 飞书带 secret 时消息体里会有 timestamp / sign 字段——白名单解析直接忽略,鉴权一律
26+
// 走 URL token,不另校验飞书 sign。
27+
28+
import (
29+
"encoding/json"
30+
"fmt"
31+
"net/http"
32+
"strings"
33+
)
34+
35+
// feishuMsg 只声明翻译需要的字段(白名单解析),其余 payload 字段一律忽略。
36+
type feishuMsg struct {
37+
MsgType string `json:"msg_type"`
38+
Content *feishuContent `json:"content"`
39+
Card *feishuCard `json:"card"`
40+
}
41+
42+
type feishuContent struct {
43+
Text string `json:"text"`
44+
Post map[string]feishuPostLocale `json:"post"`
45+
}
46+
47+
type feishuPostLocale struct {
48+
Title string `json:"title"`
49+
Content [][]feishuPostTag `json:"content"`
50+
}
51+
52+
type feishuPostTag struct {
53+
Tag string `json:"tag"`
54+
Text string `json:"text"`
55+
Href string `json:"href"`
56+
UserName string `json:"user_name"`
57+
}
58+
59+
type feishuCard struct {
60+
Header *struct {
61+
Title struct {
62+
Content string `json:"content"`
63+
} `json:"title"`
64+
} `json:"header"`
65+
Elements []feishuCardElement `json:"elements"`
66+
}
67+
68+
// feishuCardElement:div 元素文本在 text.content;markdown 元素文本直接在 content。
69+
type feishuCardElement struct {
70+
Tag string `json:"tag"`
71+
Text *struct {
72+
Content string `json:"content"`
73+
} `json:"text"`
74+
Content string `json:"content"`
75+
}
76+
77+
// parseFeishuPush 把飞书自定义机器人消息翻译成 native 推送请求(pushAdapter.parse)。
78+
// 与 GitHub/GitLab 不同,内容长度不钳制:消息体由调用方编写(非平台生成的事件),
79+
// 超过语义上限按既有 413 拒绝,调用方有能力也应当修短。
80+
func parseFeishuPush(_ http.Header, body []byte) (*pushPayloadReq, string, string) {
81+
var msg feishuMsg
82+
if err := json.Unmarshal(body, &msg); err != nil {
83+
return nil, "", "json"
84+
}
85+
var content string
86+
switch msg.MsgType {
87+
case "text":
88+
if msg.Content != nil {
89+
content = msg.Content.Text
90+
}
91+
case "post":
92+
if msg.Content != nil {
93+
content = renderFeishuPost(msg.Content.Post)
94+
}
95+
case "interactive":
96+
content = renderFeishuCard(msg.Card)
97+
default:
98+
// 空 msg_type 与 image / share_chat 等素材类:显式拒绝(理由见文件头注释)。
99+
return nil, "", "msg_type"
100+
}
101+
if strings.TrimSpace(content) == "" {
102+
return nil, "", "content"
103+
}
104+
return &pushPayloadReq{Content: content}, "", ""
105+
}
106+
107+
// renderFeishuPost 把富文本降级为 markdown:标题加粗,每行内联拼接 text/a/at 标签,
108+
// 行间换行。优先 zh_cn 语言块,回退 en_us,再回退任意一个。
109+
func renderFeishuPost(post map[string]feishuPostLocale) string {
110+
if len(post) == 0 {
111+
return ""
112+
}
113+
loc := pickFeishuLocale(post)
114+
var lines []string
115+
if title := oneLine(loc.Title); title != "" {
116+
lines = append(lines, "**"+title+"**")
117+
}
118+
for _, row := range loc.Content {
119+
var sb strings.Builder
120+
for _, tag := range row {
121+
switch tag.Tag {
122+
case "text":
123+
sb.WriteString(tag.Text)
124+
case "a":
125+
text := oneLine(tag.Text)
126+
// href 必须是 http(s):飞书 a-tag 的 href 来自入站 payload,裸传会让
127+
// `javascript:` / `data:` 等危险 scheme 渲染成投递给群内其它成员的可点击
128+
// 链接(scheme 注入,#423 review,Jerry-Xin/mochashanyao)。非 http(s) 降级
129+
// 为纯文本。链接文本里的 `]`/`[` 仍转义,避免破坏 markdown 链接结构。
130+
if href := strings.TrimSpace(tag.Href); isHTTPURL(href) {
131+
fmt.Fprintf(&sb, "[%s](%s)", mdLinkTextEscaper.Replace(text), href)
132+
} else {
133+
sb.WriteString(text)
134+
}
135+
case "at":
136+
// user_name 是自由文本,进 `@X` 纯文本上下文须经 mdInertText 转义,
137+
// 防止 `]`/`[`/`*` 等注入(同 glActor 的处理,#423 review)。
138+
if tag.UserName != "" {
139+
sb.WriteString("@" + mdInertText(tag.UserName, 64))
140+
}
141+
// img 等依赖 image_key 的标签:无法转存为 URL,丢弃(见文件头注释)。
142+
default:
143+
}
144+
}
145+
if line := strings.TrimRight(sb.String(), " "); line != "" {
146+
lines = append(lines, line)
147+
}
148+
}
149+
return strings.Join(lines, "\n")
150+
}
151+
152+
// pickFeishuLocale 选语言块:zh_cn 优先,en_us 次之,最后兜底任意一个(map 遍历序
153+
// 不稳定,仅作为「至少别丢消息」的最末兜底)。
154+
func pickFeishuLocale(post map[string]feishuPostLocale) feishuPostLocale {
155+
if v, ok := post["zh_cn"]; ok {
156+
return v
157+
}
158+
if v, ok := post["en_us"]; ok {
159+
return v
160+
}
161+
for _, v := range post {
162+
return v
163+
}
164+
return feishuPostLocale{}
165+
}
166+
167+
// renderFeishuCard 把交互卡片降级为 markdown:标题加粗 + div/markdown 元素文本逐行
168+
// 拼接。按钮 / 图片 / 分隔线等交互或素材元素无法复现,丢弃。
169+
func renderFeishuCard(card *feishuCard) string {
170+
if card == nil {
171+
return ""
172+
}
173+
var lines []string
174+
if card.Header != nil {
175+
if title := oneLine(card.Header.Title.Content); title != "" {
176+
lines = append(lines, "**"+title+"**")
177+
}
178+
}
179+
for _, el := range card.Elements {
180+
switch el.Tag {
181+
case "div":
182+
if el.Text != nil {
183+
if t := strings.TrimSpace(el.Text.Content); t != "" {
184+
lines = append(lines, t)
185+
}
186+
}
187+
case "markdown":
188+
if t := strings.TrimSpace(el.Content); t != "" {
189+
lines = append(lines, t)
190+
}
191+
// action / img / hr / note 等:交互或素材元素,丢弃。
192+
default:
193+
}
194+
}
195+
return strings.Join(lines, "\n")
196+
}

0 commit comments

Comments
 (0)