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,147 @@
|
|
|
1
|
+
"""状态与订阅的持久化(JSON,原子写入)。
|
|
2
|
+
|
|
3
|
+
- `state.json`:每个仓库的推送基线
|
|
4
|
+
- `subscriptions.json`:哪个群订阅了哪个仓库
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional, Set
|
|
15
|
+
|
|
16
|
+
from .config import Config
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_data_dir(cfg: Config) -> Path:
|
|
20
|
+
"""插件数据目录:优先用 nonebot-plugin-localstore,退化为 data/<插件名>。"""
|
|
21
|
+
if cfg.gh_watch_data_dir:
|
|
22
|
+
return Path(cfg.gh_watch_data_dir).expanduser()
|
|
23
|
+
try:
|
|
24
|
+
import nonebot_plugin_localstore as store
|
|
25
|
+
|
|
26
|
+
return store.get_plugin_data_dir()
|
|
27
|
+
except Exception: # noqa: BLE001 - 没装 localstore 或未初始化时退化
|
|
28
|
+
return Path("data") / "github_release"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def state_path(data_dir: Path) -> Path:
|
|
32
|
+
return data_dir / "state.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def subscriptions_path(data_dir: Path) -> Path:
|
|
36
|
+
return data_dir / "subscriptions.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def now_iso() -> str:
|
|
40
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class RepoState:
|
|
45
|
+
"""单个仓库的监控状态。"""
|
|
46
|
+
|
|
47
|
+
initialized: bool = False
|
|
48
|
+
pushed: List[str] = field(default_factory=list)
|
|
49
|
+
latest: Optional[str] = None
|
|
50
|
+
last_check: Optional[str] = None
|
|
51
|
+
last_ok: Optional[str] = None
|
|
52
|
+
last_error: Optional[str] = None
|
|
53
|
+
last_source: Optional[str] = None
|
|
54
|
+
|
|
55
|
+
def copy(self) -> "RepoState":
|
|
56
|
+
return RepoState(
|
|
57
|
+
initialized=self.initialized,
|
|
58
|
+
pushed=list(self.pushed),
|
|
59
|
+
latest=self.latest,
|
|
60
|
+
last_check=self.last_check,
|
|
61
|
+
last_ok=self.last_ok,
|
|
62
|
+
last_error=self.last_error,
|
|
63
|
+
last_source=self.last_source,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_dict(cls, raw: Any) -> "RepoState":
|
|
68
|
+
if not isinstance(raw, dict):
|
|
69
|
+
return cls()
|
|
70
|
+
pushed = raw.get("pushed")
|
|
71
|
+
return cls(
|
|
72
|
+
initialized=bool(raw.get("initialized")),
|
|
73
|
+
pushed=[str(tag) for tag in pushed if isinstance(tag, str)]
|
|
74
|
+
if isinstance(pushed, list)
|
|
75
|
+
else [],
|
|
76
|
+
latest=raw.get("latest") or None,
|
|
77
|
+
last_check=raw.get("last_check") or None,
|
|
78
|
+
last_ok=raw.get("last_ok") or None,
|
|
79
|
+
last_error=raw.get("last_error") or None,
|
|
80
|
+
last_source=raw.get("last_source") or None,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _atomic_write_json(path: Path, payload: Any) -> None:
|
|
85
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
87
|
+
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
88
|
+
os.replace(tmp, path)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _load_json(path: Path) -> Any:
|
|
92
|
+
if not path.exists():
|
|
93
|
+
return None
|
|
94
|
+
try:
|
|
95
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
96
|
+
except (OSError, ValueError):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_states(path: Path) -> Dict[str, RepoState]:
|
|
101
|
+
"""读取所有仓库状态;文件缺失或损坏时返回空表。"""
|
|
102
|
+
raw = _load_json(path)
|
|
103
|
+
if not isinstance(raw, dict):
|
|
104
|
+
return {}
|
|
105
|
+
repos = raw.get("repos")
|
|
106
|
+
source = repos if isinstance(repos, dict) else raw
|
|
107
|
+
return {
|
|
108
|
+
str(name): RepoState.from_dict(value)
|
|
109
|
+
for name, value in source.items()
|
|
110
|
+
if isinstance(name, str)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def save_states(path: Path, states: Dict[str, RepoState]) -> None:
|
|
115
|
+
payload = {"repos": {name: asdict(state) for name, state in sorted(states.items())}}
|
|
116
|
+
for value in payload["repos"].values():
|
|
117
|
+
value["pushed"] = sorted(set(value["pushed"]))
|
|
118
|
+
_atomic_write_json(path, payload)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_subscriptions(path: Path) -> Dict[str, Set[int]]:
|
|
122
|
+
"""读取「仓库 → 订阅群」映射。"""
|
|
123
|
+
raw = _load_json(path)
|
|
124
|
+
if not isinstance(raw, dict):
|
|
125
|
+
return {}
|
|
126
|
+
result: Dict[str, Set[int]] = {}
|
|
127
|
+
for repo, groups in raw.items():
|
|
128
|
+
if not isinstance(repo, str) or not isinstance(groups, list):
|
|
129
|
+
continue
|
|
130
|
+
parsed: Set[int] = set()
|
|
131
|
+
for item in groups:
|
|
132
|
+
try:
|
|
133
|
+
parsed.add(int(item))
|
|
134
|
+
except (TypeError, ValueError):
|
|
135
|
+
continue
|
|
136
|
+
if parsed:
|
|
137
|
+
result[repo] = parsed
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def save_subscriptions(path: Path, subscriptions: Dict[str, Set[int]]) -> None:
|
|
142
|
+
payload = {
|
|
143
|
+
repo: sorted(groups)
|
|
144
|
+
for repo, groups in sorted(subscriptions.items())
|
|
145
|
+
if groups
|
|
146
|
+
}
|
|
147
|
+
_atomic_write_json(path, payload)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""版本号解析与「正式版」判定。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import NamedTuple, Optional
|
|
7
|
+
|
|
8
|
+
# 兼容 `v0.1.5`、`dsh-v0.1.5`、`dsh-v0.1.5-rc.1` 这几种 tag 写法
|
|
9
|
+
_TAG_RE = re.compile(
|
|
10
|
+
r"^(?:[^0-9]*?)v?(\d+)\.(\d+)\.(\d+)"
|
|
11
|
+
r"(?:-([0-9A-Za-z.\-]+))?"
|
|
12
|
+
r"(?:\+[0-9A-Za-z.\-]+)?$"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Version(NamedTuple):
|
|
17
|
+
"""语义化版本。"""
|
|
18
|
+
|
|
19
|
+
major: int
|
|
20
|
+
minor: int
|
|
21
|
+
patch: int
|
|
22
|
+
prerelease: Optional[str]
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def is_stable(self) -> bool:
|
|
26
|
+
"""没有预发布后缀才算正式版。"""
|
|
27
|
+
return self.prerelease is None
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def core(self) -> str:
|
|
31
|
+
return f"{self.major}.{self.minor}.{self.patch}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_version(tag: str) -> Optional[Version]:
|
|
35
|
+
"""解析 tag 中的版本号,解析不出来返回 None。"""
|
|
36
|
+
match = _TAG_RE.match((tag or "").strip())
|
|
37
|
+
if match is None:
|
|
38
|
+
return None
|
|
39
|
+
major, minor, patch, prerelease = match.groups()
|
|
40
|
+
return Version(int(major), int(minor), int(patch), prerelease)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_stable_release(tag: str, *, prerelease_flag: bool = False) -> bool:
|
|
44
|
+
"""判断是否为正式版。
|
|
45
|
+
|
|
46
|
+
两个条件同时满足才算正式版:GitHub 没有标记为 prerelease,且 tag 中没有
|
|
47
|
+
预发布后缀。解析不出版本号的 tag(例如 `nightly`)一律不算,避免误报。
|
|
48
|
+
"""
|
|
49
|
+
if prerelease_flag:
|
|
50
|
+
return False
|
|
51
|
+
version = parse_version(tag)
|
|
52
|
+
return bool(version and version.is_stable)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""定时检查 + 推送。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Iterable, List, Optional, Set, Tuple
|
|
8
|
+
|
|
9
|
+
import nonebot
|
|
10
|
+
from nonebot.adapters.onebot.v11 import Bot as OneBotV11Bot
|
|
11
|
+
from nonebot.adapters.onebot.v11 import Message, MessageSegment
|
|
12
|
+
from nonebot.log import logger
|
|
13
|
+
|
|
14
|
+
from .card import render_card
|
|
15
|
+
from .config import Config, load_config
|
|
16
|
+
from .models import Release
|
|
17
|
+
from .plan import plan, select_releases
|
|
18
|
+
from .render import render_release
|
|
19
|
+
from .source import ReleaseFetchError, fetch_releases
|
|
20
|
+
from .store import (
|
|
21
|
+
RepoState,
|
|
22
|
+
load_states,
|
|
23
|
+
load_subscriptions,
|
|
24
|
+
now_iso,
|
|
25
|
+
resolve_data_dir,
|
|
26
|
+
save_states,
|
|
27
|
+
state_path,
|
|
28
|
+
subscriptions_path,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CheckResult:
|
|
34
|
+
"""一次检查的结果。"""
|
|
35
|
+
|
|
36
|
+
status: str
|
|
37
|
+
detail: str
|
|
38
|
+
repo_count: int = 0
|
|
39
|
+
pushed: List[str] = field(default_factory=list)
|
|
40
|
+
failed: List[str] = field(default_factory=list)
|
|
41
|
+
errors: List[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def effective_subscriptions(
|
|
45
|
+
cfg: Config, stored: Dict[str, Set[int]]
|
|
46
|
+
) -> Dict[str, Set[int]]:
|
|
47
|
+
"""配置里的默认订阅 + 群内动态订阅。
|
|
48
|
+
|
|
49
|
+
`GH_WATCH_REPOS × GH_WATCH_GROUPS` 的组合等价于「这些群都订阅这些仓库」。
|
|
50
|
+
"""
|
|
51
|
+
result: Dict[str, Set[int]] = {repo: set(groups) for repo, groups in stored.items()}
|
|
52
|
+
default_groups = set(cfg.gh_watch_groups)
|
|
53
|
+
for repo in cfg.gh_watch_repos:
|
|
54
|
+
result.setdefault(repo, set()).update(default_groups)
|
|
55
|
+
return {repo: groups for repo, groups in result.items() if groups}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def build_announce_message(release: Release, repo: str, cfg: Config) -> "str | Message":
|
|
59
|
+
"""构造要发送的消息:优先卡片图,失败回退纯文本。"""
|
|
60
|
+
if cfg.gh_watch_send_image:
|
|
61
|
+
png = await render_card(
|
|
62
|
+
release,
|
|
63
|
+
repo=repo,
|
|
64
|
+
max_chars=cfg.gh_watch_card_max_chars,
|
|
65
|
+
show_reactions=cfg.gh_watch_card_show_reactions,
|
|
66
|
+
cut_foreign=cfg.gh_watch_body_language != "all",
|
|
67
|
+
width=cfg.gh_watch_card_width,
|
|
68
|
+
body_max_height=cfg.gh_watch_card_max_height,
|
|
69
|
+
)
|
|
70
|
+
if png is not None:
|
|
71
|
+
stage = "预发布" if release.prerelease else "正式版"
|
|
72
|
+
# 图片里的链接点不了,附一行文字方便复制
|
|
73
|
+
caption = f"【{repo} {stage}】{release.title}\n完整公告:{release.url}"
|
|
74
|
+
return Message(MessageSegment.image(png)) + f"\n{caption}"
|
|
75
|
+
|
|
76
|
+
return render_release(
|
|
77
|
+
release,
|
|
78
|
+
repo=repo,
|
|
79
|
+
tz_name=cfg.gh_watch_timezone,
|
|
80
|
+
max_chars=cfg.gh_watch_max_chars,
|
|
81
|
+
strip_mentions=cfg.gh_watch_strip_mentions,
|
|
82
|
+
cut_foreign=cfg.gh_watch_body_language != "all",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def push_to_groups(
|
|
87
|
+
message: "str | Message", groups: Iterable[int]
|
|
88
|
+
) -> Tuple[List[int], List[str]]:
|
|
89
|
+
"""推送消息,返回 (成功的群, 错误信息)。"""
|
|
90
|
+
try:
|
|
91
|
+
bots = [
|
|
92
|
+
bot for bot in nonebot.get_bots().values() if isinstance(bot, OneBotV11Bot)
|
|
93
|
+
]
|
|
94
|
+
except Exception as exc: # pragma: no cover - 依赖运行时状态
|
|
95
|
+
return [], [f"获取机器人实例失败:{exc.__class__.__name__}: {exc}"]
|
|
96
|
+
|
|
97
|
+
if not bots:
|
|
98
|
+
return [], ["当前没有已连接的 OneBot V11 机器人"]
|
|
99
|
+
|
|
100
|
+
succeeded: List[int] = []
|
|
101
|
+
errors: List[str] = []
|
|
102
|
+
for group_id in groups:
|
|
103
|
+
for bot in bots:
|
|
104
|
+
try:
|
|
105
|
+
await bot.send_group_msg(group_id=int(group_id), message=message)
|
|
106
|
+
except Exception as exc: # noqa: BLE001 - 发送失败原因很多,统一记录
|
|
107
|
+
errors.append(
|
|
108
|
+
f"群 {group_id} 推送失败(bot {bot.self_id}):"
|
|
109
|
+
f"{exc.__class__.__name__}: {exc}"
|
|
110
|
+
)
|
|
111
|
+
continue
|
|
112
|
+
succeeded.append(int(group_id))
|
|
113
|
+
break
|
|
114
|
+
return succeeded, errors
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def check_once(cfg: Optional[Config] = None) -> CheckResult:
|
|
118
|
+
"""遍历所有订阅的仓库,把新 release 推到对应的群。"""
|
|
119
|
+
cfg = cfg or load_config()
|
|
120
|
+
data_dir = resolve_data_dir(cfg)
|
|
121
|
+
states_file = state_path(data_dir)
|
|
122
|
+
states = load_states(states_file)
|
|
123
|
+
subscriptions = effective_subscriptions(cfg, load_subscriptions(subscriptions_path(data_dir)))
|
|
124
|
+
|
|
125
|
+
if not subscriptions:
|
|
126
|
+
return CheckResult(
|
|
127
|
+
status="no-repo",
|
|
128
|
+
detail="没有任何订阅:在群里发 /gh订阅 owner/repo,或配置 GH_WATCH_REPOS",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
pushed: List[str] = []
|
|
132
|
+
failed: List[str] = []
|
|
133
|
+
errors: List[str] = []
|
|
134
|
+
limit = max(1, cfg.gh_watch_max_per_round)
|
|
135
|
+
|
|
136
|
+
for repo, groups in sorted(subscriptions.items()):
|
|
137
|
+
state = states.get(repo) or RepoState()
|
|
138
|
+
try:
|
|
139
|
+
fetched = await fetch_releases(cfg, repo)
|
|
140
|
+
except ReleaseFetchError as exc:
|
|
141
|
+
state.last_check = now_iso()
|
|
142
|
+
state.last_error = str(exc)
|
|
143
|
+
states[repo] = state
|
|
144
|
+
save_states(states_file, states)
|
|
145
|
+
errors.append(f"{repo}:{exc}")
|
|
146
|
+
logger.warning(f"[gh-release] {repo} 拉取失败:{exc}")
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
announcements = select_releases(
|
|
150
|
+
fetched.releases,
|
|
151
|
+
include_prerelease=cfg.gh_watch_include_prerelease,
|
|
152
|
+
require_body=cfg.gh_watch_require_body,
|
|
153
|
+
)
|
|
154
|
+
state, pending = plan(state, announcements)
|
|
155
|
+
state.last_check = now_iso()
|
|
156
|
+
state.last_ok = now_iso()
|
|
157
|
+
state.last_error = None
|
|
158
|
+
state.last_source = fetched.source
|
|
159
|
+
if announcements:
|
|
160
|
+
state.latest = announcements[-1].tag
|
|
161
|
+
states[repo] = state
|
|
162
|
+
save_states(states_file, states)
|
|
163
|
+
|
|
164
|
+
for release in pending[:limit]:
|
|
165
|
+
message = await build_announce_message(release, repo, cfg)
|
|
166
|
+
succeeded, send_errors = await push_to_groups(message, groups)
|
|
167
|
+
errors.extend(send_errors)
|
|
168
|
+
for error in send_errors:
|
|
169
|
+
logger.warning(f"[gh-release] {error}")
|
|
170
|
+
if succeeded:
|
|
171
|
+
state.pushed = sorted(set(state.pushed) | {release.tag})
|
|
172
|
+
states[repo] = state
|
|
173
|
+
save_states(states_file, states)
|
|
174
|
+
pushed.append(f"{repo} {release.tag}")
|
|
175
|
+
logger.success(f"[gh-release] 已推送 {repo} {release.tag} 到 {len(succeeded)} 个群")
|
|
176
|
+
else:
|
|
177
|
+
failed.append(f"{repo} {release.tag}")
|
|
178
|
+
|
|
179
|
+
if pushed:
|
|
180
|
+
detail = "已推送 " + "、".join(pushed)
|
|
181
|
+
if failed or errors:
|
|
182
|
+
detail += f"({len(failed)} 条失败、{len(errors)} 条错误)"
|
|
183
|
+
status = "pushed"
|
|
184
|
+
elif errors:
|
|
185
|
+
status = "error"
|
|
186
|
+
detail = ";".join(errors[:3])
|
|
187
|
+
else:
|
|
188
|
+
status = "no-update"
|
|
189
|
+
detail = f"没有新 release(监控 {len(subscriptions)} 个仓库)"
|
|
190
|
+
|
|
191
|
+
return CheckResult(
|
|
192
|
+
status=status,
|
|
193
|
+
detail=detail,
|
|
194
|
+
repo_count=len(subscriptions),
|
|
195
|
+
pushed=pushed,
|
|
196
|
+
failed=failed,
|
|
197
|
+
errors=errors,
|
|
198
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nonebot-plugin-github-release
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 监控任意 GitHub 仓库的 release,把新版本推送到 QQ 群
|
|
5
|
+
Project-URL: Homepage, https://github.com/NSQX13579/nonebot-plugin-github-release
|
|
6
|
+
Project-URL: Repository, https://github.com/NSQX13579/nonebot-plugin-github-release
|
|
7
|
+
Author-email: ns <102937666+NSQX13579@users.noreply.github.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 ns
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: github,nonebot,nonebot2,release,watch
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Operating System :: OS Independent
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Requires-Python: >=3.9
|
|
35
|
+
Requires-Dist: httpx>=0.26
|
|
36
|
+
Requires-Dist: nonebot-adapter-onebot>=2.4.0
|
|
37
|
+
Requires-Dist: nonebot-plugin-apscheduler>=0.5.0
|
|
38
|
+
Requires-Dist: nonebot-plugin-localstore>=0.7.0
|
|
39
|
+
Requires-Dist: nonebot2>=2.4.0
|
|
40
|
+
Requires-Dist: pydantic>=2.0
|
|
41
|
+
Requires-Dist: tzdata; sys_platform == 'win32'
|
|
42
|
+
Provides-Extra: image
|
|
43
|
+
Requires-Dist: nonebot-plugin-htmlrender>=0.6.7; extra == 'image'
|
|
44
|
+
Provides-Extra: test
|
|
45
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# nonebot-plugin-github-release
|
|
49
|
+
|
|
50
|
+
监控任意 GitHub 仓库的 release,把新版本推送到 QQ 群。按群订阅,每个群只收自己关心的仓库。
|
|
51
|
+
|
|
52
|
+
本插件由 [nonebot-plugin-dsh-release](https://github.com/NSQX13579/nonebot-plugin-dsh-release) 改进而得。
|
|
53
|
+
|
|
54
|
+
## 安装
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install nonebot-plugin-github-release
|
|
58
|
+
playwright install chromium # 卡片图片需要,不装会自动回退纯文本
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
依赖 `nonebot-plugin-apscheduler`(定时)和 `nonebot-plugin-localstore`(数据目录),装包时会一并带上。
|
|
62
|
+
|
|
63
|
+
## 配置
|
|
64
|
+
|
|
65
|
+
`.env`(完整模板见 `.env.example`):
|
|
66
|
+
|
|
67
|
+
```dotenv
|
|
68
|
+
# 监控哪些仓库(留空也行,直接在群里 /gh订阅 更灵活)
|
|
69
|
+
GH_WATCH_REPOS=[]
|
|
70
|
+
GH_WATCH_GROUPS=[]
|
|
71
|
+
|
|
72
|
+
# 可选:匿名 API 限额 60 次/小时且按出口 IP 共享,多仓库建议配上
|
|
73
|
+
GH_WATCH_GITHUB_TOKEN=
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
插件不带任何默认仓库——用群里的 `/gh订阅 owner/repo` 加,或者把上面的列表填上。
|
|
77
|
+
|
|
78
|
+
## 指令
|
|
79
|
+
|
|
80
|
+
| 指令 | 权限 | 说明 |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| `/gh订阅 owner/repo` | 群管 | 本群订阅某个仓库 |
|
|
83
|
+
| `/gh退订 owner/repo` | 群管 | 取消订阅 |
|
|
84
|
+
| `/gh列表` | 所有人 | 看本群订阅了哪些仓库 |
|
|
85
|
+
| `/gh公告 owner/repo` | 所有人 | 最新一条 release 全文 |
|
|
86
|
+
| `/gh公告 owner/repo 5` | 所有人 | 列最近 5 条(最多 10) |
|
|
87
|
+
| `/gh公告 owner/repo v1.2` | 所有人 | 按版本号检索 |
|
|
88
|
+
| `/gh状态` | 所有人 | 推送状态 |
|
|
89
|
+
| `/gh检查` | 超管 | 立刻检查一次,不等下一轮 |
|
|
90
|
+
|
|
91
|
+
## 消息长什么样
|
|
92
|
+
|
|
93
|
+

|
|
94
|
+
|
|
95
|
+
图下面另附一行带链接的文字——图片里的地址点不了。卡片上的头像、发布时间、版本号、正文、表情计数都是 GitHub 的真实数据。
|
|
96
|
+
|
|
97
|
+
**渲染出任何问题都会回退纯文本**(没装 htmlrender、没装 chromium、超时都算),只打一条警告日志,推送不受影响。
|
|
98
|
+
|
|
99
|
+
## 行为说明
|
|
100
|
+
|
|
101
|
+
- 数据源:Releases API 为主,被限流时自动切到不限流的 `releases.atom`
|
|
102
|
+
- 首次运行只建推送基线,不会把历史 release 刷进群
|
|
103
|
+
- 以 tag 去重,失败的不写进已推送列表,下一轮自然重试
|
|
104
|
+
- 中英双语正文默认只保留中文段,`GH_WATCH_BODY_LANGUAGE=all` 可改成两段都留
|
|
105
|
+
- `GH_WATCH_INCLUDE_PRERELEASE=False` 则只推正式版
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
nonebot_plugin_github_release/__init__.py,sha256=r0gyMcjx9Zp-FESRBdzm1qrPD6MwacRSspDc-m6qAFE,2261
|
|
2
|
+
nonebot_plugin_github_release/card.py,sha256=iQGQkCLFCuNNMUCkBDJZB7R5CU3VRiIoX-3xDU_GoA8,12326
|
|
3
|
+
nonebot_plugin_github_release/commands.py,sha256=hhjSEUjOWZ-Wo3EShBHOyKI5Kpu0T_iI8R0d_CKYvAY,9982
|
|
4
|
+
nonebot_plugin_github_release/config.py,sha256=jX1kOBKTciNclKrx-HMhGd20bgVcLm5eVOLT3gHiyMI,2117
|
|
5
|
+
nonebot_plugin_github_release/latest.py,sha256=kK30Y262aYogkKINlg0QfXYz8d-NEEipOccMtdQawqQ,1972
|
|
6
|
+
nonebot_plugin_github_release/models.py,sha256=t6VNTclLlZNromcdsU8Lyq5JnBlh943KINRnZj83QeM,2031
|
|
7
|
+
nonebot_plugin_github_release/plan.py,sha256=QJm5LqINleSyWwjSWW5dBRD1jgtvupSE2vNoq-Dicko,1648
|
|
8
|
+
nonebot_plugin_github_release/render.py,sha256=rb8R8rdK7YaOOw-KKggpBfGHtR9tNMsQCWDTRRy8uM0,6004
|
|
9
|
+
nonebot_plugin_github_release/source.py,sha256=mVAGwGslOSIeX_0JqAxf12JkBWqAmIw08hakDVGVTyM,5802
|
|
10
|
+
nonebot_plugin_github_release/store.py,sha256=0fkqDbFHny0yZj3Zn-l6Dkbx3cWnYaNthhhV7-zPC9s,4520
|
|
11
|
+
nonebot_plugin_github_release/versions.py,sha256=qBS2O8RPF5M4tLW-mFxCwX9GolFKEHugoH2ekjZZwjg,1492
|
|
12
|
+
nonebot_plugin_github_release/watch.py,sha256=mBvdUuM3zVGoPuIOL5CPTSvEUAcV06lqMr5WjkRBhiY,7026
|
|
13
|
+
nonebot_plugin_github_release-0.1.0.dist-info/METADATA,sha256=fcK9tVX2mOLxJAQGQgLNSIY7Q4hpD-ZyKJbmXIuqDMA,4655
|
|
14
|
+
nonebot_plugin_github_release-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
15
|
+
nonebot_plugin_github_release-0.1.0.dist-info/licenses/LICENSE,sha256=0WfBnUlo5DQbHA5SAIVlDFlYbkIzl0OkjiI7DqTC7CA,1059
|
|
16
|
+
nonebot_plugin_github_release-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ns
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|