diff --git a/formatters.py b/formatters.py index 6a0fefc..fa4fc2d 100644 --- a/formatters.py +++ b/formatters.py @@ -523,3 +523,80 @@ def format_pr_details(repo: str, pr_data: dict[str, Any]) -> str: result += f"\n链接: {pr_data['html_url']}" return result + + +def format_webhook_push_message( + repo: str, + payload: dict[str, Any], + sender: dict[str, Any] | None, +) -> str | None: + ref = payload.get("ref", "") + branch_or_tag = ref.split("/")[-1] if "/" in ref else ref + + commits = payload.get("commits", []) + if not commits: + return None + + actor = (sender or {}).get("login") or "未知" + + message_lines = [ + f"[GitHub Webhook] 仓库 {repo} 代码推送", + f"分支: {branch_or_tag}", + f"触发人: {actor}", + f"新增 {len(commits)} 个提交:" + ] + + for commit in commits[:3]: + msg = commit.get("message", "").split("\n")[0] + sha = commit.get("id", "")[:7] + message_lines.append(f"- {sha} {msg}") + + if len(commits) > 3: + message_lines.append(f"... 等共 {len(commits)} 个提交") + + compare_url = payload.get("compare") + if compare_url: + message_lines.append(f"链接: {compare_url}") + + return "\n".join(message_lines) + + +def format_webhook_release_message( + repo: str, + action: str, + release: dict[str, Any], + sender: dict[str, Any] | None, +) -> str | None: + action_labels = { + "published": "发布了新版本", + "created": "创建了版本", + "edited": "更新了版本", + "deleted": "删除了版本", + "prereleased": "发布了预发布版本", + "released": "发布了正式版本", + } + + label = action_labels.get(action) + if not label: + return None + + actor = (sender or {}).get("login") or release.get("author", {}).get("login") or "未知" + tag_name = release.get("tag_name", "未知版本") + name = release.get("name") or tag_name + + message_lines = [ + f"[GitHub Webhook] 仓库 {repo} Release 更新", + f"版本: {name} ({tag_name})", + f"事件: {label}", + f"触发人: {actor}", + ] + + body = release.get("body", "") + if body and action in ["published", "released", "prereleased"]: + message_lines.append("发布说明:") + message_lines.append(truncate_text(body, limit=200)) + + if release.get("html_url"): + message_lines.append(f"链接: {release['html_url']}") + + return "\n".join(message_lines) diff --git a/main.py b/main.py index 17aa553..f84e2e8 100644 --- a/main.py +++ b/main.py @@ -30,6 +30,8 @@ "https://api.github.com/repos/{repo}/readme" # 新增 README API URL ) GITHUB_ISSUES_API_URL = "https://api.github.com/repos/{repo}/issues" +GITHUB_COMMITS_API_URL = "https://api.github.com/repos/{repo}/commits" +GITHUB_RELEASES_API_URL = "https://api.github.com/repos/{repo}/releases" GITHUB_ISSUE_API_URL = "https://api.github.com/repos/{repo}/issues/{issue_number}" GITHUB_PR_API_URL = "https://api.github.com/repos/{repo}/pulls/{pr_number}" GITHUB_RATE_LIMIT_URL = "https://api.github.com/rate_limit" @@ -409,7 +411,7 @@ async def _check_all_repos(self): logger.error(f"检查仓库 {repo} 更新时出错: {e}") async def _fetch_new_items(self, repo: str, last_check: str | None): - """Fetch new issues and PRs from a repository since last check""" + """Fetch new issues, PRs, commits, and releases from a repository since last check""" if not last_check: # If first time checking, just record current time and return empty list # Store as UTC timestamp without timezone info to avoid comparison issues @@ -431,54 +433,100 @@ async def _fetch_new_items(self, repo: str, last_check: str | None): logger.debug(f"仓库 {repo} 的上次检查时间: {last_check_dt.isoformat()}") new_items = [] - # GitHub API returns both issues and PRs in the issues endpoint async with aiohttp.ClientSession() as session: + # 1. Fetch Issues / PRs try: - params = { + params_issues = { "sort": "created", "direction": "desc", "state": "all", "per_page": 10, + "since": last_check_dt.isoformat() + "Z", } async with session.get( GITHUB_ISSUES_API_URL.format(repo=repo), - params=params, + params=params_issues, headers=self._get_github_headers(), ) as resp: if resp.status == 200: items = await resp.json() - for item in items: - # Convert GitHub's timestamp to naive UTC datetime for consistent comparison github_timestamp = item["created_at"].replace("Z", "") - created_at = datetime.fromisoformat(github_timestamp) - - # Always remove timezone info for comparison - created_at = created_at.replace(tzinfo=None) - - logger.debug( - f"比较: 仓库 {repo} 的 item #{item['number']} 创建于 {created_at.isoformat()}, 上次检查: {last_check_dt.isoformat()}" - ) - + created_at = datetime.fromisoformat(github_timestamp).replace(tzinfo=None) if created_at > last_check_dt: - logger.info( - f"发现新的 item #{item['number']} in {repo}" - ) + logger.info(f"发现新的 item #{item.get('number')} in {repo}") new_items.append(item) else: - # Since items are sorted by creation time, we can break early - logger.debug(f"没有更多新 items in {repo}") break else: text = await resp.text() - if len(text) > 100: - text = text[:100] + "..." - logger.error( - f"获取仓库 {repo} 的 Issue/PR 失败: {resp.status}: {text}" - ) + logger.error(f"获取仓库 {repo} 的 Issue/PR 失败: {resp.status}: {text[:100]}") except Exception as e: logger.error(f"获取仓库 {repo} 的 Issue/PR 时出错: {e}") + # 2. Fetch Commits + try: + params_commits = { + "per_page": 100, + "since": last_check_dt.isoformat() + "Z", + } + async with session.get( + GITHUB_COMMITS_API_URL.format(repo=repo), + params=params_commits, + headers=self._get_github_headers(), + ) as resp: + if resp.status == 200: + commits = await resp.json() + if isinstance(commits, list): + for commit in commits: + commit_date_str = commit.get("commit", {}).get("committer", {}).get("date", "") + if not commit_date_str: + continue + github_timestamp = commit_date_str.replace("Z", "") + created_at = datetime.fromisoformat(github_timestamp).replace(tzinfo=None) + if created_at > last_check_dt: + logger.info(f"发现新的 commit {commit.get('sha')[:7]} in {repo}") + commit["_astrbot_type"] = "commit" + # To pass branch info in notifier we can't easily get it here, but we can just say "代码推送" + new_items.append(commit) + else: + break + else: + text = await resp.text() + logger.error(f"获取仓库 {repo} 的 Commits 失败: {resp.status}: {text[:100]}") + except Exception as e: + logger.error(f"获取仓库 {repo} 的 Commits 时出错: {e}") + + # 3. Fetch Releases + try: + params_releases = {"per_page": 5} + # releases API doesn't support 'since', we rely on sorting (default is by created_at desc) + async with session.get( + GITHUB_RELEASES_API_URL.format(repo=repo), + params=params_releases, + headers=self._get_github_headers(), + ) as resp: + if resp.status == 200: + releases = await resp.json() + if isinstance(releases, list): + for release in releases: + release_date_str = release.get("published_at") or release.get("created_at") or "" + if not release_date_str: + continue + github_timestamp = release_date_str.replace("Z", "") + created_at = datetime.fromisoformat(github_timestamp).replace(tzinfo=None) + if created_at > last_check_dt: + logger.info(f"发现新的 release {release.get('tag_name')} in {repo}") + release["_astrbot_type"] = "release" + new_items.append(release) + else: + break + else: + text = await resp.text() + logger.error(f"获取仓库 {repo} 的 Releases 失败: {resp.status}: {text[:100]}") + except Exception as e: + logger.error(f"获取仓库 {repo} 的 Releases 时出错: {e}") + # Update the last check time to now (UTC without timezone info) if new_items: logger.info(f"找到 {len(new_items)} 个新的 items 在 {repo}") @@ -493,9 +541,7 @@ async def _fetch_new_items(self, repo: str, last_check: str | None): return new_items except Exception as e: - logger.error(f"解析时间时出错: {e}") - # If we can't parse the time correctly, just return an empty list - # and update the last check time to prevent continuous errors + logger.error(f"解析时间/获取数据时出错: {e}", exc_info=True) self.last_check_time[repo] = ( datetime.utcnow().replace(microsecond=0).isoformat() ) @@ -515,13 +561,40 @@ async def _notify_subscribers(self, repo: str, new_items: list[dict[str, Any]]): try: # Create notification message for item in new_items: - item_type = "PR" if "pull_request" in item else "Issue" - message = ( - f"[GitHub 更新] 仓库 {repo} 有新的{item_type}:\n" - f"#{item['number']} {item['title']}\n" - f"作者: {item['user']['login']}\n" - f"链接: {item['html_url']}" - ) + if "_astrbot_type" in item: + if item["_astrbot_type"] == "commit": + sha = item.get("sha", "")[:7] + msg = item.get("commit", {}).get("message", "").split("\n")[0] + author = item.get("commit", {}).get("author", {}).get("name", "未知") + url = item.get("html_url", "") + message = ( + f"[GitHub 更新] 仓库 {repo} 有新的代码推送:\n" + f"- {sha} {msg}\n" + f"作者: {author}\n" + f"链接: {url}" + ) + elif item["_astrbot_type"] == "release": + tag_name = item.get("tag_name", "未知版本") + name = item.get("name") or tag_name + author = item.get("author", {}).get("login", "未知") + url = item.get("html_url", "") + message = ( + f"[GitHub 更新] 仓库 {repo} 发布了新版本:\n" + f"版本: {name} ({tag_name})\n" + f"发布者: {author}\n" + f"链接: {url}" + ) + else: + # Fallback if unknown type + continue + else: + item_type = "PR" if "pull_request" in item else "Issue" + message = ( + f"[GitHub 更新] 仓库 {repo} 有新的{item_type}:\n" + f"#{item['number']} {item['title']}\n" + f"作者: {item['user']['login']}\n" + f"链接: {item['html_url']}" + ) # Send message to subscriber await self.context.send_message( @@ -640,6 +713,16 @@ async def handle_webhook_event( message = formatters.format_webhook_create_message( repo_full_name, payload, sender ) + elif event_type == "push": + message = formatters.format_webhook_push_message( + repo_full_name, payload, sender + ) + elif event_type == "release": + release = payload.get("release") + if isinstance(release, dict): + message = formatters.format_webhook_release_message( + repo_full_name, action, release, sender + ) else: logger.debug(f"暂不处理的 GitHub Webhook 事件类型: {event_type}") return diff --git a/metadata.yaml b/metadata.yaml index 98a204e..9aca78a 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,6 +1,6 @@ name: astrbot_plugin_github_cards # 插件名称 desc: GitHub 相关链接自动发送 GitHub 仓库简介/issue/pr卡片、订阅 GitHub 仓库事件 # 插件描述 help: -version: 1.1.0 # 插件版本 +version: 1.1.1 # 插件版本 author: Soulter # 插件作者 repo: https://github.com/Soulter/astrbot_plugin_github_cards diff --git a/webhook_server.py b/webhook_server.py index 27615d2..42e9b01 100644 --- a/webhook_server.py +++ b/webhook_server.py @@ -1,6 +1,7 @@ import asyncio import hashlib import hmac +import json from typing import Any from quart import Quart, Response, request @@ -52,7 +53,18 @@ async def github_webhook(): return Response("missing event", status=400) try: - data = await request.get_json() + if request.is_json: + data = await request.get_json() + else: + form_data = await request.form + if "payload" in form_data: + data = json.loads(form_data["payload"]) + else: + data = None + + if data is None: + logger.warning("GitHub Webhook 缺少有效的 payload 数据") + return Response("invalid payload", status=400) except Exception: logger.warning("GitHub Webhook JSON 解析失败") return Response("invalid payload", status=400)