Experimental Discord bot written in Python
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

crosspostcog.py 10KB

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