diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7917d2472b..c398f5ef6a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -993,6 +993,25 @@ jobs: --retain 5 \ --apply + notify-feishu: + name: Notify Feishu group + needs: [publish, sync-release-to-cnb] + # Card sends once the release is public even if the CNB mirror sync failed. + if: always() && needs.publish.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Send release card to Feishu + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }} + FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }} + FEISHU_CHAT_ID: ${{ secrets.FEISHU_CHAT_ID }} + run: python3 scripts/notify_feishu.py "${{ github.ref_name }}" + docker: runs-on: ubuntu-latest strategy: diff --git a/scripts/notify_feishu.py b/scripts/notify_feishu.py new file mode 100644 index 0000000000..56e5449be1 --- /dev/null +++ b/scripts/notify_feishu.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Release workflow step: push a Feishu interactive card for a published release. + +Usage: + python3 scripts/notify_feishu.py # send to FEISHU_CHAT_ID + python3 scripts/notify_feishu.py --user ou_xxx # override to a P2P target (for testing) + +Requires env: FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_CHAT_ID (only for chat mode), +GITHUB_TOKEN via `gh` for release notes. +""" +import json +import os +import re +import subprocess +import sys +import urllib.request + +REPO = "t8y2/dbx" +CNB_TAG_URL = "https://cnb.cool/dbxio.com/dbx/-/releases/tag/{tag}" +GH_TAG_URL = "https://github.com/t8y2/dbx/releases/tag/{tag}" + +SECTION_META = [ + ("新功能", "✨ 新功能"), + ("改进", "🔧 改进"), + ("修复", "🐛 修复"), +] +HIGHLIGHT_LIMIT = 5 + +TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" +SEND_URL = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={kind}" + + +def gh_release(tag: str) -> dict: + out = subprocess.run( + ["gh", "release", "view", tag, "--repo", REPO, + "--json", "name,body,isPrerelease,publishedAt"], + capture_output=True, text=True, check=True, + ).stdout + return json.loads(out) + + +def parse_sections(body: str) -> dict: + sections = {} + current = None + for line in body.splitlines(): + m = re.match(r"^###\s+(\S+)", line) + if m: + current = m.group(1) + sections.setdefault(current, []) + continue + if current and line.startswith("- "): + item = line[2:].strip() + item = re.sub(r"\s*\(contributed by @[^)]*\)", "", item) + item = re.sub(r"\s*\(PR \[#\d+\]\([^)]*\)\)", "", item) + if item: + sections[current].append(item) + return sections + + +def build_card(tag: str, rel: dict, sections: dict) -> dict: + name = rel.get("name") or f"DBX {tag}" + prerelease = rel.get("isPrerelease") + title = f"🚀 {name}{'(预发布)' if prerelease else ''}" + total = sum(len(sections.get(k, [])) for k, _ in SECTION_META) + + elements = [] + for idx, (key, label) in enumerate(SECTION_META): + items = sections.get(key, []) + if not items: + continue + shown = items[:HIGHLIGHT_LIMIT] + parts = [f"**{label}** · {len(items)} 项"] + parts += [f"- {it}" for it in shown] + elements.append({"tag": "markdown", "content": "\n".join(parts)}) + if len(items) > len(shown): + elements.append({ + "tag": "collapsible_panel", + "expanded": False, + "header": { + "title": {"tag": "plain_text", "content": f"展开全部 {len(items)} 条"}, + "background_color": "grey", + }, + "elements": [{"tag": "markdown", + "content": "\n".join(f"- {it}" for it in items[len(shown):])}], + }) + if idx < len(SECTION_META) - 1: + elements.append({"tag": "hr"}) + + elements += [ + {"tag": "column_set", "flex_mode": "stretch", "columns": [ + {"tag": "column", "width": "weighted", "weight": 1, "padding": "0", "elements": [ + {"tag": "button", "text": {"tag": "plain_text", "content": f"完整更新日志({total} 项)"}, + "type": "primary", "url": GH_TAG_URL.format(tag=tag)}]}, + {"tag": "column", "width": "weighted", "weight": 1, "padding": "0", "elements": [ + {"tag": "button", "text": {"tag": "plain_text", "content": "国内镜像下载"}, + "type": "default", "url": CNB_TAG_URL.format(tag=tag)}]}, + ]}, + ] + return {"schema": "2.0", + "config": {"update_multi": True}, + "header": {"template": "blue", + "title": {"tag": "plain_text", "content": title}}, + "body": {"elements": elements}} + + +def feishu_post(url: str, payload: dict, token: str = "") -> dict: + req = urllib.request.Request(url, data=json.dumps(payload).encode(), method="POST") + req.add_header("Content-Type", "application/json; charset=utf-8") + if token: + req.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode()) + + +def send(kind: str, receive_id: str, card: dict) -> None: + app_id, app_secret = os.environ["FEISHU_APP_ID"], os.environ["FEISHU_APP_SECRET"] + tok = feishu_post(TOKEN_URL, {"app_id": app_id, "app_secret": app_secret}) + if tok.get("code") != 0: + print("token error:", tok.get("msg"), file=sys.stderr) + sys.exit(1) + res = feishu_post(SEND_URL.format(kind=kind), { + "receive_id": receive_id, + "msg_type": "interactive", + "content": json.dumps(card, ensure_ascii=False), + }, token=tok["tenant_access_token"]) + if res.get("code") != 0: + print("send error:", res.get("code"), res.get("msg"), file=sys.stderr) + sys.exit(1) + print("feishu card sent to", receive_id) + + +def main() -> None: + tag = sys.argv[1] + if len(sys.argv) > 2 and sys.argv[2] == "--user": + kind, receive_id = "open_id", sys.argv[3] + else: + kind, receive_id = "chat_id", os.environ["FEISHU_CHAT_ID"] + rel = gh_release(tag) + sections = parse_sections(rel["body"]) + send(kind, receive_id, build_card(tag, rel, sections)) + + +if __name__ == "__main__": + main()