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.

crosspostcog.py 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. from discord import Guild, Message, PartialEmoji
  2. from discord.ext import commands
  3. from datetime import datetime, timedelta
  4. import math
  5. from rscollections import AgeBoundList, SizeBoundDict
  6. from cogs.basecog import BaseCog
  7. from storage import Storage
  8. class SpamContext:
  9. def __init__(self, member, message_hash):
  10. self.member = member
  11. self.message_hash = message_hash
  12. self.age = datetime.now()
  13. self.warning_message = None
  14. self.is_kicked = False
  15. self.is_banned = False
  16. self.messages = set()
  17. self.deleted_messages = set()
  18. class CrossPostCog(BaseCog):
  19. STATE_KEY_RECENT_MESSAGES = "crosspost_recent_messages"
  20. STATE_KEY_SPAM_CONTEXT = "crosspost_spam_context"
  21. CONFIG_KEY_WARN_COUNT = "crosspost_warn_count"
  22. CONFIG_KEY_BAN_COUNT = "crosspost_ban_count"
  23. CONFIG_KEY_MIN_MESSAGE_LENGTH = "crosspost_min_message_length"
  24. CONFIG_KEY_MESSAGE_AGE = "crosspost_message_age"
  25. MIN_WARN_COUNT = 2
  26. MIN_BAN_COUNT = 2
  27. MIN_MESSAGE_LENGTH = 0
  28. MIN_TIME_SPAN = 1
  29. def __init__(self, bot):
  30. super().__init__(bot)
  31. self.max_spam_contexts = 12
  32. # Config
  33. def __warn_count(self, guild: Guild) -> int:
  34. return Storage.get_config_value(guild, self.CONFIG_KEY_WARN_COUNT) or 3
  35. def __ban_count(self, guild: Guild) -> int:
  36. return Storage.get_config_value(guild, self.CONFIG_KEY_BAN_COUNT) or 9999
  37. def __min_message_length(self, guild: Guild) -> int:
  38. return Storage.get_config_value(guild, self.CONFIG_KEY_MIN_MESSAGE_LENGTH) or 0
  39. def __message_age_seconds(self, guild: Guild) -> int:
  40. return Storage.get_config_value(guild, self.CONFIG_KEY_MESSAGE_AGE) or 120
  41. async def __record_message(self, message: Message) -> None:
  42. if message.author.permissions_in(message.channel).ban_members:
  43. # User exempt from spam detection
  44. return
  45. if len(message.content) < self.__min_message_length(message.guild):
  46. # Message too short to count towards spam total
  47. return
  48. max_age = timedelta(seconds=self.__message_age_seconds(message.guild))
  49. recent_messages = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES) \
  50. or AgeBoundList(max_age, lambda index, message : message.created_at)
  51. recent_messages.max_age = max_age
  52. recent_messages.append(message)
  53. Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
  54. # Get all recent messages by user
  55. member_messages = [m for m in recent_messages if m.author.id == message.author.id]
  56. if len(member_messages) < self.__warn_count(message.guild):
  57. return
  58. # Look for repeats
  59. hash_to_count = {}
  60. max_count = 0
  61. for m in member_messages:
  62. key = hash(m.content)
  63. count = (hash_to_count.get(key) or 0) + 1
  64. hash_to_count[key] = count
  65. max_count = max(max_count, count)
  66. if max_count < self.__warn_count(message.guild):
  67. return
  68. # Handle the spam
  69. spam_lookup = Storage.get_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT) \
  70. or SizeBoundDict(self.max_spam_contexts, lambda key, context : context.age)
  71. Storage.set_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
  72. for message_hash, count in hash_to_count.items():
  73. if count < self.__warn_count(message.guild):
  74. continue
  75. key = f'{message.author.id}|{message_hash}'
  76. context = spam_lookup.get(key)
  77. is_new = context is None
  78. if context is None:
  79. context = SpamContext(message.author, message_hash)
  80. spam_lookup[key] = context
  81. context.age = message.created_at
  82. for m in member_messages:
  83. if hash(m.content) == message_hash:
  84. context.messages.add(m)
  85. await self.__update_from_context(context)
  86. async def __update_from_context(self, context: SpamContext):
  87. content = next(iter(context.messages)).content
  88. if len(context.messages) >= self.__ban_count(context.member.guild):
  89. if not context.is_banned:
  90. await context.member.ban(reason='Posting same message repeatedly', delete_message_days=1)
  91. msg = f'User {context.member.mention} auto banned for ' + \
  92. 'crosspost spamming.\n' + \
  93. f'> {content}'
  94. if context.warning_message:
  95. await self.update_warn(context.warning_message, msg)
  96. await context.warning_message.clear_reaction('🗑')
  97. await context.warning_message.clear_reaction('👢')
  98. await context.warning_message.clear_reaction('🚫')
  99. else:
  100. context.warning_message = await self.warn(context.member.guild, msg)
  101. elif len(context.messages) >= self.__warn_count(context.member.guild):
  102. content = next(iter(context.messages)).content
  103. msg = f'User {context.member.mention} has posted the exact same ' + \
  104. f'message {len(context.messages)} times.\n' + \
  105. f'> {content}' + \
  106. '\n'
  107. can_delete = len(context.messages) > len(context.deleted_messages)
  108. if can_delete:
  109. msg += '\n🗑 to delete messages'
  110. else:
  111. msg += '\nAll messages deleted'
  112. if not context.is_kicked:
  113. msg += '\n👢 to kick user'
  114. elif not context.is_banned:
  115. msg += '\nUser kicked'
  116. if context.is_banned:
  117. msg += '\nUser banned'
  118. else:
  119. msg += '\n🚫 to ban user'
  120. if context.warning_message:
  121. await self.update_warn(context.warning_message, msg)
  122. else:
  123. context.warning_message = await self.warn(context.member.guild, msg)
  124. self.listen_for_reactions_to(context.warning_message, context)
  125. if can_delete:
  126. await context.warning_message.add_reaction('🗑')
  127. else:
  128. await context.warning_message.clear_reaction('🗑')
  129. if not context.is_kicked:
  130. await context.warning_message.add_reaction('👢')
  131. else:
  132. await context.warning_message.clear_reaction('👢')
  133. if not context.is_banned:
  134. await context.warning_message.add_reaction('🚫')
  135. else:
  136. await context.warning_message.clear_reaction('🚫')
  137. async def __delete_messages(self, context: SpamContext) -> None:
  138. for message in context.messages - context.deleted_messages:
  139. await message.delete()
  140. context.deleted_messages.add(message)
  141. await self.__update_from_context(context)
  142. async def __kick(self, context: SpamContext) -> None:
  143. await context.member.kick(reason='Posting same message repeatedly')
  144. context.is_kicked = True
  145. await self.__update_from_context(context)
  146. async def __ban(self, context: SpamContext) -> None:
  147. await context.member.ban(reason='Posting same message repeatedly', delete_message_days=1)
  148. context.deleted_messages |= context.messages
  149. context.is_kicked = True
  150. context.is_banned = True
  151. await self.__update_from_context(context)
  152. async def on_mod_react(self, message: Message, emoji: PartialEmoji, context: SpamContext) -> None:
  153. if context is None:
  154. return
  155. if emoji.name == '🗑':
  156. await self.__delete_messages(context)
  157. elif emoji.name == '👢':
  158. await self.__kick(context)
  159. elif emoji.name == '🚫':
  160. await self.__ban(context)
  161. @commands.Cog.listener()
  162. async def on_message(self, message: Message):
  163. await self.__record_message(message)
  164. # -- Commands -----------------------------------------------------------
  165. @commands.group(
  166. brief='Manages crosspost/repeated post detection and handling',
  167. )
  168. @commands.has_permissions(ban_members=True)
  169. @commands.guild_only()
  170. async def crosspost(self, context: commands.Context):
  171. 'Command group'
  172. if context.invoked_subcommand is None:
  173. await context.send_help()
  174. @crosspost.command(
  175. name='setwarncount',
  176. brief='Sets the number of duplicate messages to trigger a mod warning',
  177. description='If the same user posts the exact same message ' +
  178. 'content this many times a warning will be posted to the mods,' +
  179. 'even if the messages are posted in different channels.',
  180. usage='<warn_count:int>',
  181. )
  182. async def joinraid_setwarncount(self, context: commands.Context,
  183. warn_count: int):
  184. if not await self.validate_param(context, 'warn_count', warn_count,
  185. allowed_types=(int, ), min_value=self.MIN_WARN_COUNT):
  186. return
  187. Storage.set_config_value(context.guild, self.CONFIG_KEY_WARN_COUNT, warn_count)
  188. await context.message.reply(f'✅ Mods will be warned if a user posts ' +
  189. f'the exact same message {warn_count} or more times within ' +
  190. f'{self.__message_age_seconds(context.guild)} seconds.',
  191. mention_author=False)
  192. @crosspost.command(
  193. name='getwarncount',
  194. brief='Returns the number of duplicate messages to trigger a mod warning',
  195. )
  196. async def joinraid_getwarncount(self, context: commands.Context):
  197. await context.message.reply(f'ℹ️ Mods will be warned if a user posts ' +
  198. f'the exact same message {self.__warn_count(context.guild)} or more ' +
  199. f'times within {self.__message_age_seconds(context.guild)} seconds.',
  200. mention_author=False)
  201. @crosspost.command(
  202. name='setbancount',
  203. brief='Sets the number of duplicate messages to trigger an automatic ban',
  204. description='If the same user posts the exact same message ' +
  205. 'content this many times they will be automatically banned and the ' +
  206. 'mods will be alerted.',
  207. usage='<ban_count:int>',
  208. )
  209. async def joinraid_setbancount(self, context: commands.Context,
  210. ban_count: int):
  211. if not await self.validate_param(context, 'ban_count', ban_count,
  212. allowed_types=(int, ), min_value=self.MIN_BAN_COUNT):
  213. return
  214. Storage.set_config_value(context.guild, self.CONFIG_KEY_BAN_COUNT, ban_count)
  215. await context.message.reply(f'✅ Users will be banned if they post ' +
  216. f'the exact same message {ban_count} or more times within ' +
  217. f'{self.__message_age_seconds(context.guild)} seconds.',
  218. mention_author=False)
  219. @crosspost.command(
  220. name='getbancount',
  221. brief='Returns the number of duplicate messages to trigger an automatic ban',
  222. )
  223. async def joinraid_getbancount(self, context: commands.Context):
  224. await context.message.reply(f'ℹ️ Users will be banned if they post ' +
  225. f'the exact same message {self.__ban_count(context.guild)} or more ' +
  226. f'times within {self.__message_age_seconds(context.guild)} seconds.',
  227. mention_author=False)
  228. @crosspost.command(
  229. name='setminlength',
  230. brief='Sets the minimum number of characters for a message to count toward spamming',
  231. description='Messages shorter than this number of characters will not ' +
  232. 'count toward spam counts. This helps prevent flagging common, ' +
  233. 'frequent, short responses like "lol". A value of 0 counts all messages.',
  234. usage='<min_length:int>',
  235. )
  236. async def joinraid_setminlength(self, context: commands.Context,
  237. min_length: int):
  238. if not await self.validate_param(context, 'min_length', min_length,
  239. allowed_types=(int, ), min_value=self.MIN_MESSAGE_LENGTH):
  240. return
  241. Storage.set_config_value(context.guild, self.CONFIG_KEY_MIN_MESSAGE_LENGTH, min_length)
  242. if min_length == 0:
  243. await context.message.reply(f'✅ All messages will count against ' +
  244. 'spam counts, regardless of length.', mention_author=False)
  245. else:
  246. await context.message.reply(f'✅ Only messages {min_length} ' +
  247. f'characters or longer will count against spam counts.',
  248. mention_author=False)
  249. @crosspost.command(
  250. name='getminlength',
  251. brief='Returns the number of duplicate messages to trigger an automatic ban',
  252. )
  253. async def joinraid_getminlength(self, context: commands.Context):
  254. min_length = self.__min_message_length(context.guild)
  255. if min_length == 0:
  256. await context.message.reply(f'ℹ️ All messages will count against ' +
  257. 'spam counts, regardless of length.', mention_author=False)
  258. else:
  259. await context.message.reply(f'ℹ️ Only messages {min_length} ' +
  260. f'characters or longer will count against spam counts.',
  261. mention_author=False)
  262. @crosspost.command(
  263. name='settimewindow',
  264. brief='Sets the length of time recent messages are checked for duplicates',
  265. description='Repeated messages are only checked against recent ' +
  266. 'messages. This sets the length of that window, in seconds. Lower ' +
  267. 'values save memory and prevent false positives.',
  268. usage='<seconds:int>',
  269. )
  270. async def joinraid_settimewindow(self, context: commands.Context,
  271. seconds: int):
  272. if not await self.validate_param(context, 'seconds', seconds,
  273. allowed_types=(int, ), min_value=self.MIN_TIME_SPAN):
  274. return
  275. Storage.set_config_value(context.guild, self.CONFIG_KEY_MESSAGE_AGE, seconds)
  276. await context.message.reply(f'✅ Only messages in the past {seconds} ' +
  277. f'seconds will be checked for duplicates.',
  278. mention_author=False)
  279. @crosspost.command(
  280. name='gettimewindow',
  281. brief='Returns the length of time recent messages are checked for duplicates',
  282. )
  283. async def joinraid_gettimewindow(self, context: commands.Context):
  284. seconds = self.__message_age_seconds(context.guild)
  285. await context.message.reply(f'ℹ️ Only messages in the past {seconds} ' +
  286. 'seconds will be checked for duplicates.',
  287. mention_author=False)