Experimental Discord bot written in Python
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

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