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.

urlspamcog.py 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. """
  2. Cog for detecting URLs posted by new users.
  3. """
  4. import re
  5. from datetime import timedelta
  6. from typing import Literal
  7. from discord import Guild, Member, Message
  8. from discord import utils as discordutils
  9. from discord.ext.commands import Cog
  10. from discord.utils import escape_markdown
  11. from config import CONFIG
  12. from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
  13. from rocketbot.utils import describe_timedelta
  14. class URLSpamContext:
  15. """
  16. Data about a suspected spam message containing a URL.
  17. """
  18. def __init__(self, spam_message: Message):
  19. self.spam_message = spam_message
  20. self.is_deleted = False
  21. self.is_kicked = False
  22. self.is_banned = False
  23. class URLSpamCog(BaseCog, name='URL Spam'):
  24. """
  25. Detects users posting URLs who just joined recently: a common spam pattern.
  26. Can be configured to take immediate action or just warn the mods.
  27. """
  28. SETTING_ENABLED = CogSetting(
  29. 'enabled',
  30. bool,
  31. default_value=False,
  32. brief='URL spam detection',
  33. description='Whether URLs posted soon after joining are flagged.',
  34. )
  35. SETTING_ACTION = CogSetting(
  36. 'action',
  37. Literal['nothing', 'modwarn', 'delete', 'kick', 'ban'],
  38. default_value='nothing',
  39. brief='action to take on spam',
  40. description='The action to take on detected URL spam.',
  41. enum_values={'nothing', 'modwarn', 'delete', 'kick', 'ban'},
  42. )
  43. SETTING_JOIN_AGE = CogSetting(
  44. 'joinage',
  45. timedelta,
  46. default_value=timedelta(minutes=15),
  47. brief='seconds since member joined',
  48. description='The minimum seconds since the user joined the '
  49. 'server before they can post URLs. URLs posted by users '
  50. 'who joined too recently will be flagged. Keep in mind '
  51. 'many servers have a minimum 10 minute cooldown before '
  52. 'new members can say anything. Setting to 0 effectively '
  53. 'disables URL spam detection.',
  54. min_value=timedelta(seconds=0),
  55. )
  56. SETTING_DECEPTIVE_ACTION = CogSetting(
  57. 'deceptiveaction',
  58. Literal['nothing', 'modwarn', 'modwarndelete', 'chatwarn', 'chatwarndelete', 'delete', 'kick', 'ban'],
  59. default_value='nothing',
  60. brief='action to take on deceptive link markdown',
  61. description='The action to take on chat messages with links '
  62. 'where the text looks like a different URL than the actual link.',
  63. enum_values={'nothing', 'modwarn', 'modwarndelete',
  64. 'chatwarn', 'chatwarndelete', 'delete', 'kick', 'ban'},
  65. )
  66. SETTING_IGNORED_DOMAINS = CogSetting(
  67. 'ignoreddomains',
  68. str,
  69. default_value='tenor.com;giphy.com;imgur.com',
  70. brief='List of allowed domains to exclude from detecting',
  71. description='A semicolon-delimited list of domains that can be posted '
  72. 'without triggering a warning. E.g. GIF hosting sites.'
  73. )
  74. def __init__(self, bot):
  75. super().__init__(
  76. bot,
  77. config_prefix='urlspam',
  78. short_description='Manages URL spam detection.',
  79. )
  80. self.add_setting(URLSpamCog.SETTING_ENABLED)
  81. self.add_setting(URLSpamCog.SETTING_ACTION)
  82. self.add_setting(URLSpamCog.SETTING_JOIN_AGE)
  83. self.add_setting(URLSpamCog.SETTING_DECEPTIVE_ACTION)
  84. self.add_setting(URLSpamCog.SETTING_IGNORED_DOMAINS)
  85. @Cog.listener()
  86. async def on_message(self, message: Message):
  87. """Event listener"""
  88. if message.author is None or \
  89. message.guild is None or \
  90. message.channel is None or \
  91. message.content is None:
  92. self.__trace("Missing message data")
  93. return
  94. if message.author.bot:
  95. self.__trace("Message from bot")
  96. return
  97. if message.channel.permissions_for(message.author).ban_members:
  98. self.__trace("User exempt")
  99. return
  100. if not self.get_guild_setting(message.guild, self.SETTING_ENABLED):
  101. self.__trace("Cog disabled")
  102. return
  103. urls = self.__find_urls(message.content)
  104. if len(urls) == 0:
  105. self.__trace("Contains no URLs")
  106. return
  107. if self.__all_ignored_domains(message.guild, urls):
  108. self.__trace("All URLs are ignored domains")
  109. return
  110. await self.check_message_recency(message)
  111. await self.check_deceptive_links(message)
  112. def __all_ignored_domains(self, guild: Guild, urls: list[str]) -> bool:
  113. domain_list: str = self.get_guild_setting(guild, self.SETTING_IGNORED_DOMAINS)
  114. self.__trace(f"Domain list is {domain_list}")
  115. domains = domain_list.split(';')
  116. self.__trace(f"Split into {len(domains)} domains")
  117. for url in urls:
  118. if not self.__is_ignored_url(url, domains):
  119. return False
  120. return True
  121. def __is_ignored_url(self, url: str, ignored_domains: list[str]) -> bool:
  122. p = re.compile(r'https?://([\w\.]+)')
  123. domain = re.search(p, url).group(1)
  124. for ignored_domain in ignored_domains:
  125. if domain.endswith(ignored_domain.strip()):
  126. self.__trace(f"URL {url} matches ignored domain {ignored_domain}")
  127. return True
  128. self.__trace("URL {url} not ignored")
  129. return False
  130. async def check_message_recency(self, message: Message):
  131. """Checks if the message was sent too recently by a new user"""
  132. action = self.get_guild_setting(message.guild, self.SETTING_ACTION)
  133. join_seconds = self.get_guild_setting(message.guild, self.SETTING_JOIN_AGE)
  134. min_join_age = timedelta(seconds=join_seconds)
  135. if action == 'nothing':
  136. self.__trace("Configured action is nothing")
  137. return
  138. join_age = message.created_at - message.author.joined_at
  139. join_age_str = describe_timedelta(join_age)
  140. if join_age > min_join_age:
  141. self.__trace("User has been member long enough")
  142. return
  143. context = URLSpamContext(message)
  144. needs_attention = False
  145. if action == 'modwarn':
  146. needs_attention = not self.was_warned_recently(message.author)
  147. self.log(message.guild, f'New user {message.author.name} ' + \
  148. f'({message.author.id}) posted URL {join_age_str} after ' + \
  149. 'joining.' + (' Mods alerted.' if needs_attention else ''))
  150. elif action == 'delete':
  151. await message.delete()
  152. context.is_deleted = True
  153. self.log(message.guild, f'New user {message.author.name} ' + \
  154. f'({message.author.id}) posted URL {join_age_str} after ' + \
  155. 'joining. Message deleted.')
  156. elif action == 'kick':
  157. await message.delete()
  158. context.is_deleted = True
  159. await message.author.kick(
  160. reason=f'Rocketbot: Posted a link {join_age_str} after joining')
  161. context.is_kicked = True
  162. self.log(message.guild, f'New user {message.author.name} ' + \
  163. f'({message.author.id}) posted URL {join_age_str} after ' + \
  164. 'joining. User kicked.')
  165. elif action == 'ban':
  166. await message.author.ban(
  167. reason=f'Rocketbot: User posted a link {join_age_str} after joining',
  168. delete_message_days=1)
  169. context.is_deleted = True
  170. context.is_kicked = True
  171. context.is_banned = True
  172. self.log(message.guild, f'New user {message.author.name} ' + \
  173. f'({message.author.id}) posted URL {join_age_str} after ' + \
  174. 'joining. User banned.')
  175. bm = BotMessage(
  176. message.guild,
  177. f'User {message.author.mention} posted a URL ' + \
  178. f'{join_age_str} after joining: {message.jump_url}',
  179. type = BotMessage.TYPE_MOD_WARNING if needs_attention else BotMessage.TYPE_INFO,
  180. context = context)
  181. bm.quote = discordutils.remove_markdown(message.clean_content)
  182. await bm.set_reactions(BotMessageReaction.standard_set(
  183. did_delete=context.is_deleted,
  184. did_kick=context.is_kicked,
  185. did_ban=context.is_banned))
  186. await self.post_message(bm)
  187. if needs_attention:
  188. self.record_warning(message.author)
  189. async def check_deceptive_links(self, message: Message):
  190. """
  191. Checks if the message contains deceptive URL Markdown, e.g.
  192. `[nicewebsite.com](https://evilwebsite.com)'`
  193. """
  194. action = self.get_guild_setting(message.guild, self.SETTING_DECEPTIVE_ACTION)
  195. if action is None or action == 'nothing':
  196. return
  197. if not self.contains_deceptive_links(message.content):
  198. return
  199. mod_text = f'User {message.author.name} ({message.author.id}) posted a deceptive link. {message.jump_url}'
  200. quoted = '> ' + escape_markdown(message.content).replace('\n', '\n> ')
  201. mod_text += f'\n\n{quoted}'
  202. self.log(message.guild, f'{message.author.name} posted deceptive link - action: {action}')
  203. if 'modwarn' in action:
  204. if 'delete' in action:
  205. mod_text += '\n\nMessage deleted'
  206. else:
  207. mod_text += f'\n\n{message.jump_url}'
  208. bm = BotMessage(message.guild, mod_text, BotMessage.TYPE_MOD_WARNING, suppress_embeds=True)
  209. await self.post_message(bm)
  210. if 'delete' in action:
  211. await message.delete()
  212. elif 'chatwarn' in action:
  213. if 'delete' in action:
  214. response = ':warning: Links with deceptive labels are prohibited :warning:'
  215. else:
  216. response = ':warning: Message contains a deceptively labeled link! Click carefully. :warning:'
  217. await message.reply(response, mention_author=False)
  218. if 'delete' in action:
  219. await message.delete()
  220. elif action == 'delete':
  221. mod_text += '\n\nDeleting message'
  222. bm = BotMessage(message.guild, mod_text, BotMessage.TYPE_INFO, suppress_embeds=True)
  223. await self.post_message(bm)
  224. await message.delete()
  225. elif action == 'kick':
  226. mod_text += '\n\nUser kicked'
  227. bm = BotMessage(message.guild, mod_text, BotMessage.TYPE_MOD_WARNING, suppress_embeds=True)
  228. await self.post_message(bm)
  229. await message.delete()
  230. await message.author.kick(
  231. reason='Rocketbot: User posted a deceptive link')
  232. elif action == 'ban':
  233. mod_text += '\n\nUser banned'
  234. bm = BotMessage(message.guild, mod_text, BotMessage.TYPE_MOD_WARNING, suppress_embeds=True)
  235. await self.post_message(bm)
  236. await message.author.ban(
  237. reason='Rocketbot: User posted a deceptive link',
  238. delete_message_days=1)
  239. def contains_deceptive_links(self, content: str) -> bool:
  240. # Strip Markdown that can safely contain URL sequences
  241. content = re.sub(r'`[^`]+`', '', content) # `inline code`
  242. content = re.sub(r'```.+?```', '', content, re.DOTALL) # ``` code block ```
  243. matches = re.findall(r'\[([^]]+)]\(([^)]+)\)', content)
  244. for match in matches:
  245. original_label: str = match[0].strip()
  246. original_link: str = match[1].strip()
  247. label: str = original_label
  248. link: str = original_link
  249. if link.startswith('<') and link.endswith('>'):
  250. link = link[1:-1]
  251. if self.is_url(label):
  252. if label != link:
  253. return True
  254. elif self.is_casual_url(label):
  255. # Trim www. for easier comparisons.
  256. if link.startswith('https://www.'):
  257. link = 'https://' + link[12:]
  258. if link.startswith('http://www.'):
  259. link = 'http://' + link[11:]
  260. if link.endswith('/'):
  261. link = link[:-1]
  262. if label.startswith('www.'):
  263. label = label[4:]
  264. if label.endswith('/'):
  265. label = label[:-1]
  266. if link.startswith('https://') and 'https://' + label != link:
  267. return True
  268. elif link.startswith('http://') and 'http://' + label != link:
  269. return True
  270. return False
  271. def is_url(self, s: str) -> bool:
  272. """Tests if a string is strictly a URL"""
  273. ipv6_host_pattern = r'\[[0-9a-fA-F:]+\]'
  274. ipv4_host_pattern = r'[0-9\.]+'
  275. hostname_pattern = r'[a-zA-Z0-9-]+\.[a-zA-Z0-9-\.]+'
  276. host_pattern = r'(?:' + ipv6_host_pattern + '|' + ipv4_host_pattern + '|' + hostname_pattern + ')'
  277. port_pattern = '(?::[0-9]+)?'
  278. path_pattern = r'(?:/[^ \]\)]*)?'
  279. pattern = r'^http[s]?://' + host_pattern + port_pattern + path_pattern + '$'
  280. return re.match(pattern, s, re.IGNORECASE) is not None
  281. def is_casual_url(self, s: str) -> bool:
  282. """Tests if a string is a "casual URL" with no scheme included"""
  283. ipv6_host_pattern = r'\[[0-9a-fA-F:]+\]'
  284. ipv4_host_pattern = r'[0-9\.]+'
  285. hostname_pattern = r'[a-zA-Z0-9-]+\.[a-zA-Z0-9-\.]+'
  286. host_pattern = r'(?:' + ipv6_host_pattern + '|' + ipv4_host_pattern + '|' + hostname_pattern + ')'
  287. port_pattern = '(?::[0-9]+)?'
  288. path_pattern = r'(?:/[^ \]\)]*)?'
  289. pattern = r'^' + host_pattern + port_pattern + path_pattern + '$'
  290. return re.match(pattern, s, re.IGNORECASE) is not None
  291. async def on_mod_react(self,
  292. bot_message: BotMessage,
  293. reaction: BotMessageReaction,
  294. reacted_by: Member) -> None:
  295. context: URLSpamContext = bot_message.context
  296. if context is None:
  297. return
  298. sm: Message = context.spam_message
  299. if reaction.emoji == CONFIG['trash_emoji']:
  300. if not context.is_deleted:
  301. await sm.delete()
  302. context.is_deleted = True
  303. self.log(sm.guild, f'URL spam by {sm.author.name} deleted ' + \
  304. f'by {reacted_by.name}')
  305. elif reaction.emoji == CONFIG['kick_emoji']:
  306. if not context.is_deleted:
  307. await sm.delete()
  308. context.is_deleted = True
  309. if not context.is_kicked:
  310. await sm.author.kick(
  311. reason=f'Rocketbot: Kicked for URL spam by {reacted_by.name}')
  312. context.is_kicked = True
  313. self.log(sm.guild, f'URL spammer {sm.author.name} kicked ' + \
  314. f'by {reacted_by.name}')
  315. elif reaction.emoji == CONFIG['ban_emoji']:
  316. if not context.is_banned:
  317. await sm.author.ban(
  318. reason=f'Rocketbot: Banned for URL spam by {reacted_by.name}',
  319. delete_message_days=1)
  320. context.is_deleted = True
  321. context.is_kicked = True
  322. context.is_banned = True
  323. self.log(sm.guild, f'URL spammer {sm.author.name} banned ' + \
  324. f'by {reacted_by.name}')
  325. else:
  326. return
  327. await bot_message.set_reactions(BotMessageReaction.standard_set(
  328. did_delete=context.is_deleted,
  329. did_kick=context.is_kicked,
  330. did_ban=context.is_banned))
  331. def __trace(self, message: str):
  332. # print(f'URLSpamCog: {message}')
  333. pass
  334. @classmethod
  335. def __contains_url(cls, text: str) -> bool:
  336. p = re.compile(r'https?://\S+')
  337. return p.search(text) is not None
  338. @classmethod
  339. def __find_urls(cls, text: str) -> list[str]:
  340. p = re.compile(r'https?://\S+')
  341. return re.findall(p, text)