Experimental Discord bot written in Python
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

urlspamcog.py 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. """
  2. Cog for detecting URLs posted by new users.
  3. """
  4. import re
  5. from datetime import timedelta
  6. from discord import Member, Message, utils as discordutils
  7. from discord.ext import commands
  8. from discord.utils import escape_markdown
  9. from config import CONFIG
  10. from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
  11. from rocketbot.utils import describe_timedelta
  12. class URLSpamContext:
  13. """
  14. Data about a suspected spam message containing a URL.
  15. """
  16. def __init__(self, spam_message: Message):
  17. self.spam_message = spam_message
  18. self.is_deleted = False
  19. self.is_kicked = False
  20. self.is_banned = False
  21. class URLSpamCog(BaseCog, name='URL Spam'):
  22. """
  23. Detects users posting URLs who just joined recently: a common spam pattern.
  24. Can be configured to take immediate action or just warn the mods.
  25. """
  26. SETTING_ENABLED = CogSetting('enabled', bool,
  27. brief='URL spam detection',
  28. description='Whether URLs posted soon after joining are flagged.')
  29. SETTING_ACTION = CogSetting('action', str,
  30. brief='action to take on spam',
  31. description='The action to take on detected URL spam.',
  32. enum_values=set(['nothing', 'modwarn', 'delete', 'kick', 'ban']))
  33. SETTING_JOIN_AGE = CogSetting('joinage', float,
  34. brief='seconds since member joined',
  35. description='The minimum seconds since the user joined the ' + \
  36. 'server before they can post URLs. URLs posted by users ' + \
  37. 'who joined too recently will be flagged. Keep in mind ' + \
  38. 'many servers have a minimum 10 minute cooldown before ' + \
  39. 'new members can say anything. Setting to 0 effectively ' + \
  40. 'disables URL spam detection.',
  41. usage='<seconds:int>',
  42. min_value=0)
  43. SETTING_DECEPTIVE_ACTION = CogSetting('deceptiveaction', str,
  44. brief='action to take on deceptive link markdown',
  45. description='The action to take on chat messages with links ' + \
  46. 'where the text looks like a different URL than the actual link.',
  47. enum_values=set(['nothing', 'modwarn', 'modwarndelete', \
  48. 'chatwarn', 'chatwarndelete', 'delete', 'kick', 'ban']))
  49. def __init__(self, bot):
  50. super().__init__(bot)
  51. self.add_setting(URLSpamCog.SETTING_ENABLED)
  52. self.add_setting(URLSpamCog.SETTING_ACTION)
  53. self.add_setting(URLSpamCog.SETTING_JOIN_AGE)
  54. self.add_setting(URLSpamCog.SETTING_DECEPTIVE_ACTION)
  55. @commands.group(
  56. brief='Manages URL spam detection',
  57. )
  58. @commands.has_permissions(ban_members=True)
  59. @commands.guild_only()
  60. async def urlspam(self, context: commands.Context):
  61. 'URL spam command group'
  62. if context.invoked_subcommand is None:
  63. await context.send_help()
  64. @commands.Cog.listener()
  65. async def on_message(self, message: Message):
  66. 'Event listener'
  67. if message.author is None or \
  68. message.author.bot or \
  69. message.guild is None or \
  70. message.channel is None or \
  71. message.content is None:
  72. return
  73. if not self.get_guild_setting(message.guild, self.SETTING_ENABLED):
  74. return
  75. await self.check_message_recency(message);
  76. await self.check_deceptive_links(message);
  77. async def check_message_recency(self, message: Message):
  78. 'Checks if the message was sent too recently by a new user'
  79. action = self.get_guild_setting(message.guild, self.SETTING_ACTION)
  80. join_seconds = self.get_guild_setting(message.guild, self.SETTING_JOIN_AGE)
  81. min_join_age = timedelta(seconds=join_seconds)
  82. if action == 'nothing':
  83. return
  84. if not self.__contains_url(message.content):
  85. return
  86. join_age = message.created_at - message.author.joined_at
  87. join_age_str = describe_timedelta(join_age)
  88. if join_age < min_join_age:
  89. context = URLSpamContext(message)
  90. needs_attention = False
  91. if action == 'modwarn':
  92. needs_attention = not self.was_warned_recently(message.author)
  93. self.log(message.guild, f'New user {message.author.name} ' + \
  94. f'({message.author.id}) posted URL {join_age_str} after ' + \
  95. 'joining.' + (' Mods alerted.' if needs_attention else ''))
  96. elif action == 'delete':
  97. await message.delete()
  98. context.is_deleted = 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. Message deleted.')
  102. elif action == 'kick':
  103. await message.delete()
  104. context.is_deleted = True
  105. await message.author.kick(
  106. reason=f'Rocketbot: Posted a link {join_age_str} after joining')
  107. context.is_kicked = True
  108. self.log(message.guild, f'New user {message.author.name} ' + \
  109. f'({message.author.id}) posted URL {join_age_str} after ' + \
  110. 'joining. User kicked.')
  111. elif action == 'ban':
  112. await message.author.ban(
  113. reason=f'Rocketbot: User posted a link {join_age_str} after joining',
  114. delete_message_days=1)
  115. context.is_deleted = True
  116. context.is_kicked = True
  117. context.is_banned = True
  118. self.log(message.guild, f'New user {message.author.name} ' + \
  119. f'({message.author.id}) posted URL {join_age_str} after ' + \
  120. 'joining. User banned.')
  121. bm = BotMessage(
  122. message.guild,
  123. f'User {message.author.mention} posted a URL ' + \
  124. f'{join_age_str} after joining.',
  125. type = BotMessage.TYPE_MOD_WARNING if needs_attention else BotMessage.TYPE_INFO,
  126. context = context)
  127. bm.quote = discordutils.remove_markdown(message.clean_content)
  128. await bm.set_reactions(BotMessageReaction.standard_set(
  129. did_delete=context.is_deleted,
  130. did_kick=context.is_kicked,
  131. did_ban=context.is_banned))
  132. await self.post_message(bm)
  133. if needs_attention:
  134. self.record_warning(message.author)
  135. async def check_deceptive_links(self, message: Message):
  136. """
  137. Checks if the message contains deceptive URL markdown, e.g.
  138. `[nicewebsite.com](https://evilwebsite.com)'`
  139. """
  140. action = self.get_guild_setting(message.guild, self.SETTING_DECEPTIVE_ACTION)
  141. if action == None or action == 'nothing':
  142. return
  143. if not self.contains_deceptive_links(message.content):
  144. return
  145. mod_text = f'User {message.author.name} ({message.author.id}) posted a deceptive link.'
  146. quoted = '> ' + escape_markdown(message.content).replace('\n', '\n> ')
  147. mod_text += f'\n\n{quoted}'
  148. self.log(message.guild, f'{message.author.name} posted deceptive link - action: {action}')
  149. if 'modwarn' in action:
  150. if 'delete' in action:
  151. mod_text += '\n\nMessage deleted'
  152. else:
  153. mod_text += f'\n\n{message.jump_url}'
  154. bm = BotMessage(message.guild, mod_text, BotMessage.TYPE_MOD_WARNING, suppress_embeds=True)
  155. await self.post_message(bm)
  156. if 'delete' in action:
  157. await message.delete()
  158. elif 'chatwarn' in action:
  159. if 'delete' in action:
  160. response = f':warning: Links with deceptive labels are prohibited :warning:'
  161. else:
  162. response = f':warning: Message contains a deceptively labeled link! Click carefully. :warning:'
  163. await message.reply(response, mention_author=False)
  164. if 'delete' in action:
  165. await message.delete()
  166. elif action == 'delete':
  167. mod_text += f'\n\nDeleting message'
  168. bm = BotMessage(message.guild, mod_text, BotMessage.TYPE_INFO, suppress_embeds=True)
  169. await self.post_message(bm)
  170. await message.delete()
  171. elif action == 'kick':
  172. mod_text += f'\n\nUser kicked'
  173. bm = BotMessage(message.guild, mod_text, BotMessage.TYPE_MOD_WARNING, suppress_embeds=True)
  174. await self.post_message(bm)
  175. await message.delete()
  176. await message.author.kick(
  177. reason=f'Rocketbot: User posted a deceptive link')
  178. elif action == 'ban':
  179. mod_text += f'\n\nUser banned'
  180. bm = BotMessage(message.guild, mod_text, BotMessage.TYPE_MOD_WARNING, suppress_embeds=True)
  181. await self.post_message(bm)
  182. await message.author.ban(
  183. reason=f'Rocketbot: User posted a deceptive link',
  184. delete_message_days=1)
  185. def contains_deceptive_links(self, content: str) -> bool:
  186. # Strip markdown that can safely contain URL sequences
  187. content = re.sub(r'`[^`]+`', '', content) # `inline code`
  188. content = re.sub(r'```.+?```', '', content, re.DOTALL) # ``` code block ```
  189. matches = re.findall(r'\[([^\]]+)\]\(([^\)]+)\)', content)
  190. for match in matches:
  191. original_label: str = match[0].strip()
  192. original_link: str = match[1].strip()
  193. label: str = original_label
  194. link: str = original_link
  195. if link.startswith('<') and link.endswith('>'):
  196. link = link[1:-1]
  197. if self.is_url(label):
  198. if label != link:
  199. return True
  200. elif self.is_casual_url(label):
  201. # Trim www. for easier comparisons.
  202. if link.startswith('https://www.'):
  203. link = 'https://' + link[12:]
  204. if link.startswith('http://www.'):
  205. link = 'http://' + link[11:]
  206. if link.endswith('/'):
  207. link = link[:-1]
  208. if label.startswith('www.'):
  209. label = label[4:]
  210. if label.endswith('/'):
  211. label = label[:-1]
  212. if link.startswith('https://') and 'https://' + label != link:
  213. return True
  214. elif link.startswith('http://') and 'http://' + label != link:
  215. return True
  216. return False
  217. def is_url(self, s: str):
  218. 'Tests if a string is strictly a URL'
  219. pattern = r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'
  220. return re.match(pattern, s, re.IGNORECASE) != None
  221. def is_casual_url(self, s: str):
  222. 'Tests if a string is a "casual URL" with no scheme included'
  223. pattern = r'(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'
  224. return re.match(pattern, s, re.IGNORECASE) != None
  225. async def on_mod_react(self,
  226. bot_message: BotMessage,
  227. reaction: BotMessageReaction,
  228. reacted_by: Member) -> None:
  229. context: URLSpamContext = bot_message.context
  230. if context is None:
  231. return
  232. sm: Message = context.spam_message
  233. if reaction.emoji == CONFIG['trash_emoji']:
  234. if not context.is_deleted:
  235. await sm.delete()
  236. context.is_deleted = True
  237. self.log(sm.guild, f'URL spam by {sm.author.name} deleted ' + \
  238. f'by {reacted_by.name}')
  239. elif reaction.emoji == CONFIG['kick_emoji']:
  240. if not context.is_deleted:
  241. await sm.delete()
  242. context.is_deleted = True
  243. if not context.is_kicked:
  244. await sm.author.kick(
  245. reason=f'Rocketbot: Kicked for URL spam by {reacted_by.name}')
  246. context.is_kicked = True
  247. self.log(sm.guild, f'URL spammer {sm.author.name} kicked ' + \
  248. f'by {reacted_by.name}')
  249. elif reaction.emoji == CONFIG['ban_emoji']:
  250. if not context.is_banned:
  251. await sm.author.ban(
  252. reason=f'Rocketbot: Banned for URL spam by {reacted_by.name}',
  253. delete_message_days=1)
  254. context.is_deleted = True
  255. context.is_kicked = True
  256. context.is_banned = True
  257. self.log(sm.guild, f'URL spammer {sm.author.name} banned ' + \
  258. f'by {reacted_by.name}')
  259. else:
  260. return
  261. await bot_message.set_reactions(BotMessageReaction.standard_set(
  262. did_delete=context.is_deleted,
  263. did_kick=context.is_kicked,
  264. did_ban=context.is_banned))
  265. @classmethod
  266. def __contains_url(cls, text: str) -> bool:
  267. p = re.compile(r'http(?:s)?://[^\s]+')
  268. return p.search(text) is not None