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.

urlspamcog.py 6.4KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. from discord import Guild, Member, Message
  2. from discord.ext import commands
  3. import re
  4. from datetime import timedelta
  5. from cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
  6. from config import CONFIG
  7. from storage import Storage
  8. class URLSpamContext:
  9. def __init__(self, spam_message: Message):
  10. self.spam_message = spam_message
  11. self.is_deleted = False
  12. self.is_kicked = False
  13. self.is_banned = False
  14. class URLSpamCog(BaseCog):
  15. """
  16. Detects users posting URLs who just joined recently: a common spam pattern.
  17. Can be configured to take immediate action or just warn the mods.
  18. """
  19. SETTING_ENABLED = CogSetting('enabled', bool,
  20. brief='URL spam detection',
  21. description='Whether URLs posted soon after joining are flagged.')
  22. SETTING_ACTION = CogSetting('action', str,
  23. brief='action to take on spam',
  24. description='The action to take on detected URL spam.',
  25. enum_values=set(['nothing', 'modwarn', 'delete', 'kick', 'ban']))
  26. SETTING_JOIN_AGE = CogSetting('joinage', float,
  27. brief='seconds since member joined',
  28. description='The minimum seconds since the user joined the ' + \
  29. 'server before they can post URLs. URLs posted by users ' + \
  30. 'who joined too recently will be flagged. Keep in mind ' + \
  31. 'many servers have a minimum 10 minute cooldown before ' + \
  32. 'new members can say anything. Setting to 0 effectively ' + \
  33. 'disables URL spam detection.',
  34. usage='<seconds:int>',
  35. min_value=0)
  36. def __init__(self, bot):
  37. super().__init__(bot)
  38. self.add_setting(URLSpamCog.SETTING_ENABLED)
  39. self.add_setting(URLSpamCog.SETTING_ACTION)
  40. self.add_setting(URLSpamCog.SETTING_JOIN_AGE)
  41. @commands.group(
  42. brief='Manages URL spam detection',
  43. )
  44. @commands.has_permissions(ban_members=True)
  45. @commands.guild_only()
  46. async def urlspam(self, context: commands.Context):
  47. 'Command group'
  48. if context.invoked_subcommand is None:
  49. await context.send_help()
  50. @commands.Cog.listener()
  51. async def on_message(self, message: Message):
  52. if message.author is None or \
  53. message.author.bot or \
  54. message.guild is None or \
  55. message.channel is None or \
  56. message.content is None:
  57. return
  58. if not self.get_guild_setting(message.guild, self.SETTING_ENABLED):
  59. return
  60. action = self.get_guild_setting(message.guild, self.SETTING_ACTION)
  61. join_seconds = self.get_guild_setting(message.guild, self.SETTING_JOIN_AGE)
  62. min_join_age = timedelta(seconds=join_seconds)
  63. if action == 'nothing':
  64. return
  65. if not self.__contains_url(message.content):
  66. return
  67. join_age = message.created_at - message.author.joined_at
  68. join_age_str = self.__format_timedelta(join_age)
  69. if join_age < min_join_age:
  70. context = URLSpamContext(message)
  71. needs_attention = False
  72. if action == 'modwarn':
  73. needs_attention = True
  74. self.log(message.guild, f'New user {message.author.name} ' + \
  75. f'({message.author.id}) posted URL {join_age_str} after ' + \
  76. 'joining. Mods alerted.')
  77. elif action == 'delete':
  78. await message.delete()
  79. context.is_deleted = True
  80. self.log(message.guild, f'New user {message.author.name} ' + \
  81. f'({message.author.id}) posted URL {join_age_str} after ' + \
  82. 'joining. Message deleted.')
  83. elif action == 'kick':
  84. await message.delete()
  85. context.is_deleted = True
  86. await message.author.kick(
  87. reason=f'Rocketbot: Posted a link {join_age_str} after joining')
  88. context.is_kicked = True
  89. self.log(message.guild, f'New user {message.author.name} ' + \
  90. f'({message.author.id}) posted URL {join_age_str} after ' + \
  91. 'joining. User kicked.')
  92. elif action == 'ban':
  93. await message.author.ban(
  94. reason=f'Rocketbot: User posted a link {join_age_str} after joining',
  95. delete_message_days=1)
  96. context.is_deleted = True
  97. context.is_kicked = True
  98. context.is_banned = True
  99. self.log(message.guild, f'New user {message.author.name} ' + \
  100. f'({message.author.id}) posted URL {join_age_str} after ' + \
  101. 'joining. User banned.')
  102. bm = BotMessage(
  103. message.guild,
  104. f'User {message.author.mention} posted a URL ' + \
  105. f'{join_age_str} after joining.',
  106. type = BotMessage.TYPE_MOD_WARNING if needs_attention else BotMessage.TYPE_INFO,
  107. context = context)
  108. bm.quote = message.content
  109. await bm.set_reactions(BotMessageReaction.standard_set(
  110. did_delete=context.is_deleted,
  111. did_kick=context.is_kicked,
  112. did_ban=context.is_banned))
  113. await self.post_message(bm)
  114. async def on_mod_react(self,
  115. bot_message: BotMessage,
  116. reaction: BotMessageReaction,
  117. reacted_by: Member) -> None:
  118. context: URLSpamContext = bot_message.context
  119. if context is None:
  120. return
  121. sm: Message = context.spam_message
  122. if reaction.emoji == CONFIG['trash_emoji']:
  123. if not context.is_deleted:
  124. await sm.delete()
  125. context.is_deleted = True
  126. self.log(sm.guild, f'URL spam by {sm.author.name} deleted ' + \
  127. f'by {reacted_by.name}')
  128. elif reaction.emoji == CONFIG['kick_emoji']:
  129. if not context.is_deleted:
  130. await sm.delete()
  131. context.is_deleted = True
  132. if not context.is_kicked:
  133. await sm.author.kick(
  134. reason=f'Rocketbot: Kicked for URL spam by {reacted_by.name}')
  135. context.is_kicked = True
  136. self.log(sm.guild, f'URL spammer {sm.author.name} kicked ' + \
  137. f'by {reacted_by.name}')
  138. elif reaction.emoji == CONFIG['ban_emoji']:
  139. if not context.is_banned:
  140. await sm.author.ban(
  141. reason=f'Rocketbot: Banned for URL spam by {reacted_by.name}',
  142. delete_message_days=1)
  143. context.is_deleted = True
  144. context.is_kicked = True
  145. context.is_banned = True
  146. self.log(sm.guild, f'URL spammer {sm.author.name} banned ' + \
  147. f'by {reacted_by.name}')
  148. else:
  149. return
  150. await bot_message.set_reactions(BotMessageReaction.standard_set(
  151. did_delete=context.is_deleted,
  152. did_kick=context.is_kicked,
  153. did_ban=context.is_banned))
  154. @classmethod
  155. def __contains_url(cls, text: str) -> bool:
  156. p = re.compile(r'http(?:s)?://[^\s]+')
  157. return p.search(text) is not None
  158. @classmethod
  159. def __format_timedelta(cls, timespan: timedelta) -> str:
  160. parts = []
  161. d = timespan.days
  162. h = timespan.seconds // 3600
  163. m = (timespan.seconds // 60) % 60
  164. s = timespan.seconds % 60
  165. if d > 0:
  166. parts.append(f'{d} days')
  167. if d > 0 or h > 0:
  168. parts.append(f'{h} hours')
  169. if d > 0 or h > 0 or m > 0:
  170. parts.append(f'{m} minutes')
  171. parts.append(f'{s} seconds')
  172. # Limit the precision to the two most significant elements
  173. while len(parts) > 2:
  174. parts.pop(-1)
  175. return ' '.join(parts)