Experimental Discord bot written in Python
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

joinraidcog.py 15KB

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