message_cleanup.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. from __future__ import annotations
  2. import asyncio
  3. import logging
  4. import re
  5. from functools import wraps
  6. from typing import Any
  7. from weakref import WeakKeyDictionary
  8. GROUP_INTERACTION_TTL_SECONDS = 10
  9. _LOGGER = logging.getLogger(__name__)
  10. _scheduled_messages: set[tuple[int, int]] = set()
  11. _cleanup_tasks: set[asyncio.Task[Any]] = set()
  12. _active_interactions: WeakKeyDictionary[asyncio.Task[Any], tuple[int, int]] = WeakKeyDictionary()
  13. _TRACKED_SEND_METHODS = (
  14. "send_message",
  15. "send_photo",
  16. "send_document",
  17. "send_video",
  18. "send_animation",
  19. "send_audio",
  20. "send_voice",
  21. "send_video_note",
  22. "send_sticker",
  23. "send_contact",
  24. "send_location",
  25. "send_venue",
  26. "send_poll",
  27. "send_dice",
  28. "send_media_group",
  29. "send_cached_media",
  30. "copy_message",
  31. "copy_media_group",
  32. "forward_messages",
  33. )
  34. def _message_key(message: Any) -> tuple[int, int] | None:
  35. chat = getattr(message, "chat", None)
  36. message_id = getattr(message, "id", None)
  37. if not chat or not message_id:
  38. return None
  39. try:
  40. return int(chat.id), int(message_id)
  41. except (TypeError, ValueError):
  42. return None
  43. def _is_group_message(message: Any) -> bool:
  44. chat = getattr(message, "chat", None)
  45. if not chat:
  46. return False
  47. chat_type = getattr(chat, "type", None)
  48. chat_type_value = str(getattr(chat_type, "value", chat_type) or "").lower()
  49. if chat_type_value:
  50. return chat_type_value in {"group", "supergroup"}
  51. try:
  52. return int(chat.id) < 0
  53. except (TypeError, ValueError):
  54. return False
  55. def _current_task() -> asyncio.Task[Any] | None:
  56. try:
  57. return asyncio.current_task()
  58. except RuntimeError:
  59. return None
  60. async def _delete_later(message: Any, delay_seconds: int) -> None:
  61. key = _message_key(message)
  62. if key is None:
  63. return
  64. try:
  65. await asyncio.sleep(max(1, int(delay_seconds)))
  66. try:
  67. await message.delete(revoke=True)
  68. except asyncio.CancelledError:
  69. raise
  70. except Exception as exc:
  71. _LOGGER.warning(
  72. "群交互消息自动删除失败 chat_id=%s message_id=%s: %s",
  73. key[0],
  74. key[1],
  75. exc,
  76. )
  77. finally:
  78. _scheduled_messages.discard(key)
  79. def schedule_group_cleanup(
  80. *messages: Any,
  81. delay_seconds: int = GROUP_INTERACTION_TTL_SECONDS,
  82. ) -> None:
  83. for message in messages:
  84. key = _message_key(message)
  85. if key is None or not _is_group_message(message):
  86. continue
  87. if key in _scheduled_messages:
  88. continue
  89. _scheduled_messages.add(key)
  90. task = asyncio.create_task(
  91. _delete_later(message, delay_seconds),
  92. name=f"group-interaction-cleanup:{key[0]}:{key[1]}",
  93. )
  94. _cleanup_tasks.add(task)
  95. task.add_done_callback(_cleanup_tasks.discard)
  96. async def reply_group_interaction(
  97. message: Any,
  98. text: str,
  99. **kwargs: Any,
  100. ) -> Any:
  101. """Reply to a group interaction and explicitly clean up both messages."""
  102. response = await message.reply(text, **kwargs)
  103. schedule_group_cleanup(message, response)
  104. return response
  105. def is_group_cleanup_scheduled(message: Any) -> bool:
  106. key = _message_key(message)
  107. return bool(key and key in _scheduled_messages)
  108. def clear_current_group_interaction() -> None:
  109. task = _current_task()
  110. if task is not None:
  111. _active_interactions.pop(task, None)
  112. def is_group_interaction_candidate(
  113. message: Any, *, menu_texts: set[str] | frozenset[str] = frozenset()
  114. ) -> bool:
  115. if not _is_group_message(message):
  116. return False
  117. text = str(
  118. getattr(message, "text", None) or getattr(message, "caption", None) or ""
  119. ).strip()
  120. folded = text.casefold()
  121. if text.startswith("/") or folded in {"@admin", "@admins"}:
  122. return True
  123. if folded.startswith(("@admin ", "@admins ")) or text in menu_texts:
  124. return True
  125. return bool(
  126. re.fullmatch(
  127. r"(?:抽奖|开奖|中奖名单|签到|积分|积分排行|流水|封禁|解封|禁言|解除禁言|禁言\d+分钟|警告|删除消息|删除|删|踢出|踢|飞)",
  128. text,
  129. )
  130. )
  131. def begin_group_message_interaction(
  132. message: Any,
  133. *,
  134. menu_texts: set[str] | frozenset[str] = frozenset(),
  135. delay_seconds: int = GROUP_INTERACTION_TTL_SECONDS,
  136. ) -> bool:
  137. clear_current_group_interaction()
  138. if not is_group_interaction_candidate(message, menu_texts=menu_texts):
  139. return False
  140. task = _current_task()
  141. if task is None:
  142. return False
  143. _active_interactions[task] = (int(message.chat.id), delay_seconds)
  144. schedule_group_cleanup(message, delay_seconds=delay_seconds)
  145. return True
  146. def begin_group_callback_interaction(
  147. query: Any, *, delay_seconds: int = GROUP_INTERACTION_TTL_SECONDS
  148. ) -> bool:
  149. clear_current_group_interaction()
  150. message = getattr(query, "message", None)
  151. if (
  152. not message
  153. or not _is_group_message(message)
  154. or not is_group_cleanup_scheduled(message)
  155. ):
  156. return False
  157. task = _current_task()
  158. if task is None:
  159. return False
  160. _active_interactions[task] = (int(message.chat.id), delay_seconds)
  161. return True
  162. def track_current_group_interaction(
  163. result: Any,
  164. *,
  165. reply_to_message_id: Any = None,
  166. ) -> None:
  167. if reply_to_message_id is None:
  168. return
  169. task = _current_task()
  170. if task is None:
  171. return
  172. context = _active_interactions.get(task)
  173. if context is None:
  174. return
  175. chat_id, delay_seconds = context
  176. messages = result if isinstance(result, (list, tuple)) else (result,)
  177. for message in messages:
  178. key = _message_key(message)
  179. if key and key[0] == chat_id and _is_group_message(message):
  180. schedule_group_cleanup(message, delay_seconds=delay_seconds)
  181. def install_group_interaction_tracking(client: Any) -> None:
  182. if getattr(client, "_wbb_group_interaction_tracking", False):
  183. return
  184. for method_name in _TRACKED_SEND_METHODS:
  185. original = getattr(client, method_name, None)
  186. if not callable(original):
  187. continue
  188. @wraps(original)
  189. async def tracked_send(*args: Any, _original=original, **kwargs: Any):
  190. result = await _original(*args, **kwargs)
  191. track_current_group_interaction(
  192. result,
  193. reply_to_message_id=kwargs.get("reply_to_message_id"),
  194. )
  195. return result
  196. setattr(client, method_name, tracked_send)
  197. client._wbb_group_interaction_tracking = True