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 9.7KB

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