nonebot-plugin-github-release 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nonebot_plugin_github_release/__init__.py +69 -0
- nonebot_plugin_github_release/card.py +352 -0
- nonebot_plugin_github_release/commands.py +258 -0
- nonebot_plugin_github_release/config.py +70 -0
- nonebot_plugin_github_release/latest.py +60 -0
- nonebot_plugin_github_release/models.py +64 -0
- nonebot_plugin_github_release/plan.py +47 -0
- nonebot_plugin_github_release/render.py +174 -0
- nonebot_plugin_github_release/source.py +170 -0
- nonebot_plugin_github_release/store.py +147 -0
- nonebot_plugin_github_release/versions.py +52 -0
- nonebot_plugin_github_release/watch.py +198 -0
- nonebot_plugin_github_release-0.1.0.dist-info/METADATA +109 -0
- nonebot_plugin_github_release-0.1.0.dist-info/RECORD +16 -0
- nonebot_plugin_github_release-0.1.0.dist-info/WHEEL +4 -0
- nonebot_plugin_github_release-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""nonebot-plugin-github-release
|
|
2
|
+
|
|
3
|
+
监控任意 GitHub 仓库的 release,把新版本推送到 QQ 群。
|
|
4
|
+
|
|
5
|
+
- 数据源:Releases API 为主,被限流时自动切到不限流的 `releases.atom`
|
|
6
|
+
- 推送形式:默认发一张类 GitHub 卡片的图片,渲染失败自动回退纯文本
|
|
7
|
+
- 按群订阅:`/gh订阅 owner/repo`,每个群只收自己关心的仓库
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
|
|
12
|
+
from nonebot import require
|
|
13
|
+
|
|
14
|
+
require("nonebot_plugin_apscheduler")
|
|
15
|
+
|
|
16
|
+
from nonebot.log import logger # noqa: E402
|
|
17
|
+
from nonebot.plugin import PluginMetadata # noqa: E402
|
|
18
|
+
from nonebot_plugin_apscheduler import scheduler # noqa: E402
|
|
19
|
+
|
|
20
|
+
from . import commands as _commands # noqa: E402,F401
|
|
21
|
+
from .config import Config, load_config # noqa: E402
|
|
22
|
+
from .watch import check_once # noqa: E402
|
|
23
|
+
|
|
24
|
+
__all__ = ["Config", "check_once"]
|
|
25
|
+
|
|
26
|
+
__plugin_meta__ = PluginMetadata(
|
|
27
|
+
name="GitHub 发布推送",
|
|
28
|
+
description="监控 GitHub 仓库的 release,把新版本推送到 QQ 群",
|
|
29
|
+
usage=(
|
|
30
|
+
"/gh订阅 owner/repo 让本群订阅(群管)\n"
|
|
31
|
+
"/gh公告 owner/repo 看最新 release\n"
|
|
32
|
+
"/gh状态 看推送状态"
|
|
33
|
+
),
|
|
34
|
+
type="application",
|
|
35
|
+
homepage="https://github.com/NSQX13579/nonebot-plugin-github-release",
|
|
36
|
+
config=Config,
|
|
37
|
+
supported_adapters={"~onebot.v11"},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_JOB_ID = "github_release_watch"
|
|
41
|
+
|
|
42
|
+
plugin_config: Config = load_config()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _scheduled_check() -> None:
|
|
46
|
+
result = await check_once(plugin_config)
|
|
47
|
+
logger.debug(f"[gh-release] 定时检查:{result.status} | {result.detail}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
scheduler.add_job(
|
|
51
|
+
_scheduled_check,
|
|
52
|
+
"interval",
|
|
53
|
+
minutes=max(1, plugin_config.gh_watch_interval_minutes),
|
|
54
|
+
# 启动 30 秒后先跑一轮,不用干等一个完整间隔
|
|
55
|
+
next_run_time=datetime.now() + timedelta(seconds=30),
|
|
56
|
+
id=_JOB_ID,
|
|
57
|
+
replace_existing=True,
|
|
58
|
+
coalesce=True,
|
|
59
|
+
max_instances=1,
|
|
60
|
+
misfire_grace_time=300,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if plugin_config.gh_watch_repos or plugin_config.gh_watch_groups:
|
|
64
|
+
logger.info(
|
|
65
|
+
f"[gh-release] 已启动:每 {plugin_config.gh_watch_interval_minutes} 分钟检查 "
|
|
66
|
+
f"{len(plugin_config.gh_watch_repos)} 个仓库"
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
logger.info("[gh-release] 尚未配置仓库,可在群里用 /gh订阅 owner/repo 添加")
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""把 release 渲染成一张「类 GitHub 卡片」的图片。
|
|
2
|
+
|
|
3
|
+
版式照 GitHub 的 release 卡片做,但只画真实存在的东西——仓库头像、发布时间、
|
|
4
|
+
版本号、正文、表情计数都是 API 给的真实数据;不伪造浏览器外框和可点控件。
|
|
5
|
+
任何一步失败都返回 None,由调用方回退纯文本。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
import re
|
|
12
|
+
from base64 import b64encode
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from nonebot.log import logger
|
|
18
|
+
|
|
19
|
+
from .models import Release
|
|
20
|
+
from .render import clean_body, resolve_timezone
|
|
21
|
+
|
|
22
|
+
_CARD_CSS = """
|
|
23
|
+
* { box-sizing: border-box; }
|
|
24
|
+
html, body { margin: 0; padding: 0; background: #ffffff; }
|
|
25
|
+
body {
|
|
26
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei",
|
|
27
|
+
"PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC",
|
|
28
|
+
"WenQuanYi Micro Hei", sans-serif;
|
|
29
|
+
color: #1f2328;
|
|
30
|
+
-webkit-font-smoothing: antialiased;
|
|
31
|
+
text-rendering: optimizeLegibility;
|
|
32
|
+
}
|
|
33
|
+
.card { width: __WIDTH__px; padding: 20px 24px 22px; }
|
|
34
|
+
.head { display: flex; align-items: center; gap: 10px; }
|
|
35
|
+
.avatar {
|
|
36
|
+
width: 40px; height: 40px; border-radius: 50%; object-fit: cover;
|
|
37
|
+
display: block; background: #d0d7de;
|
|
38
|
+
}
|
|
39
|
+
.avatar-fallback {
|
|
40
|
+
width: 40px; height: 40px; border-radius: 50%; background: #0969da;
|
|
41
|
+
color: #ffffff; font-size: 18px; font-weight: 600; line-height: 40px;
|
|
42
|
+
text-align: center;
|
|
43
|
+
}
|
|
44
|
+
.head-text { display: flex; flex-direction: column; gap: 2px; }
|
|
45
|
+
.repo { font-size: 14px; font-weight: 600; color: #1f2328; }
|
|
46
|
+
.when { font-size: 12px; color: #656d76; }
|
|
47
|
+
.title { font-size: 24px; font-weight: 600; margin: 14px 0 12px; line-height: 1.25; }
|
|
48
|
+
.tag {
|
|
49
|
+
display: inline-block; font-size: 12px; font-weight: 500; color: #1a7f37;
|
|
50
|
+
background: #dafbe1; border: 1px solid #aceebb; border-radius: 100px;
|
|
51
|
+
padding: 1px 8px; margin-left: 8px; vertical-align: 3px;
|
|
52
|
+
}
|
|
53
|
+
.tag.pre { color: #9a6700; background: #fff8c5; border-color: #f5e6a2; }
|
|
54
|
+
.body {
|
|
55
|
+
background: #f6f8fa; border: 1px solid #d8dee4; border-radius: 6px;
|
|
56
|
+
padding: 16px; max-height: __BODY_MAX_HEIGHT__px; overflow: hidden;
|
|
57
|
+
}
|
|
58
|
+
.body h4 { font-size: 16px; font-weight: 600; margin: 14px 0 8px; padding: 0; }
|
|
59
|
+
.body h4:first-child { margin-top: 0; }
|
|
60
|
+
.body p { font-size: 14px; line-height: 1.6; margin: 0 0 10px; }
|
|
61
|
+
.body ul { margin: 0 0 10px; padding-left: 22px; }
|
|
62
|
+
.body li { font-size: 14px; line-height: 1.6; margin: 4px 0; }
|
|
63
|
+
.body hr { border: 0; border-top: 1px solid #d8dee4; margin: 14px 0; }
|
|
64
|
+
.body code {
|
|
65
|
+
font-family: ui-monospace, SFMono-Regular, "Cascadia Mono", Consolas,
|
|
66
|
+
"Liberation Mono", monospace;
|
|
67
|
+
font-size: 12.5px; background: rgba(175, 184, 193, 0.2);
|
|
68
|
+
padding: 1px 5px; border-radius: 6px;
|
|
69
|
+
}
|
|
70
|
+
.body .mention { color: #0969da; font-weight: 500; }
|
|
71
|
+
.foot { margin-top: 12px; font-size: 12.5px; color: #656d76; }
|
|
72
|
+
.foot a { color: #0969da; text-decoration: none; }
|
|
73
|
+
.reactions { display: flex; gap: 6px; margin-top: 14px; flex-wrap: wrap; }
|
|
74
|
+
.reaction {
|
|
75
|
+
display: inline-flex; align-items: center; gap: 5px; font-size: 12px;
|
|
76
|
+
color: #1f2328; background: #f6f8fa; border: 1px solid #d0d7de;
|
|
77
|
+
border-radius: 100px; padding: 3px 10px;
|
|
78
|
+
}
|
|
79
|
+
.reaction .count { font-weight: 600; }
|
|
80
|
+
.truncated { color: #656d76; font-size: 12.5px; margin-top: 8px; }
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
_BULLET_RE = re.compile(r"^(·|\*|-)\s+(.*)$")
|
|
84
|
+
_HEADING_RE = re.compile(r"^▍\s*(.+)$")
|
|
85
|
+
_CODE_RE = re.compile(r"`([^`]+)`")
|
|
86
|
+
_MENTION_RE = re.compile(r"@([A-Za-z0-9][A-Za-z0-9._-]*)")
|
|
87
|
+
|
|
88
|
+
_REACTION_EMOJI: Dict[str, str] = {
|
|
89
|
+
"+1": "👍",
|
|
90
|
+
"-1": "👎",
|
|
91
|
+
"laugh": "😄",
|
|
92
|
+
"hooray": "🎉",
|
|
93
|
+
"confused": "😕",
|
|
94
|
+
"heart": "❤️",
|
|
95
|
+
"rocket": "🚀",
|
|
96
|
+
"eyes": "👀",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# 头像取到一次就记住,避免网络抖动时卡片退化成字母头像
|
|
100
|
+
_AVATAR_MEMORY: Dict[str, str] = {}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def humanize_time(moment: datetime, *, now: Optional[datetime] = None) -> str:
|
|
104
|
+
"""把时间说成「16 小时前」。"""
|
|
105
|
+
if moment.tzinfo is None:
|
|
106
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
107
|
+
now = now or datetime.now(timezone.utc)
|
|
108
|
+
seconds = (now - moment).total_seconds()
|
|
109
|
+
if seconds < 60:
|
|
110
|
+
return "刚刚"
|
|
111
|
+
if seconds < 3600:
|
|
112
|
+
return f"{int(seconds // 60)} 分钟前"
|
|
113
|
+
if seconds < 86400:
|
|
114
|
+
return f"{int(seconds // 3600)} 小时前"
|
|
115
|
+
days = int(seconds // 86400)
|
|
116
|
+
if days == 1:
|
|
117
|
+
return "昨天"
|
|
118
|
+
if days < 30:
|
|
119
|
+
return f"{days} 天前"
|
|
120
|
+
return moment.astimezone(resolve_timezone("Asia/Shanghai")).strftime("%Y-%m-%d")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _inline(text: str) -> str:
|
|
124
|
+
escaped = html.escape(text, quote=False)
|
|
125
|
+
escaped = _CODE_RE.sub(r"<code>\1</code>", escaped)
|
|
126
|
+
escaped = _MENTION_RE.sub(
|
|
127
|
+
lambda m: f'<span class="mention">@{m.group(1)}</span>', escaped
|
|
128
|
+
)
|
|
129
|
+
return escaped
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def body_to_html(text: str) -> str:
|
|
133
|
+
"""把清洗过的正文转成 HTML 片段。"""
|
|
134
|
+
blocks: List[str] = []
|
|
135
|
+
list_open = False
|
|
136
|
+
|
|
137
|
+
def close_list() -> None:
|
|
138
|
+
nonlocal list_open
|
|
139
|
+
if list_open:
|
|
140
|
+
blocks.append("</ul>")
|
|
141
|
+
list_open = False
|
|
142
|
+
|
|
143
|
+
for raw in text.splitlines():
|
|
144
|
+
line = raw.strip()
|
|
145
|
+
if not line:
|
|
146
|
+
continue
|
|
147
|
+
if set(line) == {"-"} and len(line) >= 3:
|
|
148
|
+
close_list()
|
|
149
|
+
blocks.append("<hr>")
|
|
150
|
+
continue
|
|
151
|
+
heading = _HEADING_RE.match(line)
|
|
152
|
+
if heading:
|
|
153
|
+
close_list()
|
|
154
|
+
blocks.append(f"<h4>{_inline(heading.group(1))}</h4>")
|
|
155
|
+
continue
|
|
156
|
+
bullet = _BULLET_RE.match(line)
|
|
157
|
+
if bullet:
|
|
158
|
+
if not list_open:
|
|
159
|
+
blocks.append("<ul>")
|
|
160
|
+
list_open = True
|
|
161
|
+
blocks.append(f"<li>{_inline(bullet.group(2))}</li>")
|
|
162
|
+
continue
|
|
163
|
+
close_list()
|
|
164
|
+
blocks.append(f"<p>{_inline(line)}</p>")
|
|
165
|
+
|
|
166
|
+
close_list()
|
|
167
|
+
return "\n".join(blocks)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _reaction_html(reactions: Optional[Dict[str, int]]) -> str:
|
|
171
|
+
if not reactions:
|
|
172
|
+
return ""
|
|
173
|
+
items: List[str] = []
|
|
174
|
+
for key, emoji in _REACTION_EMOJI.items():
|
|
175
|
+
count = int(reactions.get(key, 0) or 0)
|
|
176
|
+
if count <= 0:
|
|
177
|
+
continue
|
|
178
|
+
items.append(f'<span class="reaction">{emoji}<span class="count">{count}</span></span>')
|
|
179
|
+
if not items:
|
|
180
|
+
return ""
|
|
181
|
+
return f'<div class="reactions">{"".join(items)}</div>'
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def build_card_html(
|
|
185
|
+
release: Release,
|
|
186
|
+
*,
|
|
187
|
+
repo: str,
|
|
188
|
+
avatar_uri: Optional[str] = None,
|
|
189
|
+
max_chars: int = 2400,
|
|
190
|
+
show_reactions: bool = True,
|
|
191
|
+
cut_foreign: bool = True,
|
|
192
|
+
width: int = 900,
|
|
193
|
+
body_max_height: int = 1500,
|
|
194
|
+
) -> str:
|
|
195
|
+
"""生成卡片 HTML。正文保留 @提及(渲染成蓝字),它不影响任何人。"""
|
|
196
|
+
cleaned = clean_body(release.body, strip_mentions=False, cut_foreign=cut_foreign)
|
|
197
|
+
truncated = False
|
|
198
|
+
if max_chars > 0 and len(cleaned) > max_chars:
|
|
199
|
+
cleaned = cleaned[:max_chars].rstrip()
|
|
200
|
+
truncated = True
|
|
201
|
+
|
|
202
|
+
if avatar_uri:
|
|
203
|
+
avatar = (
|
|
204
|
+
f'<img class="avatar" src="{html.escape(avatar_uri, quote=True)}" '
|
|
205
|
+
f'alt="{html.escape(repo)}">'
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
initial = html.escape((repo.split("/")[0] or "g")[:1].upper())
|
|
209
|
+
avatar = f'<div class="avatar-fallback">{initial}</div>'
|
|
210
|
+
|
|
211
|
+
stage = "预发布" if release.prerelease else "正式版"
|
|
212
|
+
body_html = body_to_html(cleaned) or "<p>(这条 release 没有正文)</p>"
|
|
213
|
+
if truncated:
|
|
214
|
+
body_html += '<div class="truncated">……正文较长,完整内容见下方链接</div>'
|
|
215
|
+
|
|
216
|
+
reactions = _reaction_html(release.reactions) if show_reactions else ""
|
|
217
|
+
|
|
218
|
+
return f"""<!DOCTYPE html>
|
|
219
|
+
<html lang="zh-CN">
|
|
220
|
+
<head>
|
|
221
|
+
<meta charset="utf-8">
|
|
222
|
+
<style>{_CARD_CSS.replace("__WIDTH__", str(width)).replace("__BODY_MAX_HEIGHT__", str(body_max_height))}</style>
|
|
223
|
+
</head>
|
|
224
|
+
<body>
|
|
225
|
+
<div class="card">
|
|
226
|
+
<div class="head">
|
|
227
|
+
{avatar}
|
|
228
|
+
<div class="head-text">
|
|
229
|
+
<div class="repo">{html.escape(repo)} 发布</div>
|
|
230
|
+
<div class="when">{html.escape(humanize_time(release.published_at))}</div>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
<div class="title">{html.escape(release.title)}<span class="tag{" pre" if release.prerelease else ""}">{stage}</span></div>
|
|
234
|
+
<div class="body">
|
|
235
|
+
{body_html}
|
|
236
|
+
</div>
|
|
237
|
+
<div class="foot">完整公告:<a href="{html.escape(release.url, quote=True)}">{html.escape(release.url)}</a></div>
|
|
238
|
+
{reactions}
|
|
239
|
+
</div>
|
|
240
|
+
</body>
|
|
241
|
+
</html>"""
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def render_card(
|
|
245
|
+
release: Release,
|
|
246
|
+
*,
|
|
247
|
+
repo: str,
|
|
248
|
+
max_chars: int = 2400,
|
|
249
|
+
show_reactions: bool = True,
|
|
250
|
+
cut_foreign: bool = True,
|
|
251
|
+
width: int = 900,
|
|
252
|
+
body_max_height: int = 1500,
|
|
253
|
+
scale: float = 2.0,
|
|
254
|
+
) -> Optional[bytes]:
|
|
255
|
+
"""渲染卡片图片;失败返回 None,由调用方回退纯文本。"""
|
|
256
|
+
try:
|
|
257
|
+
import nonebot_plugin_htmlrender as htmlrender
|
|
258
|
+
except ImportError:
|
|
259
|
+
logger.warning(
|
|
260
|
+
"[gh-release] 未安装 nonebot-plugin-htmlrender,卡片功能不可用,将发送文本"
|
|
261
|
+
)
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
card_html = build_card_html(
|
|
265
|
+
release,
|
|
266
|
+
repo=repo,
|
|
267
|
+
avatar_uri=await load_avatar_uri(repo, release.author_avatar_url),
|
|
268
|
+
max_chars=max_chars,
|
|
269
|
+
show_reactions=show_reactions,
|
|
270
|
+
cut_foreign=cut_foreign,
|
|
271
|
+
width=width,
|
|
272
|
+
body_max_height=body_max_height,
|
|
273
|
+
)
|
|
274
|
+
try:
|
|
275
|
+
return await htmlrender.html_to_pic(
|
|
276
|
+
card_html,
|
|
277
|
+
# 视口高度给小值,full_page 才会贴着内容走,底部不留空白
|
|
278
|
+
viewport={"width": width, "height": 100},
|
|
279
|
+
device_scale_factor=scale,
|
|
280
|
+
)
|
|
281
|
+
except Exception as exc: # noqa: BLE001 - 渲染失败姿势很多,统一降级
|
|
282
|
+
logger.warning(f"[gh-release] 卡片渲染失败,回退文本:{exc.__class__.__name__}: {exc}")
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def load_avatar_uri(repo: str, fallback_url: str = "") -> Optional[str]:
|
|
287
|
+
"""把仓库头像下载下来内联成 data URI,并缓存到内存与磁盘。
|
|
288
|
+
|
|
289
|
+
优先用仓库主的头像(GitHub 的 `/{owner}.png`),跟真实卡片一致。
|
|
290
|
+
"""
|
|
291
|
+
if repo in _AVATAR_MEMORY:
|
|
292
|
+
return _AVATAR_MEMORY[repo]
|
|
293
|
+
|
|
294
|
+
cache_file = _avatar_cache_file(repo)
|
|
295
|
+
if cache_file is not None and cache_file.exists():
|
|
296
|
+
try:
|
|
297
|
+
cached = cache_file.read_bytes()
|
|
298
|
+
except OSError:
|
|
299
|
+
cached = b""
|
|
300
|
+
if cached:
|
|
301
|
+
uri = f"data:image/png;base64,{b64encode(cached).decode()}"
|
|
302
|
+
_AVATAR_MEMORY[repo] = uri
|
|
303
|
+
return uri
|
|
304
|
+
|
|
305
|
+
candidates = []
|
|
306
|
+
owner = repo.split("/")[0].strip()
|
|
307
|
+
if owner:
|
|
308
|
+
candidates.append(f"https://github.com/{owner}.png?size=96")
|
|
309
|
+
if fallback_url:
|
|
310
|
+
candidates.append(fallback_url)
|
|
311
|
+
|
|
312
|
+
import httpx
|
|
313
|
+
|
|
314
|
+
for url in candidates:
|
|
315
|
+
try:
|
|
316
|
+
async with httpx.AsyncClient(timeout=8.0, follow_redirects=True) as client:
|
|
317
|
+
response = await client.get(
|
|
318
|
+
url, headers={"User-Agent": "nonebot-plugin-github-release"}
|
|
319
|
+
)
|
|
320
|
+
response.raise_for_status()
|
|
321
|
+
except Exception as exc: # noqa: BLE001 - 头像拿不到不算错误
|
|
322
|
+
logger.debug(f"[gh-release] 头像获取失败({url}):{exc}")
|
|
323
|
+
continue
|
|
324
|
+
content = response.content
|
|
325
|
+
if not content or len(content) > 512 * 1024:
|
|
326
|
+
continue
|
|
327
|
+
mime = response.headers.get("content-type", "image/png").split(";")[0]
|
|
328
|
+
if not mime.startswith("image/"):
|
|
329
|
+
continue
|
|
330
|
+
if cache_file is not None:
|
|
331
|
+
try:
|
|
332
|
+
cache_file.write_bytes(content)
|
|
333
|
+
except OSError as exc:
|
|
334
|
+
logger.debug(f"[gh-release] 头像缓存写入失败:{exc}")
|
|
335
|
+
uri = f"data:{mime};base64,{b64encode(content).decode()}"
|
|
336
|
+
_AVATAR_MEMORY[repo] = uri
|
|
337
|
+
return uri
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _avatar_cache_file(repo: str) -> Optional[Path]:
|
|
342
|
+
"""头像磁盘缓存路径,拿不到就返回 None。"""
|
|
343
|
+
try:
|
|
344
|
+
from .config import load_config
|
|
345
|
+
from .store import resolve_data_dir
|
|
346
|
+
|
|
347
|
+
directory = resolve_data_dir(load_config()) / "avatar"
|
|
348
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
return directory / f"{repo.replace('/', '_')}.png"
|
|
350
|
+
except Exception as exc: # noqa: BLE001 - 缓存不可用不影响主流程
|
|
351
|
+
logger.debug(f"[gh-release] 头像缓存目录不可用:{exc}")
|
|
352
|
+
return None
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""群内指令。
|
|
2
|
+
|
|
3
|
+
命名约定:`gh` + 一个名词。命令名一律按字面注册(不用 `CommandGroup`,
|
|
4
|
+
它会把子命令拼成带分隔符的元组,导致文档里写的命令根本触发不了)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Dict, List, Optional, Set
|
|
11
|
+
|
|
12
|
+
from nonebot import get_driver, on_command
|
|
13
|
+
from nonebot.adapters import Event
|
|
14
|
+
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageEvent
|
|
15
|
+
from nonebot.log import logger
|
|
16
|
+
from nonebot.params import CommandArg
|
|
17
|
+
|
|
18
|
+
from .config import load_config
|
|
19
|
+
from .latest import fetch_announcements, render_list
|
|
20
|
+
from .render import format_iso_local, render_release
|
|
21
|
+
from .source import ReleaseFetchError
|
|
22
|
+
from .store import (
|
|
23
|
+
load_states,
|
|
24
|
+
load_subscriptions,
|
|
25
|
+
resolve_data_dir,
|
|
26
|
+
save_subscriptions,
|
|
27
|
+
state_path,
|
|
28
|
+
subscriptions_path,
|
|
29
|
+
)
|
|
30
|
+
from .watch import build_announce_message, check_once, effective_subscriptions
|
|
31
|
+
|
|
32
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_superuser(event: Event) -> bool:
|
|
36
|
+
return event.get_user_id() in get_driver().config.superusers
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _is_group_admin(bot: Bot, event: Event) -> bool:
|
|
40
|
+
if not isinstance(event, GroupMessageEvent):
|
|
41
|
+
return False
|
|
42
|
+
try:
|
|
43
|
+
info = await bot.get_group_member_info(
|
|
44
|
+
group_id=event.group_id, user_id=event.user_id
|
|
45
|
+
)
|
|
46
|
+
except Exception as exc: # noqa: BLE001 - 拿不到成员信息时按无权限处理
|
|
47
|
+
logger.debug(f"[gh-release] 查询群成员信息失败:{exc}")
|
|
48
|
+
return False
|
|
49
|
+
return str(info.get("role", "")) in ("owner", "admin")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_repo(text: str) -> Optional[str]:
|
|
53
|
+
"""从参数里取 owner/repo。"""
|
|
54
|
+
candidate = text.strip().strip("/")
|
|
55
|
+
if candidate.startswith("https://github.com/"):
|
|
56
|
+
candidate = candidate[len("https://github.com/") :]
|
|
57
|
+
candidate = candidate.split("/releases")[0].strip("/")
|
|
58
|
+
return candidate if _REPO_RE.match(candidate) else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _group_subscriptions(cfg, event: Event) -> Set[str]:
|
|
62
|
+
if not isinstance(event, GroupMessageEvent):
|
|
63
|
+
return set()
|
|
64
|
+
subs = effective_subscriptions(cfg, load_subscriptions(subscriptions_path(resolve_data_dir(cfg))))
|
|
65
|
+
return {repo for repo, groups in subs.items() if event.group_id in groups}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
help_cmd = on_command("gh帮助", aliases={"gh指令"}, priority=5, block=True)
|
|
69
|
+
list_cmd = on_command("gh列表", priority=5, block=True)
|
|
70
|
+
subscribe_cmd = on_command("gh订阅", priority=5, block=True)
|
|
71
|
+
unsubscribe_cmd = on_command("gh退订", priority=5, block=True)
|
|
72
|
+
announce_cmd = on_command("gh公告", priority=5, block=True)
|
|
73
|
+
status_cmd = on_command("gh状态", priority=5, block=True)
|
|
74
|
+
check_cmd = on_command("gh检查", priority=5, block=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@help_cmd.handle()
|
|
78
|
+
async def _handle_help() -> None:
|
|
79
|
+
cfg = load_config()
|
|
80
|
+
await help_cmd.finish(
|
|
81
|
+
"\n".join(
|
|
82
|
+
[
|
|
83
|
+
"【GitHub 发布推送】",
|
|
84
|
+
f"轮询间隔:{cfg.gh_watch_interval_minutes} 分钟",
|
|
85
|
+
f"推送范围:{'含 alpha / rc' if cfg.gh_watch_include_prerelease else '仅正式版'}",
|
|
86
|
+
"",
|
|
87
|
+
"指令:",
|
|
88
|
+
"/gh订阅 owner/repo 本群订阅某个仓库(群管)",
|
|
89
|
+
"/gh退订 owner/repo 取消订阅(群管)",
|
|
90
|
+
"/gh列表 看本群订阅了哪些",
|
|
91
|
+
"/gh公告 owner/repo 看最新一条 release 全文",
|
|
92
|
+
"/gh公告 owner/repo 5 列最近 5 条",
|
|
93
|
+
"/gh状态 推送状态",
|
|
94
|
+
"/gh检查 立刻检查一次(超管)",
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@list_cmd.handle()
|
|
101
|
+
async def _handle_list(event: MessageEvent) -> None:
|
|
102
|
+
cfg = load_config()
|
|
103
|
+
if not isinstance(event, GroupMessageEvent):
|
|
104
|
+
await list_cmd.finish("这个命令只能在群里用")
|
|
105
|
+
repos = _group_subscriptions(cfg, event)
|
|
106
|
+
if not repos:
|
|
107
|
+
await list_cmd.finish("本群还没有订阅任何仓库,用 /gh订阅 owner/repo 添加")
|
|
108
|
+
await list_cmd.finish("本群订阅的仓库:\n" + "\n".join(f"· {repo}" for repo in sorted(repos)))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@subscribe_cmd.handle()
|
|
112
|
+
async def _handle_subscribe(bot: Bot, event: MessageEvent, args: Message = CommandArg()) -> None:
|
|
113
|
+
if not isinstance(event, GroupMessageEvent):
|
|
114
|
+
await subscribe_cmd.finish("这个命令只能在群里用")
|
|
115
|
+
if not (_is_superuser(event) or await _is_group_admin(bot, event)):
|
|
116
|
+
await subscribe_cmd.finish("只有群管理员或机器人超管可以改订阅")
|
|
117
|
+
|
|
118
|
+
repo = _parse_repo(args.extract_plain_text())
|
|
119
|
+
if repo is None:
|
|
120
|
+
await subscribe_cmd.finish("用法:/gh订阅 owner/repo")
|
|
121
|
+
|
|
122
|
+
cfg = load_config()
|
|
123
|
+
path = subscriptions_path(resolve_data_dir(cfg))
|
|
124
|
+
subs: Dict[str, Set[int]] = load_subscriptions(path)
|
|
125
|
+
groups = subs.setdefault(repo, set())
|
|
126
|
+
if event.group_id in groups:
|
|
127
|
+
await subscribe_cmd.finish(f"本群已经订阅 {repo} 了")
|
|
128
|
+
groups.add(event.group_id)
|
|
129
|
+
save_subscriptions(path, subs)
|
|
130
|
+
await subscribe_cmd.finish(f"已订阅 {repo},下一条 release 会自动推送")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@unsubscribe_cmd.handle()
|
|
134
|
+
async def _handle_unsubscribe(bot: Bot, event: MessageEvent, args: Message = CommandArg()) -> None:
|
|
135
|
+
if not isinstance(event, GroupMessageEvent):
|
|
136
|
+
await unsubscribe_cmd.finish("这个命令只能在群里用")
|
|
137
|
+
if not (_is_superuser(event) or await _is_group_admin(bot, event)):
|
|
138
|
+
await unsubscribe_cmd.finish("只有群管理员或机器人超管可以改订阅")
|
|
139
|
+
|
|
140
|
+
cfg = load_config()
|
|
141
|
+
repos = sorted(_group_subscriptions(cfg, event))
|
|
142
|
+
repo = _parse_repo(args.extract_plain_text())
|
|
143
|
+
if repo is None:
|
|
144
|
+
if len(repos) == 1:
|
|
145
|
+
repo = repos[0]
|
|
146
|
+
else:
|
|
147
|
+
await unsubscribe_cmd.finish(
|
|
148
|
+
"用法:/gh退订 owner/repo\n本群当前订阅:" + ("、".join(repos) or "(无)")
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
path = subscriptions_path(resolve_data_dir(cfg))
|
|
152
|
+
subs = load_subscriptions(path)
|
|
153
|
+
if event.group_id not in subs.get(repo, set()):
|
|
154
|
+
await unsubscribe_cmd.finish(f"本群没有订阅 {repo}")
|
|
155
|
+
subs[repo].discard(event.group_id)
|
|
156
|
+
save_subscriptions(path, subs)
|
|
157
|
+
await unsubscribe_cmd.finish(f"已退订 {repo}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@announce_cmd.handle()
|
|
161
|
+
async def _handle_announce(event: MessageEvent, args: Message = CommandArg()) -> None:
|
|
162
|
+
"""查看某个仓库的 release:默认最新一条,数字列简表,其他按版本号检索。"""
|
|
163
|
+
cfg = load_config()
|
|
164
|
+
parts = args.extract_plain_text().strip().split()
|
|
165
|
+
|
|
166
|
+
repo: Optional[str] = None
|
|
167
|
+
keyword = ""
|
|
168
|
+
if parts:
|
|
169
|
+
repo = _parse_repo(parts[0])
|
|
170
|
+
if repo is not None:
|
|
171
|
+
keyword = " ".join(parts[1:]).strip()
|
|
172
|
+
if repo is None:
|
|
173
|
+
candidates = sorted(_group_subscriptions(cfg, event))
|
|
174
|
+
if not parts and len(candidates) == 1:
|
|
175
|
+
repo = candidates[0]
|
|
176
|
+
else:
|
|
177
|
+
await announce_cmd.finish(
|
|
178
|
+
"用法:/gh公告 owner/repo [数量|版本号]\n本群订阅:" + ("、".join(candidates) or "(无)")
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
releases, _source = await fetch_announcements(cfg, repo)
|
|
183
|
+
except ReleaseFetchError as exc:
|
|
184
|
+
await announce_cmd.finish(f"拉取 {repo} 失败:{exc}")
|
|
185
|
+
|
|
186
|
+
if not releases:
|
|
187
|
+
await announce_cmd.finish(f"{repo} 暂时没有可展示的 release")
|
|
188
|
+
|
|
189
|
+
if keyword.isdigit():
|
|
190
|
+
count = max(1, min(int(keyword), 10))
|
|
191
|
+
await announce_cmd.finish(
|
|
192
|
+
render_list(
|
|
193
|
+
list(reversed(releases[-count:])),
|
|
194
|
+
repo=repo,
|
|
195
|
+
tz_name=cfg.gh_watch_timezone,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if keyword:
|
|
200
|
+
needle = keyword.lower()
|
|
201
|
+
matched = [
|
|
202
|
+
release
|
|
203
|
+
for release in releases
|
|
204
|
+
if needle in release.tag.lower() or needle in release.name.lower()
|
|
205
|
+
]
|
|
206
|
+
if not matched:
|
|
207
|
+
await announce_cmd.finish(f"{repo} 没有包含「{keyword}」的 release")
|
|
208
|
+
release = matched[0]
|
|
209
|
+
else:
|
|
210
|
+
release = releases[-1]
|
|
211
|
+
|
|
212
|
+
if cfg.gh_watch_send_image:
|
|
213
|
+
await announce_cmd.finish(await build_announce_message(release, repo, cfg))
|
|
214
|
+
await announce_cmd.finish(
|
|
215
|
+
render_release(
|
|
216
|
+
release,
|
|
217
|
+
repo=repo,
|
|
218
|
+
tz_name=cfg.gh_watch_timezone,
|
|
219
|
+
max_chars=cfg.gh_watch_query_max_chars,
|
|
220
|
+
strip_mentions=cfg.gh_watch_strip_mentions,
|
|
221
|
+
cut_foreign=cfg.gh_watch_body_language != "all",
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@status_cmd.handle()
|
|
227
|
+
async def _handle_status() -> None:
|
|
228
|
+
cfg = load_config()
|
|
229
|
+
data_dir = resolve_data_dir(cfg)
|
|
230
|
+
states = load_states(state_path(data_dir))
|
|
231
|
+
subs = effective_subscriptions(cfg, load_subscriptions(subscriptions_path(data_dir)))
|
|
232
|
+
|
|
233
|
+
lines = ["【GitHub 发布推送 状态】", f"订阅仓库:{len(subs)} 个"]
|
|
234
|
+
if not subs:
|
|
235
|
+
lines.append("(还没有订阅,用 /gh订阅 owner/repo 添加)")
|
|
236
|
+
for repo, groups in sorted(subs.items()):
|
|
237
|
+
state = states.get(repo)
|
|
238
|
+
latest = state.latest if state else None
|
|
239
|
+
source = state.last_source if state else None
|
|
240
|
+
error = state.last_error if state else None
|
|
241
|
+
checked = state.last_check if state else None
|
|
242
|
+
lines.append(
|
|
243
|
+
f"· {repo}|群 {len(groups)} 个|最新 {latest or '-'}"
|
|
244
|
+
f"|源 {source or '-'}|检查 {format_iso_local(checked, cfg.gh_watch_timezone)}"
|
|
245
|
+
)
|
|
246
|
+
if error:
|
|
247
|
+
lines.append(f" 最近错误:{error}")
|
|
248
|
+
lines.append("")
|
|
249
|
+
lines.append(f"轮询间隔:{cfg.gh_watch_interval_minutes} 分钟|全部指令:/gh帮助")
|
|
250
|
+
await status_cmd.finish("\n".join(lines))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@check_cmd.handle()
|
|
254
|
+
async def _handle_check(event: MessageEvent) -> None:
|
|
255
|
+
if not _is_superuser(event):
|
|
256
|
+
await check_cmd.finish("这个命令只有机器人超管能用;看公告用 /gh公告")
|
|
257
|
+
result = await check_once()
|
|
258
|
+
await check_cmd.finish(f"[gh-release] {result.status}:{result.detail}")
|