Experimental Discord bot written in Python
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. """
  2. Cog for detecting spam messages posted in multiple channels.
  3. """
  4. from datetime import datetime, timedelta
  5. from discord import Member, Message
  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 = 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 = BotMessage(context.member.guild, '',
  162. BotMessage.TYPE_MOD_WARNING, context)
  163. message.quote = first_spam_message.content
  164. if context.is_autobanned:
  165. text = f'User {context.member.mention} auto banned for ' + \
  166. f'posting the same message in {channel_count} channels. ' + \
  167. 'Messages from past 24 hours deleted.'
  168. await message.set_reactions([])
  169. await message.set_text(text)
  170. else:
  171. await message.set_text(f'User {context.member.mention} posted ' +
  172. f'the same message in {channel_count} channels.')
  173. await message.set_reactions(BotMessageReaction.standard_set(
  174. did_delete = deleted_count >= spam_count,
  175. message_count = spam_count,
  176. did_kick = context.is_kicked,
  177. did_ban = context.is_banned))
  178. if context.bot_message is None:
  179. await self.post_message(message)
  180. context.bot_message = message
  181. async def on_mod_react(self,
  182. bot_message: BotMessage,
  183. reaction: BotMessageReaction,
  184. reacted_by: Member) -> None:
  185. context: SpamContext = bot_message.context
  186. if context is None:
  187. return
  188. channel_count = len(context.unique_channels)
  189. if reaction.emoji == CONFIG['trash_emoji']:
  190. for message in context.spam_messages - context.deleted_messages:
  191. await message.delete()
  192. context.deleted_messages.add(message)
  193. await self.__update_from_context(context)
  194. self.log(context.member.guild,
  195. f'{context.member.name} ({context.member.id}) posted same ' + \
  196. f'message in {channel_count} channels. Deleted by {reacted_by.name}.')
  197. elif reaction.emoji == CONFIG['kick_emoji']:
  198. await context.member.kick(
  199. reason=f'Rocketbot: Posted same message in {channel_count} ' + \
  200. f'channels. Kicked by {reacted_by.name}.')
  201. context.is_kicked = True
  202. await self.__update_from_context(context)
  203. self.log(context.member.guild,
  204. f'{context.member.name} ({context.member.id}) posted same ' + \
  205. f'message in {channel_count} channels. Kicked by {reacted_by.name}.')
  206. elif reaction.emoji == CONFIG['ban_emoji']:
  207. await context.member.ban(
  208. reason=f'Rocketbot: Posted same message in {channel_count} ' + \
  209. f'channels. Banned by {reacted_by.name}.',
  210. delete_message_days=1)
  211. context.deleted_messages |= context.spam_messages
  212. context.is_kicked = True
  213. context.is_banned = True
  214. await self.__update_from_context(context)
  215. self.log(context.member.guild,
  216. f'{context.member.name} ({context.member.id}) posted same ' + \
  217. f'message in {channel_count} channels. Kicked by {reacted_by.name}.')
  218. @commands.Cog.listener()
  219. async def on_message(self, message: Message):
  220. 'Event handler'
  221. if message.author is None or \
  222. message.author.bot or \
  223. message.channel is None or \
  224. message.guild is None or \
  225. message.content is None or \
  226. message.content == '':
  227. return
  228. if not self.get_guild_setting(message.guild, self.SETTING_ENABLED):
  229. return
  230. await self.__record_message(message)
  231. @commands.group(
  232. brief='Manages crosspost detection and handling',
  233. )
  234. @commands.has_permissions(ban_members=True)
  235. @commands.guild_only()
  236. async def crosspost(self, context: commands.Context):
  237. 'Crosspost detection command group'
  238. if context.invoked_subcommand is None:
  239. await context.send_help()