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.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. from datetime import datetime, timedelta
  2. from discord import Guild, Member, Message
  3. from discord.ext import commands
  4. from cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
  5. from config import CONFIG
  6. from rscollections import AgeBoundList, SizeBoundDict
  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.bot_message = None # BotMessage
  14. self.is_kicked = False
  15. self.is_banned = False
  16. self.is_autobanned = False
  17. self.spam_messages = set() # of Message
  18. self.deleted_messages = set() # of Message
  19. self.unique_channels = set() # of TextChannel
  20. class CrossPostCog(BaseCog):
  21. """
  22. Detects a user posting the same text in multiple channels in a short period
  23. of time: a common pattern for spammers. Repeated posts in the same channel
  24. aren't detected, as this can often be for a reason or due to trying a
  25. failed post when connectivity is poor. Minimum message length can be
  26. enforced for detection. Minimum is always at least 1 to ignore posts with
  27. just embeds or images and no text.
  28. """
  29. SETTING_ENABLED = CogSetting('enabled', bool,
  30. brief='crosspost detection',
  31. description='Whether crosspost detection is enabled.')
  32. SETTING_WARN_COUNT = CogSetting('warncount', int,
  33. brief='number of messages to trigger a warning',
  34. description='The number of unique channels the same message is ' + \
  35. 'posted in by the same user to trigger a mod warning.',
  36. usage='<count:int>',
  37. min_value=2)
  38. SETTING_BAN_COUNT = CogSetting('bancount', int,
  39. brief='number of messages to trigger a ban',
  40. description='The number of unique channels the same message is ' + \
  41. 'posted in by the same user to trigger an automatic ban. Set ' + \
  42. 'to a large value to effectively disable, e.g. 9999.',
  43. usage='<count:int>',
  44. min_value=2)
  45. SETTING_MIN_LENGTH = CogSetting('minlength', int,
  46. brief='minimum message length',
  47. description='The minimum number of characters in a message to be ' + \
  48. 'checked for duplicates. This can help ignore common short ' + \
  49. 'messages like "lol" or a single emoji.',
  50. usage='<character_count:int>',
  51. min_value=1)
  52. SETTING_TIMESPAN = CogSetting('timespan', float,
  53. brief='time window to look for dupe messages',
  54. description='The number of seconds of message history to look at ' + \
  55. 'when looking for duplicates. Shorter values are preferred, ' + \
  56. 'both to detect bots and avoid excessive memory usage.',
  57. usage='<seconds:int>',
  58. min_value=1)
  59. STATE_KEY_RECENT_MESSAGES = "CrossPostCog.recent_messages"
  60. STATE_KEY_SPAM_CONTEXT = "CrossPostCog.spam_context"
  61. def __init__(self, bot):
  62. super().__init__(bot)
  63. self.add_setting(CrossPostCog.SETTING_ENABLED)
  64. self.add_setting(CrossPostCog.SETTING_WARN_COUNT)
  65. self.add_setting(CrossPostCog.SETTING_BAN_COUNT)
  66. self.add_setting(CrossPostCog.SETTING_MIN_LENGTH)
  67. self.add_setting(CrossPostCog.SETTING_TIMESPAN)
  68. self.max_spam_contexts = 12
  69. async def __record_message(self, message: Message) -> None:
  70. if message.author.permissions_in(message.channel).ban_members:
  71. # User exempt from spam detection
  72. return
  73. if len(message.content) < self.get_guild_setting(message.guild, self.SETTING_MIN_LENGTH):
  74. # Message too short to count towards spam total
  75. return
  76. max_age = timedelta(seconds=self.get_guild_setting(message.guild, self.SETTING_TIMESPAN))
  77. warn_count = self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT)
  78. recent_messages = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES)
  79. if recent_messages is None:
  80. recent_messages = AgeBoundList(max_age, lambda index, message : message.created_at)
  81. Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
  82. recent_messages.max_age = max_age
  83. recent_messages.append(message)
  84. # Get all recent messages by user
  85. member_messages = [m for m in recent_messages if m.author.id == message.author.id]
  86. if len(member_messages) < warn_count:
  87. return
  88. # Look for repeats
  89. hash_to_channels = {} # int --> set(TextChannel)
  90. max_count = 0
  91. for m in member_messages:
  92. key = hash(m.content)
  93. channels = hash_to_channels.get(key)
  94. if channels is None:
  95. channels = set()
  96. hash_to_channels[key] = channels
  97. channels.add(m.channel)
  98. max_count = max(max_count, len(channels))
  99. if max_count < warn_count:
  100. return
  101. # Handle the spam
  102. spam_lookup = Storage.get_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT)
  103. if spam_lookup is None:
  104. spam_lookup = SizeBoundDict(
  105. self.max_spam_contexts,
  106. lambda key, context : context.age)
  107. Storage.set_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
  108. for message_hash, channels in hash_to_channels.items():
  109. channel_count = len(channels)
  110. if channel_count < warn_count:
  111. continue
  112. key = f'{message.author.id}|{message_hash}'
  113. context = spam_lookup.get(key)
  114. is_new = context is None
  115. if context is None:
  116. context = SpamContext(message.author, message_hash)
  117. spam_lookup[key] = context
  118. context.age = message.created_at
  119. self.log(message.guild,
  120. f'\u0007{message.author.name} ({message.author.id}) ' + \
  121. f'posted the same message in {channel_count} or more channels.')
  122. for m in member_messages:
  123. if hash(m.content) == message_hash:
  124. context.spam_messages.add(m)
  125. context.unique_channels.add(m.channel)
  126. await self.__update_from_context(context)
  127. async def __update_from_context(self, context: SpamContext):
  128. ban_count = self.get_guild_setting(context.member.guild, self.SETTING_BAN_COUNT)
  129. channel_count = len(context.unique_channels)
  130. if channel_count >= ban_count:
  131. if not context.is_banned:
  132. count = len(context.spam_messages)
  133. await context.member.ban(
  134. reason='Rocketbot: Posted same message in ' + \
  135. f'{channel_count} channels. Banned by ' + \
  136. f'{self.bot.user.name}.',
  137. delete_message_days=1)
  138. context.is_kicked = True
  139. context.is_banned = True
  140. context.is_autobanned = True
  141. context.deleted_messages |= context.spam_messages
  142. self.log(context.member.guild,
  143. f'{context.member.name} ({context.member.id}) posted ' + \
  144. f'same message in {channel_count} channels. Banned by ' + \
  145. f'{self.bot.user.name}.')
  146. else:
  147. # Already banned. Nothing to update in the message.
  148. return
  149. await self.__update_message_from_context(context)
  150. async def __update_message_from_context(self, context: SpamContext) -> None:
  151. first_spam_message = next(iter(context.spam_messages))
  152. spam_count = len(context.spam_messages)
  153. channel_count = len(context.unique_channels)
  154. deleted_count = len(context.spam_messages)
  155. message = context.bot_message
  156. if message is None:
  157. message = BotMessage(context.member.guild, '',
  158. BotMessage.TYPE_MOD_WARNING, context)
  159. message.quote = first_spam_message.content
  160. if context.is_autobanned:
  161. text = f'User {context.member.mention} auto banned for ' + \
  162. f'posting the same message in {channel_count} channels. ' + \
  163. 'Messages from past 24 hours deleted.'
  164. await message.set_reactions([])
  165. await message.set_text(text)
  166. else:
  167. await message.set_text(f'User {context.member.mention} posted ' +
  168. f'the same message in {channel_count} channels.')
  169. await message.set_reactions(BotMessageReaction.standard_set(
  170. did_delete = deleted_count >= spam_count,
  171. message_count = spam_count,
  172. did_kick = context.is_kicked,
  173. did_ban = context.is_banned))
  174. if context.bot_message is None:
  175. await self.post_message(message)
  176. context.bot_message = message
  177. async def on_mod_react(self,
  178. bot_message: BotMessage,
  179. reaction: BotMessageReaction,
  180. reacted_by: Member) -> None:
  181. context: SpamContext = bot_message.context
  182. if context is None:
  183. return
  184. channel_count = len(context.unique_channels)
  185. if reaction.emoji == CONFIG['trash_emoji']:
  186. for message in context.spam_messages - context.deleted_messages:
  187. await message.delete()
  188. context.deleted_messages.add(message)
  189. await self.__update_from_context(context)
  190. self.log(context.member.guild,
  191. f'{context.member.name} ({context.member.id}) posted same ' + \
  192. f'message in {channel_count} channels. Deleted by {reacted_by.name}.')
  193. elif reaction.emoji == CONFIG['kick_emoji']:
  194. await context.member.kick(
  195. reason=f'Rocketbot: Posted same message in {channel_count} ' + \
  196. f'channels. Kicked by {reacted_by.name}.')
  197. context.is_kicked = True
  198. await self.__update_from_context(context)
  199. self.log(context.member.guild,
  200. f'{context.member.name} ({context.member.id}) posted same ' + \
  201. f'message in {channel_count} channels. Kicked by {reacted_by.name}.')
  202. elif reaction.emoji == CONFIG['ban_emoji']:
  203. await context.member.ban(
  204. reason=f'Rocketbot: Posted same message in {channel_count} ' + \
  205. f'channels. Banned by {reacted_by.name}.',
  206. delete_message_days=1)
  207. context.deleted_messages |= context.spam_messages
  208. context.is_kicked = True
  209. context.is_banned = True
  210. await self.__update_from_context(context)
  211. self.log(context.member.guild,
  212. f'{context.member.name} ({context.member.id}) posted same ' + \
  213. f'message in {channel_count} channels. Kicked by {reacted_by.name}.')
  214. @commands.Cog.listener()
  215. async def on_message(self, message: Message):
  216. if message.author is None or \
  217. message.author.bot or \
  218. message.channel is None or \
  219. message.guild is None or \
  220. message.content is None or \
  221. message.content == '':
  222. return
  223. if not self.get_guild_setting(message.guild, self.SETTING_ENABLED):
  224. return
  225. await self.__record_message(message)
  226. @commands.group(
  227. brief='Manages crosspost detection and handling',
  228. )
  229. @commands.has_permissions(ban_members=True)
  230. @commands.guild_only()
  231. async def crosspost(self, context: commands.Context):
  232. 'Crosspost detection command group'
  233. if context.invoked_subcommand is None:
  234. await context.send_help()