Experimental Discord bot written in Python
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

crosspostcog.py 11KB

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