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.

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