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

joinraidcog.py 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. from discord import Guild, Intents, Member, Message, PartialEmoji, RawReactionActionEvent
  2. from discord.ext import commands
  3. from storage import Storage
  4. from cogs.basecog import BaseCog
  5. from config import CONFIG
  6. from datetime import datetime
  7. class JoinRecord:
  8. """
  9. Data object containing details about a single guild join event.
  10. """
  11. def __init__(self, member: Member):
  12. self.member = member
  13. self.join_time = member.joined_at or datetime.now()
  14. # These flags only track whether this bot has kicked/banned
  15. self.is_kicked = False
  16. self.is_banned = False
  17. def age_seconds(self, now: datetime) -> float:
  18. """
  19. Returns the age of this join in seconds from the given "now" time.
  20. """
  21. a = now - self.join_time
  22. return float(a.total_seconds())
  23. class RaidPhase:
  24. """
  25. Enum of phases in a JoinRaidRecord. Phases progress monotonically.
  26. """
  27. NONE = 0
  28. JUST_STARTED = 1
  29. CONTINUING = 2
  30. ENDED = 3
  31. class JoinRaidRecord:
  32. """
  33. Tracks recent joins to a guild to detect join raids, where a large number
  34. of automated users all join at the same time. Manages list of joins to not
  35. grow unbounded.
  36. """
  37. def __init__(self):
  38. self.joins = []
  39. self.phase = RaidPhase.NONE
  40. # datetime when the raid started, or None.
  41. self.raid_start_time = None
  42. # Message posted to Discord to warn of the raid. Convenience property
  43. # managed by caller. Ignored by this class.
  44. self.warning_message = None
  45. def handle_join(self,
  46. member: Member,
  47. now: datetime,
  48. max_join_count: int,
  49. max_age_seconds: float) -> None:
  50. """
  51. Processes a new member join to a guild and detects join raids. Updates
  52. self.phase and self.raid_start_time properties.
  53. """
  54. # Check for existing record for this user
  55. join: JoinRecord = None
  56. i: int = 0
  57. while i < len(self.joins):
  58. elem = self.joins[i]
  59. if elem.member.id == member.id:
  60. join = self.joins.pop(i)
  61. join.join_time = now
  62. break
  63. i += 1
  64. # Add new record to end
  65. self.joins.append(join or JoinRecord(member))
  66. # Check raid status and do upkeep
  67. self.__process_joins(now, max_age_seconds, max_join_count)
  68. def __process_joins(self,
  69. now: datetime,
  70. max_age_seconds: float,
  71. max_join_count: int) -> None:
  72. """
  73. Processes self.joins after each addition, detects raids, updates
  74. self.phase, and throws out unneeded records.
  75. """
  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. if is_old and should_cull:
  86. self.joins.pop(i)
  87. else:
  88. i += 1
  89. is_raid = recent_count > max_join_count
  90. if is_raid:
  91. if self.phase == RaidPhase.NONE:
  92. self.phase = RaidPhase.JUST_STARTED
  93. self.raid_start_time = now
  94. elif self.phase == RaidPhase.JUST_STARTED:
  95. self.phase = RaidPhase.CONTINUING
  96. elif self.phase == self.phase in (RaidPhase.JUST_STARTED, RaidPhase.CONTINUING):
  97. self.phase = RaidPhase.ENDED
  98. # Undo join add if the raid is over
  99. if self.phase == RaidPhase.ENDED and len(self.joins) > 0:
  100. last = self.joins.pop(-1)
  101. async def kick_all(self,
  102. reason: str = "Part of join raid") -> list[Member]:
  103. """
  104. Kicks all users in this join raid. Skips users who have already been
  105. flagged as having been kicked or banned. Returns a List of Members
  106. who were newly kicked.
  107. """
  108. kicks = []
  109. guild = None
  110. for join in self.joins:
  111. guild = join.member.guild
  112. if join.is_kicked or join.is_banned:
  113. continue
  114. await join.member.kick(reason=reason)
  115. join.is_kicked = True
  116. kicks.append(join.member)
  117. self.phase = RaidPhase.ENDED
  118. if len(kicks) > 0:
  119. self.log(guild, f'Mod kicked {len(kicks)} people')
  120. return kicks
  121. async def ban_all(self,
  122. reason: str = "Part of join raid",
  123. delete_message_days: int = 0) -> list[Member]:
  124. """
  125. Bans all users in this join raid. Skips users who have already been
  126. flagged as having been banned. Users who were previously kicked can
  127. still be banned. Returns a List of Members who were newly banned.
  128. """
  129. bans = []
  130. guild = None
  131. for join in self.joins:
  132. guild = join.member.guild
  133. if join.is_banned:
  134. continue
  135. await join.member.ban(
  136. reason=reason,
  137. delete_message_days=delete_message_days)
  138. join.is_banned = True
  139. bans.append(join.member)
  140. self.phase = RaidPhase.ENDED
  141. if len(bans) > 0:
  142. self.log(guild, f'Mod banned {len(bans)} people')
  143. return bans
  144. class GuildContext:
  145. """
  146. Logic and state for a single guild serviced by the bot.
  147. """
  148. def __init__(self, guild_id: int):
  149. self.guild_id = guild_id
  150. # Non-persisted runtime state
  151. self.current_raid = JoinRaidRecord()
  152. self.all_raids = [ self.current_raid ] # periodically culled of old ones
  153. def reset_raid(self, now: datetime):
  154. """
  155. Retires self.current_raid and creates a new empty one.
  156. """
  157. self.current_raid = JoinRaidRecord()
  158. self.all_raids.append(self.current_raid)
  159. self.__cull_old_raids(now)
  160. def find_raid_for_message_id(self, message_id: int) -> JoinRaidRecord:
  161. """
  162. Retrieves a JoinRaidRecord instance for the given raid warning message.
  163. Returns None if not found.
  164. """
  165. for raid in self.all_raids:
  166. if raid.warning_message is not None and raid.warning_message.id == message_id:
  167. return raid
  168. return None
  169. def __cull_old_raids(self, now: datetime):
  170. """
  171. Gets rid of old JoinRaidRecord records from self.all_raids that are too
  172. old to still be useful.
  173. """
  174. i: int = 0
  175. while i < len(self.all_raids):
  176. raid = self.all_raids[i]
  177. if raid == self.current_raid:
  178. i += 1
  179. continue
  180. age_seconds = float((raid.raid_start_time - now).total_seconds())
  181. if age_seconds > 86400.0:
  182. self.__trace('Culling old raid')
  183. self.all_raids.pop(i)
  184. else:
  185. i += 1
  186. def __trace(self, message):
  187. """
  188. Debugging trace.
  189. """
  190. print(f'{self.guild_id}: {message}')
  191. class JoinRaidCog(BaseCog):
  192. """
  193. Cog for monitoring member joins and detecting potential bot raids.
  194. """
  195. MIN_JOIN_COUNT = 2
  196. STATE_KEY_RAID_COUNT = 'joinraid_count'
  197. STATE_KEY_RAID_SECONDS = 'joinraid_seconds'
  198. STATE_KEY_ENABLED = 'joinraid_enabled'
  199. def __init__(self, bot):
  200. super().__init__(bot)
  201. self.guild_id_to_context = {} # Guild.id -> GuildContext
  202. # -- Config -------------------------------------------------------------
  203. def __get_raid_rate(self, guild: Guild) -> tuple:
  204. """
  205. Returns the join rate configured for this guild.
  206. """
  207. count: int = Storage.get_config_value(guild, self.STATE_KEY_RAID_COUNT) \
  208. or self.get_cog_default('warning_count')
  209. seconds: float = Storage.get_config_value(guild, self.STATE_KEY_RAID_SECONDS) \
  210. or self.get_cog_default('warning_seconds')
  211. return (count, seconds)
  212. def __is_enabled(self, guild: Guild) -> bool:
  213. """
  214. Returns whether join raid detection is enabled in this guild.
  215. """
  216. return Storage.get_config_value(guild, self.STATE_KEY_ENABLED) \
  217. or self.get_cog_default('enabled')
  218. # -- Commands -----------------------------------------------------------
  219. @commands.group(
  220. brief='Manages join raid detection and handling',
  221. )
  222. @commands.has_permissions(ban_members=True)
  223. @commands.guild_only()
  224. async def joinraid(self, context: commands.Context):
  225. 'Command group'
  226. if context.invoked_subcommand is None:
  227. await context.send_help()
  228. @joinraid.command(
  229. name='enable',
  230. brief='Enables join raid detection',
  231. description='Join raid detection is off by default.',
  232. )
  233. async def joinraid_enable(self, context: commands.Context):
  234. 'Command handler'
  235. guild = context.guild
  236. Storage.set_config_value(guild, self.STATE_KEY_ENABLED, True)
  237. # TODO: Startup tracking if necessary
  238. await context.message.reply(
  239. CONFIG['success_emoji'] + ' ' +
  240. self.__describe_raid_settings(guild, force_enabled_status=True),
  241. mention_author=False)
  242. @joinraid.command(
  243. name='disable',
  244. brief='Disables join raid detection',
  245. description='Join raid detection is off by default.',
  246. )
  247. async def joinraid_disable(self, context: commands.Context):
  248. 'Command handler'
  249. guild = context.guild
  250. Storage.set_config_value(guild, self.STATE_KEY_ENABLED, False)
  251. # TODO: Tear down tracking if necessary
  252. await context.message.reply(
  253. CONFIG['success_emoji'] + ' ' +
  254. self.__describe_raid_settings(guild, force_enabled_status=True),
  255. mention_author=False)
  256. @joinraid.command(
  257. name='setrate',
  258. brief='Sets the rate of joins which triggers a warning to mods',
  259. description='Each time a member joins, the join records from the ' +
  260. 'previous _x_ seconds are counted up, where _x_ is the number of ' +
  261. 'seconds configured by this command. If that count meets or ' +
  262. 'exceeds the maximum join count configured by this command then ' +
  263. 'a raid is detected and a warning is issued to the mods.',
  264. usage='<join_count:int> <seconds:float>',
  265. )
  266. async def joinraid_setrate(self, context: commands.Context,
  267. join_count: int,
  268. seconds: float):
  269. 'Command handler'
  270. guild = context.guild
  271. if join_count < self.MIN_JOIN_COUNT:
  272. await context.message.reply(
  273. CONFIG['warning_emoji'] + ' ' +
  274. f'`join_count` must be >= {self.MIN_JOIN_COUNT}',
  275. mention_author=False)
  276. return
  277. if seconds <= 0:
  278. await context.message.reply(
  279. CONFIG['warning_emoji'] + ' ' +
  280. f'`seconds` must be > 0',
  281. mention_author=False)
  282. return
  283. Storage.set_config_values(guild, {
  284. self.STATE_KEY_RAID_COUNT: join_count,
  285. self.STATE_KEY_RAID_SECONDS: seconds,
  286. })
  287. await context.message.reply(
  288. CONFIG['success_emoji'] + ' ' +
  289. self.__describe_raid_settings(guild, force_rate_status=True),
  290. mention_author=False)
  291. @joinraid.command(
  292. name='getrate',
  293. brief='Shows the rate of joins which triggers a warning to mods',
  294. )
  295. async def joinraid_getrate(self, context: commands.Context):
  296. 'Command handler'
  297. await context.message.reply(
  298. CONFIG['info_emoji'] + ' ' +
  299. self.__describe_raid_settings(context.guild, force_rate_status=True),
  300. mention_author=False)
  301. # -- Listeners ----------------------------------------------------------
  302. @commands.Cog.listener()
  303. async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
  304. 'Event handler'
  305. if payload.user_id == self.bot.user.id:
  306. # Ignore bot's own reactions
  307. return
  308. member: Member = payload.member
  309. if member is None:
  310. return
  311. guild: Guild = self.bot.get_guild(payload.guild_id)
  312. if guild is None:
  313. # Possibly a DM
  314. return
  315. channel: GuildChannel = guild.get_channel(payload.channel_id)
  316. if channel is None:
  317. # Possibly a DM
  318. return
  319. message: Message = await channel.fetch_message(payload.message_id)
  320. if message is None:
  321. # Message deleted?
  322. return
  323. if message.author.id != self.bot.user.id:
  324. # Bot didn't author this
  325. return
  326. if not member.permissions_in(channel).ban_members:
  327. # Not a mod
  328. # TODO: Remove reaction?
  329. return
  330. gc: GuildContext = self.__get_guild_context(guild)
  331. raid: JoinRaidRecord = gc.find_raid_for_message_id(payload.message_id)
  332. if raid is None:
  333. # Either not a warning message or one we stopped tracking
  334. return
  335. emoji: PartialEmoji = payload.emoji
  336. if emoji.name == CONFIG['kick_emoji']:
  337. await raid.kick_all()
  338. gc.reset_raid(message.created_at)
  339. await self.__update_raid_warning(guild, raid)
  340. elif emoji.name == CONFIG['ban_emoji']:
  341. await raid.ban_all()
  342. gc.reset_raid(message.created_at)
  343. await self.__update_raid_warning(guild, raid)
  344. @commands.Cog.listener()
  345. async def on_member_join(self, member: Member) -> None:
  346. 'Event handler'
  347. guild: Guild = member.guild
  348. if not self.__is_enabled(guild):
  349. return
  350. (count, seconds) = self.__get_raid_rate(guild)
  351. now = member.joined_at
  352. gc: GuildContext = self.__get_guild_context(guild)
  353. raid: JoinRaidRecord = gc.current_raid
  354. raid.handle_join(member, now, count, seconds)
  355. if raid.phase == RaidPhase.JUST_STARTED:
  356. await self.__post_raid_warning(guild, raid)
  357. elif raid.phase == RaidPhase.CONTINUING:
  358. await self.__update_raid_warning(guild, raid)
  359. elif raid.phase == RaidPhase.ENDED:
  360. # First join that occurred too late to be part of last raid. Join
  361. # not added. Start a new raid record and add it there.
  362. gc.reset_raid(now)
  363. gc.current_raid.handle_join(member, now, count, seconds)
  364. # -- Misc ---------------------------------------------------------------
  365. def __describe_raid_settings(self,
  366. guild: Guild,
  367. force_enabled_status=False,
  368. force_rate_status=False) -> str:
  369. """
  370. Creates a Discord message describing the current join raid settings.
  371. """
  372. enabled = self.__is_enabled(guild)
  373. (count, seconds) = self.__get_raid_rate(guild)
  374. sentences = []
  375. if enabled or force_rate_status:
  376. sentences.append(f'Join raids will be detected at {count} or more joins per {seconds} seconds.')
  377. if enabled and force_enabled_status:
  378. sentences.append('Raid detection enabled.')
  379. elif not enabled:
  380. sentences.append('Raid detection disabled.')
  381. tips = []
  382. if enabled or force_rate_status:
  383. tips.append('• Use `setrate` subcommand to change detection threshold')
  384. if enabled:
  385. tips.append('• Use `disable` subcommand to disable detection.')
  386. else:
  387. tips.append('• Use `enable` subcommand to enable detection.')
  388. message = ''
  389. message += ' '.join(sentences)
  390. if len(tips) > 0:
  391. message += '\n\n' + ('\n'.join(tips))
  392. return message
  393. def __get_guild_context(self, guild: Guild) -> GuildContext:
  394. """
  395. Looks up the GuildContext for the given Guild or creates a new one if
  396. one does not yet exist.
  397. """
  398. gc: GuildContext = self.guild_id_to_context.get(guild.id)
  399. if gc is not None:
  400. return gc
  401. gc = GuildContext(guild.id)
  402. gc.join_warning_count = self.get_cog_default('warning_count')
  403. gc.join_warning_seconds = self.get_cog_default('warning_seconds')
  404. self.guild_id_to_context[guild.id] = gc
  405. return gc
  406. async def __post_raid_warning(self, guild: Guild, raid: JoinRaidRecord) -> None:
  407. """
  408. Posts a warning message about the given raid.
  409. """
  410. (message, can_kick, can_ban) = self.__describe_raid(raid)
  411. raid.warning_message = await self.warn(guild, message)
  412. if can_kick:
  413. await raid.warning_message.add_reaction(CONFIG['kick_emoji'])
  414. if can_ban:
  415. await raid.warning_message.add_reaction(CONFIG['ban_emoji'])
  416. self.log(guild, f'New join raid detected!')
  417. async def __update_raid_warning(self, guild: Guild, raid: JoinRaidRecord) -> None:
  418. """
  419. Updates the existing warning message for a raid.
  420. """
  421. if raid.warning_message is None:
  422. return
  423. (message, can_kick, can_ban) = self.__describe_raid(raid)
  424. await self.update_warn(raid.warning_message, message)
  425. if not can_kick:
  426. await raid.warning_message.clear_reaction(CONFIG['kick_emoji'])
  427. if not can_ban:
  428. await raid.warning_message.clear_reaction(CONFIG['ban_emoji'])
  429. def __describe_raid(self, raid: JoinRaidRecord) -> tuple:
  430. """
  431. Creates a Discord warning message with details about the given raid.
  432. Returns a tuple containing the message text, a flag if any users can
  433. still be kicked, and a flag if anyone can still be banned.
  434. """
  435. message = '🚨 **JOIN RAID DETECTED** 🚨'
  436. message += '\nThe following members joined in close succession:\n'
  437. any_kickable = False
  438. any_bannable = False
  439. for join in raid.joins:
  440. message += '\n• '
  441. if join.is_banned:
  442. message += '~~' + join.member.mention + '~~ - banned'
  443. elif join.is_kicked:
  444. message += '~~' + join.member.mention + '~~ - kicked'
  445. any_bannable = True
  446. else:
  447. message += join.member.mention
  448. any_bannable = True
  449. any_kickable = True
  450. message += '\n_(list updates automatically)_'
  451. message += '\n'
  452. if any_kickable:
  453. message += f'\nReact to this message with {CONFIG["kick_emoji"]} to kick all these users.'
  454. else:
  455. message += '\nNo users left to kick.'
  456. if any_bannable:
  457. message += f'\nReact to this message with {CONFIG["ban_emoji"]} to ban all these users.'
  458. else:
  459. message += '\nNo users left to ban.'
  460. return (message, any_kickable, any_bannable)