nonebot-plugin-bilidyn 0.3.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_bilidyn/__init__.py +36 -0
- nonebot_plugin_bilidyn/application/__init__.py +1 -0
- nonebot_plugin_bilidyn/application/dynamic_collection.py +307 -0
- nonebot_plugin_bilidyn/application/follow_sync.py +95 -0
- nonebot_plugin_bilidyn/application/live_room_sync.py +40 -0
- nonebot_plugin_bilidyn/bot/__init__.py +7 -0
- nonebot_plugin_bilidyn/bot/group_cleanup.py +185 -0
- nonebot_plugin_bilidyn/bot/handlers/__init__.py +9 -0
- nonebot_plugin_bilidyn/bot/handlers/cleanup.py +63 -0
- nonebot_plugin_bilidyn/bot/handlers/dynamics.py +80 -0
- nonebot_plugin_bilidyn/bot/handlers/help.py +9 -0
- nonebot_plugin_bilidyn/bot/handlers/login.py +79 -0
- nonebot_plugin_bilidyn/bot/handlers/subscriptions.py +168 -0
- nonebot_plugin_bilidyn/bot/lifecycle.py +37 -0
- nonebot_plugin_bilidyn/bot/matcher.py +90 -0
- nonebot_plugin_bilidyn/bot/polling.py +406 -0
- nonebot_plugin_bilidyn/bot/scene.py +68 -0
- nonebot_plugin_bilidyn/bot/scheduler.py +75 -0
- nonebot_plugin_bilidyn/bot/target_resolution.py +129 -0
- nonebot_plugin_bilidyn/config.py +34 -0
- nonebot_plugin_bilidyn/core/__init__.py +19 -0
- nonebot_plugin_bilidyn/core/data_models.py +180 -0
- nonebot_plugin_bilidyn/core/profile.py +190 -0
- nonebot_plugin_bilidyn/dependencies.py +33 -0
- nonebot_plugin_bilidyn/infra/__init__.py +5 -0
- nonebot_plugin_bilidyn/infra/auth.py +154 -0
- nonebot_plugin_bilidyn/infra/bili_client.py +145 -0
- nonebot_plugin_bilidyn/infra/data_manager.py +445 -0
- nonebot_plugin_bilidyn/infra/qr_login.py +49 -0
- nonebot_plugin_bilidyn/policies.py +25 -0
- nonebot_plugin_bilidyn/render/__init__.py +1052 -0
- nonebot_plugin_bilidyn/render/downloader.py +68 -0
- nonebot_plugin_bilidyn/render/messages.py +160 -0
- nonebot_plugin_bilidyn/render/models.py +250 -0
- nonebot_plugin_bilidyn/render/templates/dynamic_card.html.jinja +1335 -0
- nonebot_plugin_bilidyn/render/templates/live_card.html.jinja +538 -0
- nonebot_plugin_bilidyn-0.3.0.dist-info/METADATA +155 -0
- nonebot_plugin_bilidyn-0.3.0.dist-info/RECORD +40 -0
- nonebot_plugin_bilidyn-0.3.0.dist-info/WHEEL +4 -0
- nonebot_plugin_bilidyn-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from nonebot import require
|
|
2
|
+
from nonebot.plugin import PluginMetadata, inherit_supported_adapters
|
|
3
|
+
|
|
4
|
+
require("nonebot_plugin_alconna")
|
|
5
|
+
require("nonebot_plugin_apscheduler")
|
|
6
|
+
require("nonebot_plugin_htmlrender")
|
|
7
|
+
require("nonebot_plugin_localstore")
|
|
8
|
+
require("nonebot_plugin_uninfo")
|
|
9
|
+
require("nonebot_plugin_uniref")
|
|
10
|
+
|
|
11
|
+
USAGE_TEXT = (
|
|
12
|
+
"bili sub <UID> - 订阅 UP 主\n"
|
|
13
|
+
"bili unsub <UID> - 取消当前场景内订阅\n"
|
|
14
|
+
"bili list - 查看订阅列表\n"
|
|
15
|
+
"bili new <UID> [count] - 查看最新动态"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from . import bot as bot
|
|
19
|
+
from .config import Config
|
|
20
|
+
|
|
21
|
+
__all__ = ("USAGE_TEXT", "__plugin_meta__", "bot")
|
|
22
|
+
|
|
23
|
+
__plugin_meta__ = PluginMetadata(
|
|
24
|
+
name="B站动态订阅",
|
|
25
|
+
description="订阅B站UP主动态和直播通知推送",
|
|
26
|
+
usage=USAGE_TEXT,
|
|
27
|
+
type="application",
|
|
28
|
+
homepage="https://github.com/Misty02600/nonebot-plugin-bilidyn",
|
|
29
|
+
config=Config,
|
|
30
|
+
supported_adapters=inherit_supported_adapters(
|
|
31
|
+
"nonebot_plugin_alconna",
|
|
32
|
+
"nonebot_plugin_uninfo",
|
|
33
|
+
"nonebot_plugin_uniref",
|
|
34
|
+
),
|
|
35
|
+
extra={"author": "Misty02600 <Misty02600@gmail.com>"},
|
|
36
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""应用用例。"""
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""动态来源的分页收集与追赶边界。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from ..core.data_models import coerce_int
|
|
10
|
+
|
|
11
|
+
CATCHUP_WINDOW_SECONDS = 6 * 60 * 60
|
|
12
|
+
MAX_DYNAMIC_PAGES = 50
|
|
13
|
+
_REQUEST_INTERVAL_SECONDS = 1.0
|
|
14
|
+
|
|
15
|
+
DynamicPageFetcher = Callable[..., Awaitable[dict | None]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DynamicFetchError(Exception):
|
|
19
|
+
"""携带失败前已完成页数的动态请求异常。"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, pages: int, cause: Exception):
|
|
22
|
+
super().__init__(str(cause))
|
|
23
|
+
self.pages = pages
|
|
24
|
+
self.cause = cause
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class DynamicCollection:
|
|
29
|
+
"""一次完整或中止的动态收集结果。"""
|
|
30
|
+
|
|
31
|
+
baselines: dict[int, int]
|
|
32
|
+
effective_cutoffs: dict[int, int]
|
|
33
|
+
candidates_by_uid: dict[int, list[dict]] = field(default_factory=dict)
|
|
34
|
+
latest_seen_ts: dict[int, int] = field(default_factory=dict)
|
|
35
|
+
latest_item_by_uid: dict[int, dict] = field(default_factory=dict)
|
|
36
|
+
received_by_uid: dict[int, int] = field(default_factory=dict)
|
|
37
|
+
discarded_by_uid: dict[int, int] = field(default_factory=dict)
|
|
38
|
+
pages: int = 0
|
|
39
|
+
stop_reason: str = "not_started"
|
|
40
|
+
complete: bool = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _effective_cutoff(baseline: int, cycle_started_ts: int) -> int:
|
|
44
|
+
if baseline <= 0:
|
|
45
|
+
return cycle_started_ts
|
|
46
|
+
return max(baseline, cycle_started_ts - CATCHUP_WINDOW_SECONDS)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _new_collection(
|
|
50
|
+
baselines: dict[int, int],
|
|
51
|
+
cycle_started_ts: int,
|
|
52
|
+
) -> DynamicCollection:
|
|
53
|
+
normalized = {uid: coerce_int(value) for uid, value in baselines.items()}
|
|
54
|
+
return DynamicCollection(
|
|
55
|
+
baselines=normalized,
|
|
56
|
+
effective_cutoffs={
|
|
57
|
+
uid: _effective_cutoff(value, cycle_started_ts)
|
|
58
|
+
for uid, value in normalized.items()
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _response_items(result: dict | None) -> list[dict]:
|
|
64
|
+
if not result:
|
|
65
|
+
return []
|
|
66
|
+
items = result.get("items")
|
|
67
|
+
if not isinstance(items, list):
|
|
68
|
+
return []
|
|
69
|
+
return [item for item in items if isinstance(item, dict)]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _item_author_uid(item: dict) -> int:
|
|
73
|
+
return coerce_int(item.get("modules", {}).get("module_author", {}).get("mid", 0))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _item_pub_ts(item: dict) -> int:
|
|
77
|
+
return coerce_int(item.get("modules", {}).get("module_author", {}).get("pub_ts", 0))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _item_key(item: dict) -> tuple[object, ...]:
|
|
81
|
+
dynamic_id = str(item.get("id_str") or "").strip()
|
|
82
|
+
if dynamic_id:
|
|
83
|
+
return ("id", dynamic_id)
|
|
84
|
+
return (
|
|
85
|
+
"fallback",
|
|
86
|
+
_item_author_uid(item),
|
|
87
|
+
_item_pub_ts(item),
|
|
88
|
+
str(item.get("type") or ""),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _record_item(
|
|
93
|
+
collection: DynamicCollection,
|
|
94
|
+
item: dict,
|
|
95
|
+
expected_uids: set[int],
|
|
96
|
+
*,
|
|
97
|
+
forced_uid: int | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
uid = forced_uid if forced_uid is not None else _item_author_uid(item)
|
|
100
|
+
if uid not in expected_uids:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
collection.received_by_uid[uid] = collection.received_by_uid.get(uid, 0) + 1
|
|
104
|
+
pub_ts = _item_pub_ts(item)
|
|
105
|
+
if pub_ts <= 0:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
if pub_ts > collection.latest_seen_ts.get(uid, 0):
|
|
109
|
+
collection.latest_seen_ts[uid] = pub_ts
|
|
110
|
+
collection.latest_item_by_uid[uid] = item
|
|
111
|
+
|
|
112
|
+
baseline = collection.baselines[uid]
|
|
113
|
+
cutoff = collection.effective_cutoffs[uid]
|
|
114
|
+
if pub_ts > cutoff:
|
|
115
|
+
collection.candidates_by_uid.setdefault(uid, []).append(item)
|
|
116
|
+
elif cutoff > baseline and pub_ts > baseline:
|
|
117
|
+
collection.discarded_by_uid[uid] = collection.discarded_by_uid.get(uid, 0) + 1
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _page_tail_ts(items: list[dict]) -> int:
|
|
121
|
+
timestamps = [timestamp for item in items if (timestamp := _item_pub_ts(item)) > 0]
|
|
122
|
+
return timestamps[-1] if timestamps else 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _normalize_feed_offset(value: object) -> int | None:
|
|
126
|
+
if isinstance(value, bool):
|
|
127
|
+
return None
|
|
128
|
+
offset = coerce_int(value)
|
|
129
|
+
return offset if offset > 0 else None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def collect_user_dynamic_pages(
|
|
133
|
+
uid: int,
|
|
134
|
+
baseline: int,
|
|
135
|
+
cycle_started_ts: int,
|
|
136
|
+
credential: object,
|
|
137
|
+
fetch_page: DynamicPageFetcher,
|
|
138
|
+
*,
|
|
139
|
+
request_interval: float = _REQUEST_INTERVAL_SECONDS,
|
|
140
|
+
) -> DynamicCollection:
|
|
141
|
+
"""按单个 UID 的游标读取动态,直到安全停止边界。
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
uid: 订阅目标 UID。
|
|
145
|
+
baseline: 周期开始时该 UID 的持久化水位。
|
|
146
|
+
cycle_started_ts: 本轮检测开始的秒级时间戳。
|
|
147
|
+
credential: 透传给 SDK 适配函数的登录凭据。
|
|
148
|
+
fetch_page: 接受 ``uid``、``credential`` 和 ``offset`` 的分页函数。
|
|
149
|
+
request_interval: 同一 UID 连续翻页之间的等待秒数。
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
DynamicCollection: 候选、最新观察时间和分页完整性。
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
DynamicFetchError: 请求失败时携带失败前已经完成的页数。
|
|
156
|
+
"""
|
|
157
|
+
collection = _new_collection({uid: baseline}, cycle_started_ts)
|
|
158
|
+
expected_uids = {uid}
|
|
159
|
+
cutoff = collection.effective_cutoffs[uid]
|
|
160
|
+
offset = ""
|
|
161
|
+
seen_offsets = {offset}
|
|
162
|
+
seen_items: set[tuple[object, ...]] = set()
|
|
163
|
+
|
|
164
|
+
for page_number in range(1, MAX_DYNAMIC_PAGES + 1):
|
|
165
|
+
try:
|
|
166
|
+
result = await fetch_page(uid, credential, offset=offset)
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
raise DynamicFetchError(collection.pages, exc) from exc
|
|
169
|
+
|
|
170
|
+
collection.pages = page_number
|
|
171
|
+
items = _response_items(result)
|
|
172
|
+
if not items:
|
|
173
|
+
collection.complete = True
|
|
174
|
+
collection.stop_reason = "empty"
|
|
175
|
+
return collection
|
|
176
|
+
|
|
177
|
+
new_item_count = 0
|
|
178
|
+
for item in items:
|
|
179
|
+
key = _item_key(item)
|
|
180
|
+
if key in seen_items:
|
|
181
|
+
continue
|
|
182
|
+
seen_items.add(key)
|
|
183
|
+
new_item_count += 1
|
|
184
|
+
_record_item(collection, item, expected_uids, forced_uid=uid)
|
|
185
|
+
|
|
186
|
+
if baseline <= 0:
|
|
187
|
+
collection.complete = True
|
|
188
|
+
collection.stop_reason = "missing_baseline"
|
|
189
|
+
return collection
|
|
190
|
+
|
|
191
|
+
page_tail_ts = _page_tail_ts(items)
|
|
192
|
+
if page_tail_ts > 0 and page_tail_ts <= cutoff:
|
|
193
|
+
collection.complete = True
|
|
194
|
+
collection.stop_reason = "catchup_cutoff"
|
|
195
|
+
return collection
|
|
196
|
+
|
|
197
|
+
if not bool(result and result.get("has_more")):
|
|
198
|
+
collection.complete = True
|
|
199
|
+
collection.stop_reason = "end_of_feed"
|
|
200
|
+
return collection
|
|
201
|
+
|
|
202
|
+
next_offset = str(result.get("offset") or "").strip() if result else ""
|
|
203
|
+
if not next_offset:
|
|
204
|
+
collection.stop_reason = "empty_offset"
|
|
205
|
+
return collection
|
|
206
|
+
if next_offset in seen_offsets:
|
|
207
|
+
collection.stop_reason = "repeated_offset"
|
|
208
|
+
return collection
|
|
209
|
+
if new_item_count == 0:
|
|
210
|
+
collection.stop_reason = "no_progress"
|
|
211
|
+
return collection
|
|
212
|
+
|
|
213
|
+
offset = next_offset
|
|
214
|
+
seen_offsets.add(offset)
|
|
215
|
+
if request_interval > 0:
|
|
216
|
+
await asyncio.sleep(request_interval)
|
|
217
|
+
|
|
218
|
+
collection.stop_reason = "max_pages"
|
|
219
|
+
return collection
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def collect_followed_dynamic_pages(
|
|
223
|
+
baselines: dict[int, int],
|
|
224
|
+
cycle_started_ts: int,
|
|
225
|
+
credential: object,
|
|
226
|
+
fetch_page: DynamicPageFetcher,
|
|
227
|
+
*,
|
|
228
|
+
request_interval: float = _REQUEST_INTERVAL_SECONDS,
|
|
229
|
+
) -> DynamicCollection:
|
|
230
|
+
"""从聚合关注流第一页开始按响应游标收集本地目标动态。
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
baselines: 本轮所有聚合目标 UID 及其周期开始水位。
|
|
234
|
+
cycle_started_ts: 本轮检测开始的秒级时间戳。
|
|
235
|
+
credential: 透传给 SDK 适配函数的登录凭据。
|
|
236
|
+
fetch_page: 接受 ``credential`` 和 ``offset`` 的聚合分页函数。
|
|
237
|
+
request_interval: 聚合流连续翻页之间的等待秒数。
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
DynamicCollection: 已按本地 UID 过滤的候选和分页完整性。
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
DynamicFetchError: 请求失败时携带失败前已经完成的页数。
|
|
244
|
+
"""
|
|
245
|
+
collection = _new_collection(baselines, cycle_started_ts)
|
|
246
|
+
expected_uids = set(baselines)
|
|
247
|
+
if not expected_uids:
|
|
248
|
+
collection.complete = True
|
|
249
|
+
collection.stop_reason = "no_targets"
|
|
250
|
+
return collection
|
|
251
|
+
|
|
252
|
+
global_cutoff = min(collection.effective_cutoffs.values())
|
|
253
|
+
offset: int | None = None
|
|
254
|
+
seen_offsets: set[int] = set()
|
|
255
|
+
seen_items: set[tuple[object, ...]] = set()
|
|
256
|
+
|
|
257
|
+
for page_number in range(1, MAX_DYNAMIC_PAGES + 1):
|
|
258
|
+
try:
|
|
259
|
+
result = await fetch_page(credential, offset=offset)
|
|
260
|
+
except Exception as exc:
|
|
261
|
+
raise DynamicFetchError(collection.pages, exc) from exc
|
|
262
|
+
|
|
263
|
+
collection.pages = page_number
|
|
264
|
+
items = _response_items(result)
|
|
265
|
+
if not items:
|
|
266
|
+
collection.complete = True
|
|
267
|
+
collection.stop_reason = "empty"
|
|
268
|
+
return collection
|
|
269
|
+
|
|
270
|
+
new_item_count = 0
|
|
271
|
+
for item in items:
|
|
272
|
+
key = _item_key(item)
|
|
273
|
+
if key in seen_items:
|
|
274
|
+
continue
|
|
275
|
+
seen_items.add(key)
|
|
276
|
+
new_item_count += 1
|
|
277
|
+
_record_item(collection, item, expected_uids)
|
|
278
|
+
|
|
279
|
+
page_tail_ts = _page_tail_ts(items)
|
|
280
|
+
if page_tail_ts > 0 and page_tail_ts <= global_cutoff:
|
|
281
|
+
collection.complete = True
|
|
282
|
+
collection.stop_reason = "catchup_cutoff"
|
|
283
|
+
return collection
|
|
284
|
+
|
|
285
|
+
if not bool(result and result.get("has_more")):
|
|
286
|
+
collection.complete = True
|
|
287
|
+
collection.stop_reason = "end_of_feed"
|
|
288
|
+
return collection
|
|
289
|
+
|
|
290
|
+
next_offset = _normalize_feed_offset(result.get("offset") if result else None)
|
|
291
|
+
if next_offset is None:
|
|
292
|
+
collection.stop_reason = "empty_offset"
|
|
293
|
+
return collection
|
|
294
|
+
if next_offset in seen_offsets or next_offset == offset:
|
|
295
|
+
collection.stop_reason = "repeated_offset"
|
|
296
|
+
return collection
|
|
297
|
+
if new_item_count == 0:
|
|
298
|
+
collection.stop_reason = "no_progress"
|
|
299
|
+
return collection
|
|
300
|
+
|
|
301
|
+
offset = next_offset
|
|
302
|
+
seen_offsets.add(offset)
|
|
303
|
+
if request_interval > 0:
|
|
304
|
+
await asyncio.sleep(request_interval)
|
|
305
|
+
|
|
306
|
+
collection.stop_reason = "max_pages"
|
|
307
|
+
return collection
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""B 站关注关系同步用例。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from bilibili_api import Credential
|
|
9
|
+
from nonebot import logger
|
|
10
|
+
from nonebot_plugin_uniref import UserRef
|
|
11
|
+
|
|
12
|
+
from ..config import DynamicMode
|
|
13
|
+
from ..core.data_models import CredentialData
|
|
14
|
+
from ..infra.auth import (
|
|
15
|
+
is_login_invalid_error,
|
|
16
|
+
reset_login_invalid_notify_state,
|
|
17
|
+
)
|
|
18
|
+
from ..infra.bili_client import build_credential, ensure_user_followed
|
|
19
|
+
from ..infra.data_manager import data_manager
|
|
20
|
+
|
|
21
|
+
FollowSyncTrigger = Literal["startup", "qr_login"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def sync_followed_targets(
|
|
25
|
+
credential: Credential | None,
|
|
26
|
+
*,
|
|
27
|
+
dynamic_mode: DynamicMode,
|
|
28
|
+
trigger: FollowSyncTrigger,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""在关注流模式下尝试补齐已有目标的关注关系。
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
credential: 用于查询和修改关注关系的 B 站凭据。
|
|
34
|
+
dynamic_mode: 当前动态获取路线。
|
|
35
|
+
trigger: 发起同步的运行阶段,用于区分启动和二维码换号日志。
|
|
36
|
+
"""
|
|
37
|
+
if dynamic_mode != "follow_feed":
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
trigger_label = "启动" if trigger == "startup" else "二维码登录"
|
|
41
|
+
targets = data_manager.get_all_targets()
|
|
42
|
+
if not targets:
|
|
43
|
+
return
|
|
44
|
+
if credential is None:
|
|
45
|
+
logger.warning(
|
|
46
|
+
f"{trigger_label}关注同步缺少有效凭据,跳过补关注: "
|
|
47
|
+
"dynamic_mode=follow_feed, "
|
|
48
|
+
f"trigger={trigger}, "
|
|
49
|
+
f"uids={sorted(target.uid for target in targets)}, "
|
|
50
|
+
"stop_reason=missing_credential, action=skip_follow_sync"
|
|
51
|
+
)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
for index, target in enumerate(targets):
|
|
55
|
+
try:
|
|
56
|
+
followed = await ensure_user_followed(target.uid, credential)
|
|
57
|
+
if followed:
|
|
58
|
+
logger.info(
|
|
59
|
+
f"{trigger_label}关注同步已关注 {target.name}({target.uid}): "
|
|
60
|
+
f"dynamic_mode=follow_feed, trigger={trigger}, "
|
|
61
|
+
f"uid={target.uid}, action=follow"
|
|
62
|
+
)
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
if is_login_invalid_error(exc):
|
|
65
|
+
logger.warning(
|
|
66
|
+
f"{trigger_label}关注同步发现 B 站登录已失效,停止补关注: "
|
|
67
|
+
f"dynamic_mode=follow_feed, trigger={trigger}, uid={target.uid}, "
|
|
68
|
+
"stop_reason=login_invalid, action=stop_follow_sync"
|
|
69
|
+
)
|
|
70
|
+
break
|
|
71
|
+
logger.warning(
|
|
72
|
+
f"{trigger_label}关注同步失败,{target.name}({target.uid}) 仍使用关注流: "
|
|
73
|
+
f"dynamic_mode=follow_feed, trigger={trigger}, uid={target.uid}, "
|
|
74
|
+
"stop_reason=follow_failed, action=continue_follow_feed, "
|
|
75
|
+
f"error={exc!r}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if index + 1 < len(targets):
|
|
79
|
+
await asyncio.sleep(1)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def activate_login_credential(
|
|
83
|
+
credential: CredentialData,
|
|
84
|
+
*,
|
|
85
|
+
dynamic_mode: DynamicMode,
|
|
86
|
+
notice_ref: UserRef,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""先同步新账号关注,再持久化凭据及登录场景。"""
|
|
89
|
+
await sync_followed_targets(
|
|
90
|
+
build_credential(credential),
|
|
91
|
+
dynamic_mode=dynamic_mode,
|
|
92
|
+
trigger="qr_login",
|
|
93
|
+
)
|
|
94
|
+
data_manager.save_login_credential(credential, notice_ref)
|
|
95
|
+
reset_login_invalid_notify_state()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""历史订阅直播间信息补全。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from bilibili_api import Credential
|
|
6
|
+
from nonebot import logger
|
|
7
|
+
|
|
8
|
+
from ..core.profile import extract_live_room_id_from_user_info
|
|
9
|
+
from ..infra.auth import is_login_invalid_error
|
|
10
|
+
from ..infra.bili_client import get_user_info
|
|
11
|
+
from ..infra.data_manager import data_manager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def refresh_missing_live_room_ids(
|
|
15
|
+
credential: Credential | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""为缺少直播间 ID 的已有订阅补全数据。"""
|
|
18
|
+
targets = [
|
|
19
|
+
target for target in data_manager.get_all_targets() if not target.live_room_id
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
for target in targets:
|
|
23
|
+
try:
|
|
24
|
+
info = await get_user_info(target.uid, credential)
|
|
25
|
+
live_room_id = extract_live_room_id_from_user_info(info)
|
|
26
|
+
if live_room_id is None:
|
|
27
|
+
logger.debug(f"{target.name}({target.uid}) 没有可用的直播间")
|
|
28
|
+
else:
|
|
29
|
+
data_manager.update_target(target.uid, live_room_id=live_room_id)
|
|
30
|
+
logger.info(
|
|
31
|
+
f"启动时已补全 {target.name}({target.uid}) 的直播间 ID: "
|
|
32
|
+
f"{live_room_id}"
|
|
33
|
+
)
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
if is_login_invalid_error(exc):
|
|
36
|
+
logger.warning("启动补全直播间信息时发现 B 站登录已失效,跳过本轮补全")
|
|
37
|
+
return
|
|
38
|
+
logger.exception(f"启动补全 {target.name}({target.uid}) 直播间信息失败")
|
|
39
|
+
|
|
40
|
+
await asyncio.sleep(1)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""群订阅归属的可达性检测与显式清理。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TypeAlias
|
|
8
|
+
|
|
9
|
+
from nonebot import get_bots, logger
|
|
10
|
+
from nonebot.adapters import Bot
|
|
11
|
+
from nonebot_plugin_uninfo import SceneType, get_interface
|
|
12
|
+
from nonebot_plugin_uniref import (
|
|
13
|
+
SceneRef,
|
|
14
|
+
TargetUnavailableError,
|
|
15
|
+
encode_ref,
|
|
16
|
+
ref_to_target,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from ..infra.data_manager import data_manager
|
|
20
|
+
|
|
21
|
+
_BotIdentity: TypeAlias = tuple[str, str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class GroupReachabilityScan:
|
|
26
|
+
"""群订阅归属的可达性扫描结果。"""
|
|
27
|
+
|
|
28
|
+
stale_refs: list[SceneRef]
|
|
29
|
+
checked_refs: list[SceneRef]
|
|
30
|
+
skipped_refs: list[SceneRef]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _iter_active_bots() -> list[Bot]:
|
|
34
|
+
return list(get_bots().values())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _bot_identity(bot: Bot) -> _BotIdentity:
|
|
38
|
+
return bot.adapter.get_name(), str(bot.self_id)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _bot_can_route(ref: SceneRef, bot: Bot) -> bool:
|
|
42
|
+
"""判断在线 Bot 是否位于该 Ref 允许的路由范围内。"""
|
|
43
|
+
try:
|
|
44
|
+
ref_to_target(ref, bot=bot)
|
|
45
|
+
except (TargetUnavailableError, TypeError, ValueError):
|
|
46
|
+
return False
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _fetch_group_ids_for_bot(
|
|
51
|
+
bot: Bot,
|
|
52
|
+
*,
|
|
53
|
+
force_refresh: bool = False,
|
|
54
|
+
) -> set[str] | None:
|
|
55
|
+
interface = get_interface(bot)
|
|
56
|
+
if interface is None:
|
|
57
|
+
return None
|
|
58
|
+
if force_refresh:
|
|
59
|
+
interface.fetcher.clean()
|
|
60
|
+
try:
|
|
61
|
+
return {
|
|
62
|
+
scene.id
|
|
63
|
+
async for scene in interface.fetcher.query_scenes(bot, SceneType.GROUP)
|
|
64
|
+
}
|
|
65
|
+
except NotImplementedError:
|
|
66
|
+
return None
|
|
67
|
+
except Exception:
|
|
68
|
+
logger.exception(f"读取 Bot {bot.self_id} 的群场景列表失败,本轮改用逐群检查")
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def _check_group_reachability(
|
|
73
|
+
bot: Bot,
|
|
74
|
+
group_id: str,
|
|
75
|
+
) -> tuple[bool, bool | None]:
|
|
76
|
+
interface = get_interface(bot)
|
|
77
|
+
if interface is None:
|
|
78
|
+
return False, None
|
|
79
|
+
try:
|
|
80
|
+
scene = await interface.fetcher.query_scene(bot, SceneType.GROUP, group_id)
|
|
81
|
+
except NotImplementedError:
|
|
82
|
+
return False, None
|
|
83
|
+
except Exception:
|
|
84
|
+
logger.exception(
|
|
85
|
+
f"查询 Bot {bot.self_id} 下群 {group_id} 的可达性失败,本轮将跳过该群"
|
|
86
|
+
)
|
|
87
|
+
return True, None
|
|
88
|
+
return True, scene is not None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _configured_group_refs(refs: Iterable[SceneRef] | None) -> list[SceneRef]:
|
|
92
|
+
if refs is None:
|
|
93
|
+
result = [ref for ref, _ in data_manager.list_group_destinations()]
|
|
94
|
+
else:
|
|
95
|
+
result = [ref for ref in refs if data_manager.get_destination(ref) is not None]
|
|
96
|
+
return sorted(result, key=encode_ref)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def scan_stale_group_refs(
|
|
100
|
+
refs: Iterable[SceneRef] | None = None,
|
|
101
|
+
*,
|
|
102
|
+
force_refresh: bool = False,
|
|
103
|
+
) -> GroupReachabilityScan:
|
|
104
|
+
"""扫描当前配置中能够确认失效的群订阅归属。
|
|
105
|
+
|
|
106
|
+
同一 Ref 可能由多个在线 Bot 路由。只有所有兼容 Bot 都能完成检查且均不再
|
|
107
|
+
访问该群时,才把它判定为失效;任一兼容 Bot 可访问或无法检查都会阻止误删。
|
|
108
|
+
"""
|
|
109
|
+
configured_refs = _configured_group_refs(refs)
|
|
110
|
+
active_bots = _iter_active_bots()
|
|
111
|
+
compatible_bots_by_ref = {
|
|
112
|
+
ref: [bot for bot in active_bots if _bot_can_route(ref, bot)]
|
|
113
|
+
for ref in configured_refs
|
|
114
|
+
}
|
|
115
|
+
compatible_bots = {
|
|
116
|
+
_bot_identity(bot): bot
|
|
117
|
+
for bots in compatible_bots_by_ref.values()
|
|
118
|
+
for bot in bots
|
|
119
|
+
}
|
|
120
|
+
group_ids_by_bot = {
|
|
121
|
+
_bot_identity(bot): await _fetch_group_ids_for_bot(
|
|
122
|
+
bot,
|
|
123
|
+
force_refresh=force_refresh,
|
|
124
|
+
)
|
|
125
|
+
for bot in compatible_bots.values()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
stale_refs: list[SceneRef] = []
|
|
129
|
+
checked_refs: list[SceneRef] = []
|
|
130
|
+
skipped_refs: list[SceneRef] = []
|
|
131
|
+
|
|
132
|
+
for ref in configured_refs:
|
|
133
|
+
ref_bots = compatible_bots_by_ref[ref]
|
|
134
|
+
if not ref_bots:
|
|
135
|
+
skipped_refs.append(ref)
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
reachable = False
|
|
139
|
+
uncertain = False
|
|
140
|
+
checked_any = False
|
|
141
|
+
for bot in ref_bots:
|
|
142
|
+
group_ids = group_ids_by_bot[_bot_identity(bot)]
|
|
143
|
+
if group_ids is not None:
|
|
144
|
+
checked_any = True
|
|
145
|
+
if ref.id in group_ids:
|
|
146
|
+
reachable = True
|
|
147
|
+
break
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
supported, exists = await _check_group_reachability(bot, ref.id)
|
|
151
|
+
if not supported or exists is None:
|
|
152
|
+
uncertain = True
|
|
153
|
+
continue
|
|
154
|
+
checked_any = True
|
|
155
|
+
if exists:
|
|
156
|
+
reachable = True
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
if reachable:
|
|
160
|
+
checked_refs.append(ref)
|
|
161
|
+
elif uncertain or not checked_any:
|
|
162
|
+
skipped_refs.append(ref)
|
|
163
|
+
else:
|
|
164
|
+
checked_refs.append(ref)
|
|
165
|
+
stale_refs.append(ref)
|
|
166
|
+
|
|
167
|
+
return GroupReachabilityScan(
|
|
168
|
+
stale_refs=stale_refs,
|
|
169
|
+
checked_refs=checked_refs,
|
|
170
|
+
skipped_refs=skipped_refs,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cleanup_stale_groups(refs: Iterable[SceneRef]) -> tuple[int, int]:
|
|
175
|
+
"""删除失效群订阅归属,并返回归属数与订阅数。"""
|
|
176
|
+
removed_groups = 0
|
|
177
|
+
removed_subscriptions = 0
|
|
178
|
+
for ref in refs:
|
|
179
|
+
destination = data_manager.get_destination(ref)
|
|
180
|
+
if destination is None:
|
|
181
|
+
continue
|
|
182
|
+
removed_subscriptions += len(destination.subscriptions)
|
|
183
|
+
if data_manager.delete_destination(ref):
|
|
184
|
+
removed_groups += 1
|
|
185
|
+
return removed_groups, removed_subscriptions
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""按功能注册 Bili 命令处理器。"""
|
|
2
|
+
|
|
3
|
+
from . import cleanup as cleanup
|
|
4
|
+
from . import dynamics as dynamics
|
|
5
|
+
from . import help as help
|
|
6
|
+
from . import login as login
|
|
7
|
+
from . import subscriptions as subscriptions
|
|
8
|
+
|
|
9
|
+
__all__ = ("cleanup", "dynamics", "help", "login", "subscriptions")
|