Experimental Discord bot written in Python
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

joinraid.py 7.6KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. from discord import Guild, Intents, Member, Message, PartialEmoji, RawReactionActionEvent
  2. from discord.ext import commands
  3. from storage import Storage
  4. from cogs.base import BaseCog
  5. class JoinRecord:
  6. """
  7. Data object containing details about a guild join event.
  8. """
  9. def __init__(self, member: Member):
  10. self.member = member
  11. self.join_time = member.joined_at or datetime.now()
  12. self.is_kicked = False
  13. self.is_banned = False
  14. def age_seconds(self, now: datetime) -> float:
  15. """
  16. Returns the age of this join in seconds from the given "now" time.
  17. """
  18. a = now - self.join_time
  19. return float(a.total_seconds())
  20. class RaidPhase:
  21. """
  22. Enum of phases in a JoinRaid. Phases progress monotonically.
  23. """
  24. NONE = 0
  25. JUST_STARTED = 1
  26. CONTINUING = 2
  27. ENDED = 3
  28. class JoinRaid:
  29. """
  30. Tracks recent joins to a guild to detect join raids, where a large number of automated users
  31. all join at the same time.
  32. """
  33. def __init__(self):
  34. self.joins = []
  35. self.phase = RaidPhase.NONE
  36. # datetime when the raid started, or None.
  37. self.raid_start_time = None
  38. # Message posted to Discord to warn of the raid. Convenience property managed
  39. # by caller. Ignored by this class.
  40. self.warning_message = None
  41. def handle_join(self,
  42. member: Member,
  43. now: datetime,
  44. max_age_seconds: float,
  45. max_join_count: int) -> None:
  46. """
  47. Processes a new member join to a guild and detects join raids. Updates
  48. self.phase and self.raid_start_time properties.
  49. """
  50. # Check for existing record for this user
  51. print(f'handle_join({member.name}) start')
  52. join: JoinRecord = None
  53. i: int = 0
  54. while i < len(self.joins):
  55. elem = self.joins[i]
  56. if elem.member.id == member.id:
  57. print(f'Member {member.name} already in join list at index {i}. Removing.')
  58. join = self.joins.pop(i)
  59. join.join_time = now
  60. break
  61. i += 1
  62. # Add new record to end
  63. self.joins.append(join or JoinRecord(member))
  64. # Check raid status and do upkeep
  65. self.__process_joins(now, max_age_seconds, max_join_count)
  66. print(f'handle_join({member.name}) end')
  67. def __process_joins(self,
  68. now: datetime,
  69. max_age_seconds: float,
  70. max_join_count: int) -> None:
  71. """
  72. Processes self.joins after each addition, detects raids, updates self.phase,
  73. and throws out unneeded records.
  74. """
  75. print('__process_joins {')
  76. i: int = 0
  77. recent_count: int = 0
  78. should_cull: bool = self.phase == RaidPhase.NONE
  79. while i < len(self.joins):
  80. join: JoinRecord = self.joins[i]
  81. age: float = join.age_seconds(now)
  82. is_old: bool = age > max_age_seconds
  83. if not is_old:
  84. recent_count += 1
  85. print(f'- {i}. {join.member.name} is {age}s old - recent_count={recent_count}')
  86. if is_old and should_cull:
  87. self.joins.pop(i)
  88. print(f'- {i}. {join.member.name} is {age}s old - too old, removing')
  89. else:
  90. print(f'- {i}. {join.member.name} is {age}s old - moving on to next')
  91. i += 1
  92. is_raid = recent_count > max_join_count
  93. print(f'- is_raid {is_raid}')
  94. if is_raid:
  95. if self.phase == RaidPhase.NONE:
  96. self.phase = RaidPhase.JUST_STARTED
  97. self.raid_start_time = now
  98. print('- Phase moved to JUST_STARTED. Recording raid start time.')
  99. elif self.phase == RaidPhase.JUST_STARTED:
  100. self.phase = RaidPhase.CONTINUING
  101. print('- Phase moved to CONTINUING.')
  102. elif self.phase == self.phase in (RaidPhase.JUST_STARTED, RaidPhase.CONTINUING):
  103. self.phase = RaidPhase.ENDED
  104. print('- Phase moved to ENDED.')
  105. # Undo join add if the raid is over
  106. if self.phase == RaidPhase.ENDED and len(self.joins) > 0:
  107. last = self.joins.pop(-1)
  108. print(f'- Popping last join for {last.member.name}')
  109. print('} __process_joins')
  110. async def kick_all(self,
  111. reason: str = "Part of join raid") -> list[Member]:
  112. """
  113. Kicks all users in this join raid. Skips users who have already been
  114. flagged as having been kicked or banned. Returns a List of Members
  115. who were newly kicked.
  116. """
  117. kicks = []
  118. for join in self.joins:
  119. if join.is_kicked or join.is_banned:
  120. continue
  121. await join.member.kick(reason=reason)
  122. join.is_kicked = True
  123. kicks.append(join.member)
  124. self.phase = RaidPhase.ENDED
  125. return kicks
  126. async def ban_all(self,
  127. reason: str = "Part of join raid",
  128. delete_message_days: int = 0) -> list[Member]:
  129. """
  130. Bans all users in this join raid. Skips users who have already been
  131. flagged as having been banned. Users who were previously kicked can
  132. still be banned. Returns a List of Members who were newly banned.
  133. """
  134. bans = []
  135. for join in self.joins:
  136. if join.is_banned:
  137. continue
  138. await join.member.ban(reason=reason, delete_message_days=delete_message_days)
  139. join.is_banned = True
  140. bans.append(join.member)
  141. self.phase = RaidPhase.ENDED
  142. return bans
  143. class JoinRaidCog(BaseCog):
  144. """
  145. Cog for monitoring member joins and detecting potential bot raids.
  146. """
  147. def __init__(self, bot):
  148. self.bot = bot
  149. @commands.group(
  150. brief='Manages join raid detection and handling',
  151. )
  152. @commands.has_permissions(ban_members=True)
  153. @commands.guild_only()
  154. async def joinraid(self, context: commands.Context):
  155. 'Command group'
  156. if context.invoked_subcommand is None:
  157. await context.send_help()
  158. @joinraid.command(
  159. name='enable',
  160. brief='Enables join raid detection',
  161. description='Join raid detection is off by default.',
  162. )
  163. async def joinraid_enable(self, context: commands.Context):
  164. 'Command handler'
  165. # TODO
  166. pass
  167. @joinraid.command(
  168. name='disable',
  169. brief='Disables join raid detection',
  170. description='Join raid detection is off by default.',
  171. )
  172. async def joinraid_disable(self, context: commands.Context):
  173. 'Command handler'
  174. # TODO
  175. pass
  176. @joinraid.command(
  177. name='setrate',
  178. brief='Sets the rate of joins which triggers a warning to mods',
  179. description='Each time a member joins, the join records from the ' +
  180. 'previous _x_ seconds are counted up, where _x_ is the number of ' +
  181. 'seconds configured by this command. If that count meets or ' +
  182. 'exceeds the maximum join count configured by this command then ' +
  183. 'a raid is detected and a warning is issued to the mods.',
  184. usage='<join_count> <seconds>',
  185. )
  186. async def joinraid_setrate(self, context: commands.Context,
  187. joinCount: int,
  188. seconds: int):
  189. 'Command handler'
  190. # TODO
  191. pass
  192. @joinraid.command(
  193. name='getrate',
  194. brief='Shows the rate of joins which triggers a warning to mods',
  195. )
  196. async def joinraid_getrate(self, context: commands.Context):
  197. 'Command handler'
  198. # TODO
  199. pass
  200. @commands.Cog.listener()
  201. async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
  202. 'Event handler'
  203. # TODO
  204. pass
  205. @commands.Cog.listener()
  206. async def on_member_join(self, member: Member) -> None:
  207. 'Event handler'
  208. # TODO
  209. pass
  210. async def __send_warning(self) -> None:
  211. config = self.bot.get_cog('ConfigCog')
  212. if config is None:
  213. return
  214. config.