nonebot-plugin-git-poller 0.1.5__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.
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ from pathlib import Path
6
+ from secrets import token_urlsafe
7
+ import time
8
+ from urllib.parse import quote, urlencode
9
+
10
+ from nonebot import get_driver, logger
11
+ from nonebot_plugin_localstore import get_plugin_cache_dir
12
+
13
+ from .config import Config
14
+
15
+ _runtime_file_token = token_urlsafe(24)
16
+ _route_registered = False
17
+ ARCHIVE_FILE_ROUTE_PREFIX = "/git-poller/files"
18
+ ARCHIVE_FILE_TOKEN_TTL_SECONDS = 3600
19
+
20
+
21
+ def register_archive_file_route(config: Config) -> bool:
22
+ driver = get_driver()
23
+ server_app = getattr(driver, "server_app", None)
24
+ if server_app is None or not hasattr(server_app, "add_api_route"):
25
+ message = "git poller archive HTTP route is unavailable: current driver has no server_app"
26
+ if config.git_poller_file_base_url:
27
+ raise RuntimeError(
28
+ f"{message}. git_poller_file_base_url is configured, so OneBot needs "
29
+ "an HTTP route to download archives. Use a driver with server_app support "
30
+ "or unset git_poller_file_base_url."
31
+ )
32
+ logger.warning(message)
33
+ return False
34
+
35
+ if not config.git_poller_file_base_url:
36
+ return False
37
+
38
+ global _route_registered
39
+ if _route_registered:
40
+ return True
41
+
42
+ from starlette.responses import FileResponse, Response
43
+
44
+ route_prefix = ARCHIVE_FILE_ROUTE_PREFIX
45
+
46
+ async def serve_archive(
47
+ filename: str,
48
+ expires: str | None = None,
49
+ token: str | None = None,
50
+ ) -> Response:
51
+ if not valid_archive_download_token(filename, expires, token):
52
+ logger.warning(f"git poller archive download rejected for {filename}: invalid token")
53
+ return Response(status_code=403)
54
+ if "/" in filename or "\\" in filename:
55
+ logger.warning(f"git poller archive download rejected for unsafe filename: {filename}")
56
+ return Response(status_code=404)
57
+
58
+ archive_dir = (get_plugin_cache_dir() / "archives").resolve()
59
+ archive_path = (archive_dir / filename).resolve()
60
+ try:
61
+ archive_path.relative_to(archive_dir)
62
+ except ValueError:
63
+ return Response(status_code=404)
64
+
65
+ if not archive_path.is_file():
66
+ logger.warning(f"git poller archive download not found: {archive_path}")
67
+ return Response(status_code=404)
68
+
69
+ logger.info(f"git poller serving archive download: {archive_path}")
70
+ return FileResponse(
71
+ archive_path,
72
+ media_type="application/x-7z-compressed",
73
+ filename=filename,
74
+ )
75
+
76
+ server_app.add_api_route(
77
+ f"{route_prefix}/{{filename}}",
78
+ serve_archive,
79
+ methods=["GET"],
80
+ )
81
+ _route_registered = True
82
+ logger.info(f"git poller archive HTTP route registered: {route_prefix}/{{filename}}")
83
+ return True
84
+
85
+
86
+ def build_archive_download_url(path: Path, config: Config) -> str | None:
87
+ if not config.git_poller_file_base_url:
88
+ return None
89
+ base_url = config.git_poller_file_base_url.rstrip("/")
90
+ route_prefix = ARCHIVE_FILE_ROUTE_PREFIX
91
+ filename = quote(path.name)
92
+ expires_at = int(time.time()) + ARCHIVE_FILE_TOKEN_TTL_SECONDS
93
+ query = urlencode(
94
+ {
95
+ "expires": str(expires_at),
96
+ "token": archive_download_token(path.name, expires_at),
97
+ }
98
+ )
99
+ return f"{base_url}{route_prefix}/{filename}?{query}"
100
+
101
+
102
+ def _archive_file_token() -> str:
103
+ return _runtime_file_token
104
+
105
+
106
+ def valid_archive_download_token(
107
+ filename: str,
108
+ expires: str | None,
109
+ token: str | None,
110
+ ) -> bool:
111
+ if not expires or not token:
112
+ return False
113
+ try:
114
+ expires_at = int(expires)
115
+ except ValueError:
116
+ return False
117
+ if expires_at < int(time.time()):
118
+ return False
119
+ expected = archive_download_token(filename, expires_at)
120
+ return hmac.compare_digest(token, expected)
121
+
122
+
123
+ def archive_download_token(filename: str, expires_at: int) -> str:
124
+ message = f"{filename}\0{expires_at}".encode("utf-8")
125
+ secret = _archive_file_token().encode("utf-8")
126
+ return hmac.new(secret, message, hashlib.sha256).hexdigest()
@@ -0,0 +1,430 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone, timedelta
5
+ from io import BytesIO
6
+ from pathlib import Path
7
+ import shutil
8
+ from typing import Any
9
+
10
+ from dulwich import porcelain
11
+ from dulwich.client import _import_remote_refs, get_transport_and_path
12
+ from dulwich.config import StackedConfig, env_config
13
+ from dulwich.errors import NotGitRepository
14
+ from dulwich.objects import Blob, Commit, Tree
15
+ from dulwich.repo import Repo
16
+ from nonebot import logger
17
+ from nonebot_plugin_localstore import get_plugin_cache_dir
18
+ import os
19
+ import urllib3
20
+
21
+ from .config import Config
22
+ from .models import CommitInfo
23
+ from .repository import build_commit_url
24
+
25
+
26
+ MAX_COMMITS = 50
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class RemoteHead:
31
+ branch: str
32
+ sha: str
33
+
34
+
35
+ class GitRepositoryCache:
36
+ def __init__(self, config: Config) -> None:
37
+ self.config = config
38
+ self.cache_dir = get_plugin_cache_dir() / "repos"
39
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
40
+
41
+ def fetch(self, repo_key: str, url: str, branch: str) -> "FetchedRepository":
42
+ repo_path = self.cache_dir / repo_key
43
+ fetch_result = None
44
+ if not _looks_like_bare_repo(repo_path):
45
+ if repo_path.exists():
46
+ raise NotGitRepository(str(repo_path))
47
+ logger.info(f"git poller cloning repository cache: {url} -> {repo_path}")
48
+ cloned = porcelain.clone(
49
+ url,
50
+ repo_path,
51
+ bare=True,
52
+ branch=branch,
53
+ errstream=BytesIO(),
54
+ **self._porcelain_transport_kwargs(url),
55
+ )
56
+ cloned.close()
57
+ else:
58
+ logger.info(f"git poller fetching repository cache: {url} -> {repo_path}")
59
+ fetch_result = self._fetch_existing_repo(repo_path, url)
60
+
61
+ repo = Repo(repo_path)
62
+ try:
63
+ head_sha = _resolve_branch_head(repo, branch, remote_refs=getattr(fetch_result, "refs", None))
64
+ except BaseException:
65
+ repo.close()
66
+ raise
67
+ return FetchedRepository(repo=repo, url=url, branch=branch, head_sha=head_sha)
68
+
69
+ def resolve_remote_head(self, url: str, branch: str | None = None) -> RemoteHead:
70
+ logger.info(f"git poller checking remote head: {url} branch={branch or '<default>'}")
71
+ client, path = get_transport_and_path(
72
+ url,
73
+ config=self._git_config(),
74
+ quiet=True,
75
+ pool_manager=self._pool_manager(url),
76
+ )
77
+ result = client.get_refs(_encode_path(path))
78
+ if branch is None:
79
+ remote_head = _resolve_remote_default_head(result.refs, result.symrefs)
80
+ else:
81
+ remote_head = RemoteHead(
82
+ branch=branch,
83
+ sha=_resolve_remote_branch_head(result.refs, branch),
84
+ )
85
+ logger.info(
86
+ f"git poller remote head resolved: {url} "
87
+ f"branch={remote_head.branch} sha={remote_head.sha[:8]}"
88
+ )
89
+ return remote_head
90
+
91
+ def remove_cache(self, repo_key: str) -> bool:
92
+ repo_path = self.cache_dir / repo_key
93
+ try:
94
+ repo_path.resolve().relative_to(self.cache_dir.resolve())
95
+ except ValueError:
96
+ logger.warning(f"git poller refused to remove cache outside repo cache: {repo_path}")
97
+ return False
98
+ if not repo_path.exists():
99
+ return False
100
+ shutil.rmtree(repo_path)
101
+ logger.info(f"git poller removed repository cache: {repo_path}")
102
+ return True
103
+
104
+ def cached_repo_keys(self) -> set[str]:
105
+ result: set[str] = set()
106
+ for path in self.cache_dir.iterdir():
107
+ if path.is_dir() and _looks_like_bare_repo(path):
108
+ result.add(path.name)
109
+ return result
110
+
111
+ def _porcelain_transport_kwargs(self, url: str) -> dict[str, Any]:
112
+ kwargs: dict[str, Any] = {"quiet": True}
113
+ pool_manager = self._pool_manager(url)
114
+ if pool_manager is not None:
115
+ kwargs["pool_manager"] = pool_manager
116
+ return kwargs
117
+
118
+ def _fetch_existing_repo(self, repo_path: Path, url: str):
119
+ repo = Repo(repo_path)
120
+ try:
121
+ client, path = get_transport_and_path(
122
+ url,
123
+ config=repo.get_config_stack(),
124
+ quiet=True,
125
+ pool_manager=self._pool_manager(url),
126
+ )
127
+ fetch_result = client.fetch(
128
+ _encode_path(path),
129
+ repo,
130
+ progress=lambda data: None,
131
+ )
132
+ _import_remote_refs(
133
+ repo.refs,
134
+ "origin",
135
+ fetch_result.refs,
136
+ message=b"fetch: from " + url.encode("utf-8"),
137
+ prune=True,
138
+ )
139
+ return fetch_result
140
+ finally:
141
+ repo.close()
142
+
143
+ def _pool_manager(self, url: str) -> urllib3.PoolManager | urllib3.ProxyManager | None:
144
+ if url.startswith(("http://", "https://")):
145
+ if self.config.git_poller_proxy:
146
+ return urllib3.ProxyManager(
147
+ self.config.git_poller_proxy,
148
+ timeout=self.config.git_poller_timeout,
149
+ )
150
+ return urllib3.PoolManager(
151
+ timeout=self.config.git_poller_timeout,
152
+ )
153
+ return None
154
+
155
+ @staticmethod
156
+ def _git_config() -> StackedConfig:
157
+ config = StackedConfig.default()
158
+ env_override = env_config(os.environ)
159
+ if env_override is not None:
160
+ config.backends.insert(0, env_override)
161
+ return config
162
+
163
+
164
+ class FetchedRepository:
165
+ def __init__(self, repo: Repo, url: str, branch: str, head_sha: str) -> None:
166
+ self.repo = repo
167
+ self.url = url
168
+ self.branch = branch
169
+ self.head_sha = head_sha
170
+
171
+ def head_commit(self) -> CommitInfo:
172
+ return self.commit_info(self.head_sha)
173
+
174
+ def commit_info(self, sha: str) -> CommitInfo:
175
+ commit = self.repo.get_object(sha.encode("ascii"))
176
+ if not isinstance(commit, Commit):
177
+ raise TypeError(f"object is not a commit: {sha}")
178
+ return _commit_to_info(commit, self.url)
179
+
180
+ def commits_since(self, previous_sha: str | None) -> list[CommitInfo]:
181
+ if previous_sha and _has_object(self.repo, previous_sha):
182
+ include = [self.head_sha.encode("ascii")]
183
+ exclude = [previous_sha.encode("ascii")]
184
+ walker = self.repo.get_walker(
185
+ include=include,
186
+ exclude=exclude,
187
+ reverse=True,
188
+ max_entries=MAX_COMMITS,
189
+ )
190
+ commits = [
191
+ _commit_to_info(entry.commit, self.url)
192
+ for entry in walker
193
+ if isinstance(entry.commit, Commit)
194
+ ]
195
+ return commits or [self.head_commit()]
196
+
197
+ return [self.head_commit()]
198
+
199
+ def count_commits_since(self, previous_sha: str | None) -> int | None:
200
+ if not previous_sha:
201
+ return None
202
+ if not _has_object(self.repo, previous_sha):
203
+ return None
204
+ walker = self.repo.get_walker(
205
+ include=[self.head_sha.encode("ascii")],
206
+ exclude=[previous_sha.encode("ascii")],
207
+ )
208
+ return sum(1 for entry in walker if isinstance(entry.commit, Commit))
209
+
210
+ def export_head_tree(self, target_dir: Path) -> None:
211
+ commit = self.repo.get_object(self.head_sha.encode("ascii"))
212
+ if not isinstance(commit, Commit):
213
+ raise TypeError(f"object is not a commit: {self.head_sha}")
214
+ target_dir.mkdir(parents=True, exist_ok=True)
215
+ tree = self.repo.get_object(commit.tree)
216
+ if not isinstance(tree, Tree):
217
+ raise TypeError(f"object is not a tree: {commit.tree!r}")
218
+ self._export_tree(tree, target_dir, target_dir.resolve())
219
+
220
+ def close(self) -> None:
221
+ self.repo.close()
222
+
223
+ def _export_tree(self, tree: Tree, target_dir: Path, root_dir: Path) -> None:
224
+ for entry in tree.iteritems(name_order=True):
225
+ name = entry.path.decode("utf-8", errors="replace")
226
+ if not _safe_tree_entry_name(name):
227
+ logger.warning(f"git poller skipped unsafe tree path while exporting: {name!r}")
228
+ continue
229
+ path = _safe_export_path(target_dir, name, root_dir)
230
+ if path is None:
231
+ logger.warning(f"git poller skipped tree path outside export root: {name!r}")
232
+ continue
233
+ mode = entry.mode
234
+ obj = self.repo.get_object(entry.sha)
235
+ if isinstance(obj, Tree):
236
+ path.mkdir(parents=True, exist_ok=True)
237
+ self._export_tree(obj, path, root_dir)
238
+ continue
239
+ if not isinstance(obj, Blob):
240
+ logger.warning(f"git poller skipped non-blob object while exporting: {path}")
241
+ continue
242
+ path.parent.mkdir(parents=True, exist_ok=True)
243
+ data = obj.as_raw_string()
244
+ if _is_symlink_mode(mode):
245
+ # Keep archive generation inside the exported tree even when
246
+ # a repository contains links that point elsewhere.
247
+ path.write_text(data.decode("utf-8", errors="replace"), encoding="utf-8")
248
+ continue
249
+ path.write_bytes(data)
250
+ if _is_executable_mode(mode):
251
+ path.chmod(path.stat().st_mode | 0o111)
252
+
253
+
254
+ def _resolve_branch_head(
255
+ repo: Repo,
256
+ branch: str,
257
+ *,
258
+ remote_refs: dict[bytes, bytes | None] | None = None,
259
+ ) -> str:
260
+ if remote_refs:
261
+ return _resolve_remote_branch_head(remote_refs, branch)
262
+ refs = repo.get_refs()
263
+ candidates = [
264
+ f"refs/remotes/origin/{branch}".encode("utf-8"),
265
+ f"refs/heads/{branch}".encode("utf-8"),
266
+ ]
267
+ if _is_head_branch(branch):
268
+ candidates.extend(
269
+ [
270
+ f"refs/remotes/origin/HEAD".encode("utf-8"),
271
+ b"HEAD",
272
+ ]
273
+ )
274
+ for candidate in candidates:
275
+ value = refs.get(candidate)
276
+ if value:
277
+ return value.decode("ascii")
278
+ raise RuntimeError(f"找不到分支:{branch}")
279
+
280
+
281
+ def _resolve_remote_branch_head(refs: dict[bytes, bytes | None], branch: str) -> str:
282
+ candidates = [
283
+ f"refs/heads/{branch}".encode("utf-8"),
284
+ ]
285
+ if _is_head_branch(branch):
286
+ candidates.append(b"HEAD")
287
+ for candidate in candidates:
288
+ value = refs.get(candidate)
289
+ if value:
290
+ return value.decode("ascii")
291
+ raise RuntimeError(f"找不到分支:{branch}")
292
+
293
+
294
+ def _resolve_remote_default_head(
295
+ refs: dict[bytes, bytes | None],
296
+ symrefs: dict[bytes, bytes],
297
+ ) -> RemoteHead:
298
+ head_ref = symrefs.get(b"HEAD")
299
+ if head_ref:
300
+ branch = _branch_name_from_ref(head_ref)
301
+ if branch:
302
+ value = refs.get(head_ref) or refs.get(b"HEAD")
303
+ if value:
304
+ return RemoteHead(branch=branch, sha=value.decode("ascii"))
305
+
306
+ head_value = refs.get(b"HEAD")
307
+ if head_value:
308
+ matches = [
309
+ RemoteHead(branch=branch, sha=value.decode("ascii"))
310
+ for branch, value in _remote_branch_refs(refs)
311
+ if value == head_value
312
+ ]
313
+ if matches:
314
+ return _choose_default_head_match(matches)
315
+
316
+ branch_refs = _remote_branch_refs(refs)
317
+ if len(branch_refs) == 1:
318
+ branch, value = branch_refs[0]
319
+ return RemoteHead(branch=branch, sha=value.decode("ascii"))
320
+
321
+ raise RuntimeError("无法解析远端默认分支。")
322
+
323
+
324
+ def _remote_branch_refs(refs: dict[bytes, bytes | None]) -> list[tuple[str, bytes]]:
325
+ result: list[tuple[str, bytes]] = []
326
+ for ref, value in refs.items():
327
+ if not value:
328
+ continue
329
+ branch = _branch_name_from_ref(ref)
330
+ if branch:
331
+ result.append((branch, value))
332
+ return result
333
+
334
+
335
+ def _branch_name_from_ref(ref: bytes) -> str | None:
336
+ prefix = b"refs/heads/"
337
+ if not ref.startswith(prefix):
338
+ return None
339
+ branch = ref[len(prefix):].decode("utf-8", errors="replace").strip()
340
+ return branch or None
341
+
342
+
343
+ def _choose_default_head_match(matches: list[RemoteHead]) -> RemoteHead:
344
+ for preferred in ("main", "master"):
345
+ for match in matches:
346
+ if match.branch == preferred:
347
+ return match
348
+ return sorted(matches, key=lambda item: item.branch)[0]
349
+
350
+
351
+ def _is_head_branch(branch: str) -> bool:
352
+ return branch.strip().upper() == "HEAD"
353
+
354
+
355
+ def _commit_to_info(commit: Commit, repo_url: str) -> CommitInfo:
356
+ sha = commit.id.decode("ascii")
357
+ lines = _decode(commit.message).splitlines()
358
+ title = (lines[0].strip() if lines else "") or sha[:8]
359
+ author = _author_name(_decode(commit.author))
360
+ committed_at = _format_git_time(commit.commit_time, commit.commit_timezone)
361
+ return CommitInfo(
362
+ sha=sha,
363
+ short_sha=sha[:8],
364
+ title=title,
365
+ committed_at=committed_at,
366
+ author=author,
367
+ url=build_commit_url(repo_url, sha),
368
+ )
369
+
370
+
371
+ def _format_git_time(timestamp: int, offset: int) -> str:
372
+ tz = timezone(timedelta(seconds=offset))
373
+ return datetime.fromtimestamp(timestamp, tz=tz).isoformat(timespec="seconds")
374
+
375
+
376
+ def _author_name(value: str) -> str:
377
+ if "<" in value:
378
+ return value.split("<", 1)[0].strip()
379
+ return value.strip()
380
+
381
+
382
+ def _decode(value: bytes) -> str:
383
+ return value.decode("utf-8", errors="replace")
384
+
385
+
386
+ def _safe_tree_entry_name(name: str) -> bool:
387
+ return (
388
+ name not in {"", ".", ".."}
389
+ and "/" not in name
390
+ and "\\" not in name
391
+ and not Path(name).is_absolute()
392
+ )
393
+
394
+
395
+ def _safe_export_path(target_dir: Path, name: str, root_dir: Path) -> Path | None:
396
+ path = target_dir / name
397
+ try:
398
+ path.resolve().relative_to(root_dir)
399
+ except ValueError:
400
+ return None
401
+ return path
402
+
403
+
404
+ def _encode_path(path: str | bytes) -> bytes:
405
+ return path.encode("utf-8") if isinstance(path, str) else path
406
+
407
+
408
+ def _has_object(repo: Repo, sha: str) -> bool:
409
+ try:
410
+ repo.get_object(sha.encode("ascii"))
411
+ except KeyError:
412
+ return False
413
+ return True
414
+
415
+
416
+ def _is_symlink_mode(mode: int) -> bool:
417
+ return mode == 0o120000
418
+
419
+
420
+ def _is_executable_mode(mode: int) -> bool:
421
+ return mode == 0o100755
422
+
423
+
424
+ def _looks_like_bare_repo(path: Path) -> bool:
425
+ return (
426
+ path.is_dir()
427
+ and (path / "objects").is_dir()
428
+ and (path / "refs").is_dir()
429
+ and (path / "HEAD").is_file()
430
+ )
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ from nonebot import logger
4
+ from nonebot.adapters.onebot.v11 import ActionFailed, Bot, Message, MessageSegment
5
+
6
+ from .config import Config
7
+ from .file_server import build_archive_download_url
8
+ from .models import UpdatePayload
9
+
10
+
11
+ DEFAULT_NODE_USER_ID = 2854196310
12
+ ARCHIVE_UPLOAD_URI_ERROR_MESSAGE = (
13
+ "上传压缩包失败:OneBot 无法识别文件地址,"
14
+ "请检查 git_poller_file_base_url 是否正确。"
15
+ )
16
+
17
+
18
+ class ArchiveUploadUriError(RuntimeError):
19
+ pass
20
+
21
+
22
+ def build_forward_nodes(payload: UpdatePayload) -> list[MessageSegment]:
23
+ nodes = [_node(_summary_text(payload), payload.repo_name)]
24
+ for index, commit in enumerate(payload.commits, start=1):
25
+ nodes.append(_node(_commit_text(index, commit), payload.repo_name))
26
+ return nodes
27
+
28
+
29
+ def build_archive_delivery_text(payload: UpdatePayload, archive, *, title: str) -> str:
30
+ lines = [
31
+ f"{title}:{payload.repo_name}",
32
+ f"分支:{payload.branch}",
33
+ f"sha256:{archive.sha256}",
34
+ f"密码:{archive.password or '无'}",
35
+ ]
36
+ if not payload.commits:
37
+ lines.append("无新增 commit")
38
+ return "\n".join(lines)
39
+
40
+ for index, commit in enumerate(payload.commits):
41
+ prefix = f"最新{commit.short_sha}" if index == len(payload.commits) - 1 else commit.short_sha
42
+ lines.append(f"{prefix}:{commit.title}")
43
+ return "\n".join(lines)
44
+
45
+
46
+ async def send_update_to_group(bot: Bot, group_id: int, payload: UpdatePayload) -> None:
47
+ nodes = build_forward_nodes(payload)
48
+ logger.info(
49
+ f"git poller sending update to group {group_id}: "
50
+ f"repo={payload.repo_key}, target={payload.target_short_sha}, nodes={len(nodes)}"
51
+ )
52
+ await bot.send_group_forward_msg(group_id=int(group_id), messages=nodes)
53
+ logger.info(f"git poller update sent to group {group_id}: {payload.repo_key}")
54
+
55
+
56
+ async def upload_archive_to_group(
57
+ bot: Bot,
58
+ group_id: int,
59
+ archive,
60
+ *,
61
+ config: Config,
62
+ ) -> None:
63
+ upload_file = build_archive_download_url(archive.path, config)
64
+ if upload_file:
65
+ logger.info(
66
+ f"git poller archive upload source for group {group_id} uses HTTP route: "
67
+ f"{upload_file.split('?', 1)[0]}"
68
+ )
69
+ else:
70
+ upload_file = str(archive.path)
71
+ logger.warning(
72
+ "git_poller_file_base_url is not configured; falling back to local archive path. "
73
+ "This only works when the OneBot implementation can read the same filesystem."
74
+ )
75
+ logger.info(
76
+ f"git poller uploading archive to group {group_id}: "
77
+ f"name={archive.name}, password={archive.password_used}, file={upload_file}"
78
+ )
79
+ try:
80
+ await bot.upload_group_file(
81
+ group_id=int(group_id),
82
+ file=upload_file,
83
+ name=archive.name,
84
+ )
85
+ except ActionFailed as exc:
86
+ if _is_unrecognized_upload_uri(exc):
87
+ raise ArchiveUploadUriError(ARCHIVE_UPLOAD_URI_ERROR_MESSAGE) from exc
88
+ raise
89
+ logger.info(f"git poller archive uploaded to group {group_id}: {archive.name}")
90
+
91
+
92
+ def _summary_text(payload: UpdatePayload) -> str:
93
+ items = [
94
+ f"仓库更新:{payload.repo_name}",
95
+ f"分支:{payload.branch}",
96
+ f"当前:{payload.target_short_sha}",
97
+ f"新增 commit:{len(payload.commits)}",
98
+ ]
99
+ if payload.previous_sha:
100
+ items.append(f"上次成功:{payload.previous_sha[:8]}")
101
+ if payload.compare_url:
102
+ items.append(payload.compare_url)
103
+ return "\n".join(items)
104
+
105
+
106
+ def _commit_text(index: int, commit) -> str:
107
+ items = [
108
+ f"{index}. {commit.title}",
109
+ f"commit: {commit.short_sha}",
110
+ ]
111
+ if commit.author:
112
+ items.append(f"author: {commit.author}")
113
+ if commit.committed_at:
114
+ items.append(f"time: {commit.committed_at}")
115
+ if commit.url:
116
+ items.append(commit.url)
117
+ return "\n".join(items)
118
+
119
+
120
+ def _node(content: str, nickname: str) -> MessageSegment:
121
+ return MessageSegment.node_custom(
122
+ user_id=DEFAULT_NODE_USER_ID,
123
+ nickname=nickname or "Git 更新",
124
+ content=Message(content),
125
+ )
126
+
127
+
128
+ def _is_unrecognized_upload_uri(exc: ActionFailed) -> bool:
129
+ info = getattr(exc, "info", {})
130
+ fields = []
131
+ if isinstance(info, dict):
132
+ fields.extend(str(info.get(key, "")) for key in ("message", "wording", "msg"))
133
+ fields.append(repr(exc))
134
+ text = "\n".join(fields)
135
+ return "识别URL失败" in text and "uri=" in text