tiebameow 0.2.8__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.
tiebameow/__init__.py ADDED
File without changes
@@ -0,0 +1,4 @@
1
+ from .http_client import HTTPXClient
2
+ from .tieba_client import Client
3
+
4
+ __all__ = ["Client", "HTTPXClient"]
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import ssl
5
+ from contextlib import asynccontextmanager
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import httpx
9
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential_jitter
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import AsyncGenerator
13
+
14
+
15
+ class HTTPXClient:
16
+ _client: httpx.AsyncClient | None = None
17
+ _context: ssl.SSLContext | None = None
18
+ _lock: asyncio.Lock | None = None
19
+
20
+ DEFAULT_TIMEOUT: float = 10.0
21
+
22
+ DEFAULT_STOP = stop_after_attempt(3)
23
+ DEFAULT_WAIT = wait_exponential_jitter(initial=0.5, max=3.0)
24
+ DEFAULT_RETRY_CONDITION = retry_if_exception_type((
25
+ httpx.ConnectTimeout,
26
+ httpx.ReadTimeout,
27
+ httpx.RemoteProtocolError,
28
+ httpx.NetworkError,
29
+ ))
30
+
31
+ @classmethod
32
+ def _get_lock(cls) -> asyncio.Lock:
33
+ if cls._lock is None:
34
+ cls._lock = asyncio.Lock()
35
+ return cls._lock
36
+
37
+ @classmethod
38
+ @asynccontextmanager
39
+ async def get_client(cls) -> AsyncGenerator[httpx.AsyncClient, None]:
40
+ if cls._client is None or cls._client.is_closed:
41
+ async with cls._get_lock():
42
+ if cls._client is None or cls._client.is_closed:
43
+ if cls._context is None:
44
+ cls._context = ssl.create_default_context()
45
+ cls._context.set_ciphers("DEFAULT")
46
+ cls._client = httpx.AsyncClient(
47
+ timeout=cls.DEFAULT_TIMEOUT,
48
+ verify=cls._context,
49
+ )
50
+ yield cls._client
51
+
52
+ @classmethod
53
+ async def close(cls) -> None:
54
+ if cls._client and not cls._client.is_closed:
55
+ await cls._client.aclose()
56
+ cls._client = None
57
+
58
+ @classmethod
59
+ def configure_defaults(cls, timeout: float = DEFAULT_TIMEOUT, retry_config: dict[str, Any] | None = None) -> None:
60
+ """
61
+ 配置默认的超时和重试策略。
62
+
63
+ Args:
64
+ timeout: 默认的请求超时时间(秒)。
65
+ retry_config: 重试配置字典,包含 "stop", "wait", "retry" 键。
66
+ """
67
+ cls.DEFAULT_TIMEOUT = timeout
68
+
69
+ if retry_config:
70
+ if "stop" in retry_config:
71
+ cls.DEFAULT_STOP = retry_config["stop"]
72
+ if "wait" in retry_config:
73
+ cls.DEFAULT_WAIT = retry_config["wait"]
74
+ if "retry" in retry_config:
75
+ cls.DEFAULT_RETRY_CONDITION = retry_config["retry"]
76
+
77
+ @classmethod
78
+ async def get(cls, url: str, **kwargs: Any) -> httpx.Response | None:
79
+ @retry(stop=cls.DEFAULT_STOP, wait=cls.DEFAULT_WAIT, retry=cls.DEFAULT_RETRY_CONDITION)
80
+ async def _get() -> httpx.Response:
81
+ async with cls.get_client() as client:
82
+ response = await client.get(url, **kwargs)
83
+ response.raise_for_status()
84
+ return response
85
+
86
+ try:
87
+ return await _get()
88
+ except httpx.HTTPStatusError:
89
+ return None
90
+
91
+ @classmethod
92
+ async def post(cls, url: str, json: dict[str, Any] | None = None, **kwargs: Any) -> httpx.Response | None:
93
+ @retry(stop=cls.DEFAULT_STOP, wait=cls.DEFAULT_WAIT, retry=cls.DEFAULT_RETRY_CONDITION)
94
+ async def _post() -> httpx.Response:
95
+ async with cls.get_client() as client:
96
+ response = await client.post(url, json=json, **kwargs)
97
+ response.raise_for_status()
98
+ return response
99
+
100
+ try:
101
+ return await _post()
102
+ except Exception:
103
+ return None
@@ -0,0 +1,517 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Awaitable, Callable
6
+ from contextlib import AsyncExitStack, asynccontextmanager
7
+ from functools import wraps
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ import aiotieba as tb
11
+ from aiohttp import ClientError, ServerConnectionError, ServerTimeoutError
12
+ from aiotieba.exception import BoolResponse, HTTPStatusError, IntResponse, StrResponse, TiebaServerError
13
+ from tenacity import (
14
+ AsyncRetrying,
15
+ retry_if_exception_type,
16
+ stop_after_attempt,
17
+ wait_exponential_jitter,
18
+ )
19
+
20
+ from ..parser import (
21
+ convert_aiotieba_comments,
22
+ convert_aiotieba_posts,
23
+ convert_aiotieba_threads,
24
+ convert_aiotieba_userinfo,
25
+ )
26
+ from ..utils.logger import logger
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import AsyncGenerator
30
+ from datetime import datetime
31
+
32
+ from aiolimiter import AsyncLimiter
33
+ from aiotieba.api.get_bawu_blacklist._classdef import BawuBlacklistUsers
34
+ from aiotieba.api.get_bawu_postlogs._classdef import Postlogs
35
+ from aiotieba.api.get_bawu_userlogs._classdef import Userlogs
36
+ from aiotieba.api.get_follow_forums._classdef import FollowForums
37
+ from aiotieba.api.get_tab_map._classdef import TabMap
38
+ from aiotieba.api.get_user_contents._classdef import UserPostss, UserThreads
39
+ from aiotieba.api.tieba_uid2user_info._classdef import UserInfo_TUid
40
+ from aiotieba.typing import Comments, Posts, Threads, UserInfo
41
+
42
+ from ..models.dto import CommentsDTO, PostsDTO, ThreadsDTO, UserInfoDTO
43
+
44
+
45
+ class AiotiebaError(Exception):
46
+ """基础 aiotieba API 异常"""
47
+
48
+ def __init__(self, code: int, msg: str):
49
+ self.code = code
50
+ self.msg = msg
51
+ super().__init__(f"[{code}] {msg}")
52
+
53
+
54
+ class RetriableApiError(AiotiebaError):
55
+ """aiotieba 返回的可重试的异常"""
56
+
57
+
58
+ class UnretriableApiError(AiotiebaError):
59
+ """aiotieba 返回的无法重试的异常"""
60
+
61
+
62
+ class ErrorHandler:
63
+ RETRIABLE_CODES: set[int] = {
64
+ -65536, # 超时
65
+ 11, # 系统繁忙
66
+ 77, # 操作失败
67
+ 408, # 请求超时
68
+ 429, # 过多请求
69
+ 4011, # 需要验证码
70
+ 110001, # 未知错误
71
+ 110004, # tieba_uid2user_info 接口错误
72
+ 220034, # 操作过于频繁
73
+ 230871, # 发贴/删贴过于频繁
74
+ 300000, # 旧版客户端API无法封禁用户名为空用户
75
+ 1989005, # 加载数据失败
76
+ 2210002, # 系统错误
77
+ 28113295,
78
+ }
79
+
80
+ @classmethod
81
+ def check(cls, result: Any) -> None:
82
+ """解析 aiotieba 返回对象中的 err 字段"""
83
+ err = getattr(result, "err", None)
84
+ if err is None:
85
+ return
86
+
87
+ if isinstance(err, (HTTPStatusError, TiebaServerError)):
88
+ code = err.code
89
+ msg = err.msg
90
+
91
+ if code in cls.RETRIABLE_CODES:
92
+ raise RetriableApiError(code, msg)
93
+
94
+ raise UnretriableApiError(code, msg)
95
+
96
+ elif isinstance(err, Exception):
97
+ raise err
98
+
99
+
100
+ def with_ensure[F: Callable[..., Awaitable[Any]]](func: F) -> F:
101
+ """装饰器:为 aiotieba.Client 的方法添加重试和限流支持。"""
102
+
103
+ @wraps(func)
104
+ async def wrapper(self: Client, *args: Any, **kwargs: Any) -> Any:
105
+ return await self._request_core(func, *args, **kwargs)
106
+
107
+ return wrapper # type: ignore[return-value]
108
+
109
+
110
+ class Client(tb.Client): # type: ignore[misc]
111
+ """扩展的aiotieba客户端,添加了自定义的请求限流和并发控制功能。
112
+
113
+ 该客户端继承自aiotieba.Client,并在其基础上实现了速率限制和并发控制。
114
+ 通过装饰器和上下文管理器的方式,为所有API调用提供统一的速率限制和并发控制。
115
+ 同时还添加了对特定错误码的重试机制,以提高请求的成功率。
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ *args: Any,
121
+ limiter: AsyncLimiter | None = None,
122
+ semaphore: asyncio.Semaphore | None = None,
123
+ cooldown_429: float = 0.0,
124
+ retry_attempts: int = 3,
125
+ wait_initial: float = 0.5,
126
+ wait_max: float = 5.0,
127
+ **kwargs: Any,
128
+ ):
129
+ """初始化扩展的aiotieba客户端。
130
+
131
+ Args:
132
+ *args: 传递给父类构造函数的参数。
133
+ limiter: 速率限制器,用于控制每秒请求数。
134
+ semaphore: 信号量,用于控制最大并发数。
135
+ cooldown_seconds: 触发429时的全局冷却秒数。
136
+ **kwargs: 传递给父类构造函数的关键字参数。
137
+ """
138
+ super().__init__(*args, **kwargs)
139
+ self._limiter = limiter
140
+ self._semaphore = semaphore
141
+ self._cooldown_429 = cooldown_429
142
+ self._cooldown_until: float = 0.0
143
+ self._cooldown_lock = asyncio.Lock()
144
+ self._retry_attempts = retry_attempts
145
+ self._wait_initial = wait_initial
146
+ self._wait_max = wait_max
147
+
148
+ async def __aenter__(self) -> Client:
149
+ await super().__aenter__()
150
+ return self
151
+
152
+ async def __aexit__(
153
+ self,
154
+ exc_type: type[BaseException] | None = None,
155
+ exc_val: BaseException | None = None,
156
+ exc_tb: object = None,
157
+ ) -> None:
158
+ await super().__aexit__(exc_type, exc_val, exc_tb)
159
+
160
+ @property
161
+ def limiter(self) -> AsyncLimiter | None:
162
+ """获取速率限制器。"""
163
+ return self._limiter
164
+
165
+ @property
166
+ def semaphore(self) -> asyncio.Semaphore | None:
167
+ """获取信号量。"""
168
+ return self._semaphore
169
+
170
+ @asynccontextmanager
171
+ async def _with_limits(self) -> AsyncGenerator[None, None]:
172
+ """内部限流上下文管理器"""
173
+ async with AsyncExitStack() as stack:
174
+ if self._limiter:
175
+ await stack.enter_async_context(self._limiter)
176
+ if self._semaphore:
177
+ await stack.enter_async_context(self._semaphore)
178
+ yield
179
+
180
+ def _retry_strategy(self) -> AsyncRetrying:
181
+ """为每次请求创建重试器"""
182
+ return AsyncRetrying(
183
+ stop=stop_after_attempt(self._retry_attempts),
184
+ wait=wait_exponential_jitter(initial=self._wait_initial, max=self._wait_max),
185
+ retry=retry_if_exception_type((
186
+ OSError,
187
+ TimeoutError,
188
+ ConnectionError,
189
+ ServerTimeoutError,
190
+ ServerConnectionError,
191
+ ClientError,
192
+ HTTPStatusError,
193
+ TiebaServerError,
194
+ RetriableApiError,
195
+ )),
196
+ reraise=True,
197
+ )
198
+
199
+ async def _update_cooldown_until(self) -> None:
200
+ """延长全局冷却截止时间"""
201
+ async with self._cooldown_lock:
202
+ new_until = time.monotonic() + self._cooldown_429
203
+ if new_until > self._cooldown_until:
204
+ self._cooldown_until = new_until
205
+
206
+ async def _get_cooldown_wait(self) -> float:
207
+ """获取当前需要等待的全局冷却时间(秒)"""
208
+ async with self._cooldown_lock:
209
+ now = time.monotonic()
210
+ return max(0.0, self._cooldown_until - now)
211
+
212
+ async def _request_core(self, func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> Any:
213
+ """核心调度逻辑,处理限流、熔断和错误转换"""
214
+ retrying = self._retry_strategy()
215
+ async for attempt in retrying:
216
+ with attempt:
217
+ wait_time = await self._get_cooldown_wait()
218
+ if wait_time > 0:
219
+ logger.debug("Global cooldown active. Waiting for {:.1f}s", wait_time)
220
+ await asyncio.sleep(wait_time)
221
+
222
+ async with self._with_limits():
223
+ result = await func(self, *args, **kwargs)
224
+
225
+ try:
226
+ ErrorHandler.check(result)
227
+ except RetriableApiError as e:
228
+ if e.code == 429 and self._cooldown_429 > 0:
229
+ await self._update_cooldown_until()
230
+ logger.warning("Retrying {} due to: {}", func.__name__, e)
231
+ raise
232
+ except UnretriableApiError:
233
+ raise
234
+
235
+ return result
236
+
237
+ # 以下为直接返回DTO模型的封装方法
238
+
239
+ # 获取贴子内容 #
240
+
241
+ async def get_threads_dto(
242
+ self,
243
+ fname_or_fid: str | int,
244
+ /,
245
+ pn: int = 1,
246
+ *,
247
+ rn: int = 30,
248
+ sort: tb.ThreadSortType = tb.ThreadSortType.REPLY,
249
+ is_good: bool = False,
250
+ ) -> ThreadsDTO:
251
+ """获取指定贴吧的主题列表,并转换为通用DTO模型。"""
252
+ threads = await self.get_threads(fname_or_fid, pn, rn=rn, sort=sort, is_good=is_good)
253
+ return convert_aiotieba_threads(threads)
254
+
255
+ async def get_posts_dto(
256
+ self,
257
+ tid: int,
258
+ /,
259
+ pn: int = 1,
260
+ *,
261
+ rn: int = 30,
262
+ sort: tb.PostSortType = tb.PostSortType.ASC,
263
+ only_thread_author: bool = False,
264
+ with_comments: bool = False,
265
+ comment_sort_by_agree: bool = True,
266
+ comment_rn: int = 4,
267
+ ) -> PostsDTO:
268
+ """获取指定主题贴的回复列表,并转换为通用DTO模型。"""
269
+ posts = await self.get_posts(
270
+ tid,
271
+ pn,
272
+ rn=rn,
273
+ sort=sort,
274
+ only_thread_author=only_thread_author,
275
+ with_comments=with_comments,
276
+ comment_sort_by_agree=comment_sort_by_agree,
277
+ comment_rn=comment_rn,
278
+ )
279
+ return convert_aiotieba_posts(posts)
280
+
281
+ async def get_comments_dto(
282
+ self,
283
+ tid: int,
284
+ pid: int,
285
+ /,
286
+ pn: int = 1,
287
+ *,
288
+ is_comment: bool = False,
289
+ ) -> CommentsDTO:
290
+ """获取指定回复的楼中楼列表,并转换为通用DTO模型。"""
291
+ comments = await self.get_comments(tid, pid, pn, is_comment=is_comment)
292
+ return convert_aiotieba_comments(comments)
293
+
294
+ # 获取用户信息 #
295
+
296
+ async def anyid2user_info_dto(self, uid: int | str, is_tieba_uid: bool = True) -> UserInfoDTO:
297
+ """
298
+ 根据任意用户ID获取完整的用户信息,并转换为通用DTO模型。
299
+
300
+ Args:
301
+ uid: 用户ID,可以是贴吧ID、user_id、portrait或用户名。
302
+ is_tieba_uid: 指示uid是否为贴吧UID,默认为True。
303
+ """
304
+ if is_tieba_uid and isinstance(uid, int):
305
+ user_tuid = await self.tieba_uid2user_info(uid)
306
+ user = await self.get_user_info(user_tuid.user_id)
307
+ else:
308
+ user = await self.get_user_info(uid)
309
+ return convert_aiotieba_userinfo(user)
310
+
311
+ async def get_nickname_old(self, user_id: int) -> str:
312
+ user_info = await self.get_user_info(user_id, require=tb.ReqUInfo.BASIC)
313
+ return str(user_info.nick_name_old)
314
+
315
+ # 以下为重写的部分 aiotieba.Client API
316
+ # 添加了 @with_ensure 装饰器以启用重试机制
317
+ # 完全拦截过于魔法,这里仅重写部分常用API
318
+
319
+ # 获取贴子内容 #
320
+
321
+ @with_ensure
322
+ async def get_threads(
323
+ self,
324
+ fname_or_fid: str | int,
325
+ /,
326
+ pn: int = 1,
327
+ *,
328
+ rn: int = 30,
329
+ sort: tb.ThreadSortType = tb.ThreadSortType.REPLY,
330
+ is_good: bool = False,
331
+ ) -> Threads:
332
+ return await super().get_threads(fname_or_fid, pn, rn=rn, sort=sort, is_good=is_good)
333
+
334
+ @with_ensure
335
+ async def get_posts(
336
+ self,
337
+ tid: int,
338
+ /,
339
+ pn: int = 1,
340
+ *,
341
+ rn: int = 30,
342
+ sort: tb.PostSortType = tb.PostSortType.ASC,
343
+ only_thread_author: bool = False,
344
+ with_comments: bool = False,
345
+ comment_sort_by_agree: bool = True,
346
+ comment_rn: int = 4,
347
+ ) -> Posts:
348
+ return await super().get_posts(
349
+ tid,
350
+ pn,
351
+ rn=rn,
352
+ sort=sort,
353
+ only_thread_author=only_thread_author,
354
+ with_comments=with_comments,
355
+ comment_sort_by_agree=comment_sort_by_agree,
356
+ comment_rn=comment_rn,
357
+ )
358
+
359
+ @with_ensure
360
+ async def get_comments(
361
+ self,
362
+ tid: int,
363
+ pid: int,
364
+ /,
365
+ pn: int = 1,
366
+ *,
367
+ is_comment: bool = False,
368
+ ) -> Comments:
369
+ return await super().get_comments(tid, pid, pn, is_comment=is_comment)
370
+
371
+ @with_ensure
372
+ async def get_user_threads(
373
+ self,
374
+ id_: str | int | None = None,
375
+ pn: int = 1,
376
+ *,
377
+ public_only: bool = False,
378
+ ) -> UserThreads:
379
+ return await super().get_user_threads(id_, pn, public_only=public_only)
380
+
381
+ @with_ensure
382
+ async def get_user_posts(
383
+ self,
384
+ id_: str | int | None = None,
385
+ pn: int = 1,
386
+ *,
387
+ rn: int = 20,
388
+ ) -> UserPostss:
389
+ return await super().get_user_posts(id_, pn, rn=rn)
390
+
391
+ # 获取用户信息 #
392
+
393
+ @with_ensure
394
+ async def tieba_uid2user_info(self, tieba_uid: int) -> UserInfo_TUid:
395
+ return await super().tieba_uid2user_info(tieba_uid)
396
+
397
+ @with_ensure
398
+ async def get_user_info(self, id_: str | int, /, require: tb.ReqUInfo = tb.ReqUInfo.ALL) -> UserInfo:
399
+ return await super().get_user_info(id_, require)
400
+
401
+ @with_ensure
402
+ async def get_self_info(self, require: tb.ReqUInfo = tb.ReqUInfo.ALL) -> UserInfo:
403
+ return await super().get_self_info(require)
404
+
405
+ @with_ensure
406
+ async def get_follow_forums(self, id_: str | int, /, pn: int = 1, *, rn: int = 50) -> FollowForums:
407
+ return await super().get_follow_forums(id_, pn, rn=rn)
408
+
409
+ # 获取贴吧信息 #
410
+
411
+ @with_ensure
412
+ async def get_fid(self, fname: str) -> IntResponse:
413
+ return await super().get_fid(fname)
414
+
415
+ @with_ensure
416
+ async def get_fname(self, fid: int) -> StrResponse:
417
+ return await super().get_fname(fid)
418
+
419
+ @with_ensure
420
+ async def get_tab_map(self, fname_or_fid: str | int) -> TabMap:
421
+ return await super().get_tab_map(fname_or_fid)
422
+
423
+ # 吧务查询 #
424
+
425
+ @with_ensure
426
+ async def get_bawu_blacklist(self, fname_or_fid: str | int, /, pn: int = 1) -> BawuBlacklistUsers:
427
+ return await super().get_bawu_blacklist(fname_or_fid, pn)
428
+
429
+ @with_ensure
430
+ async def get_bawu_postlogs(
431
+ self,
432
+ fname_or_fid: str | int,
433
+ /,
434
+ pn: int = 1,
435
+ *,
436
+ search_value: str = "",
437
+ search_type: tb.BawuSearchType = tb.BawuSearchType.USER,
438
+ start_dt: datetime | None = None,
439
+ end_dt: datetime | None = None,
440
+ op_type: int = 0,
441
+ ) -> Postlogs:
442
+ return await super().get_bawu_postlogs(
443
+ fname_or_fid,
444
+ pn,
445
+ search_value=search_value,
446
+ search_type=search_type,
447
+ start_dt=start_dt,
448
+ end_dt=end_dt,
449
+ op_type=op_type,
450
+ )
451
+
452
+ @with_ensure
453
+ async def get_bawu_userlogs(
454
+ self,
455
+ fname_or_fid: str | int,
456
+ /,
457
+ pn: int = 1,
458
+ *,
459
+ search_value: str = "",
460
+ search_type: tb.BawuSearchType = tb.BawuSearchType.USER,
461
+ start_dt: datetime | None = None,
462
+ end_dt: datetime | None = None,
463
+ op_type: int = 0,
464
+ ) -> Userlogs:
465
+ return await super().get_bawu_userlogs(
466
+ fname_or_fid,
467
+ pn,
468
+ search_value=search_value,
469
+ search_type=search_type,
470
+ start_dt=start_dt,
471
+ end_dt=end_dt,
472
+ op_type=op_type,
473
+ )
474
+
475
+ # 吧务操作 #
476
+
477
+ @with_ensure
478
+ async def del_thread(self, fname_or_fid: str | int, /, tid: int) -> BoolResponse:
479
+ return await super().del_thread(fname_or_fid, tid)
480
+
481
+ @with_ensure
482
+ async def del_post(self, fname_or_fid: str | int, /, tid: int, pid: int) -> BoolResponse:
483
+ return await super().del_post(fname_or_fid, tid, pid)
484
+
485
+ @with_ensure
486
+ async def add_bawu_blacklist(self, fname_or_fid: str | int, /, id_: str | int) -> BoolResponse:
487
+ return await super().add_bawu_blacklist(fname_or_fid, id_)
488
+
489
+ @with_ensure
490
+ async def del_bawu_blacklist(self, fname_or_fid: str | int, /, id_: str | int) -> BoolResponse:
491
+ return await super().del_bawu_blacklist(fname_or_fid, id_)
492
+
493
+ @with_ensure
494
+ async def block(
495
+ self, fname_or_fid: str | int, /, id_: str | int, *, day: int = 1, reason: str = ""
496
+ ) -> BoolResponse:
497
+ return await super().block(fname_or_fid, id_, day=day, reason=reason)
498
+
499
+ @with_ensure
500
+ async def unblock(self, fname_or_fid: str | int, /, id_: str | int) -> BoolResponse:
501
+ return await super().unblock(fname_or_fid, id_)
502
+
503
+ @with_ensure
504
+ async def good(self, fname_or_fid: str | int, /, tid: int, *, cname: str = "") -> BoolResponse:
505
+ return await super().good(fname_or_fid, tid, cname=cname)
506
+
507
+ @with_ensure
508
+ async def ungood(self, fname_or_fid: str | int, /, tid: int) -> BoolResponse:
509
+ return await super().ungood(fname_or_fid, tid)
510
+
511
+ @with_ensure
512
+ async def top(self, fname_or_fid: str | int, /, tid: int, *, is_vip: bool = False) -> BoolResponse:
513
+ return await super().top(fname_or_fid, tid, is_vip=is_vip)
514
+
515
+ @with_ensure
516
+ async def untop(self, fname_or_fid: str | int, /, tid: int, *, is_vip: bool = False) -> BoolResponse:
517
+ return await super().untop(fname_or_fid, tid, is_vip=is_vip)
File without changes