Experimental Discord bot written in Python
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

crosspostcog.py 8.4KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. from discord import Guild, Member, Message, PartialEmoji
  2. from discord.ext import commands
  3. from datetime import datetime, timedelta
  4. import math
  5. from config import CONFIG
  6. from rscollections import AgeBoundList, SizeBoundDict
  7. from cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
  8. from storage import Storage
  9. class SpamContext:
  10. def __init__(self, member, message_hash):
  11. self.member = member
  12. self.message_hash = message_hash
  13. self.age = datetime.now()
  14. self.bot_message = None # BotMessage
  15. self.is_kicked = False
  16. self.is_banned = False
  17. self.is_autobanned = False
  18. self.spam_messages = set() # of Message
  19. self.deleted_messages = set() # of Message
  20. class CrossPostCog(BaseCog):
  21. SETTING_WARN_COUNT = CogSetting('warncount',
  22. brief='number of messages to trigger a warning',
  23. description='The number of identical messages to trigger a mod warning.',
  24. usage='<count:int>',
  25. min_value=2)
  26. SETTING_BAN_COUNT = CogSetting('bancount',
  27. brief='number of messages to trigger a ban',
  28. description='The number of identical messages to trigger an ' + \
  29. 'automatic ban. Set to a large value to effectively disable, e.g. 9999.',
  30. usage='<count:int>',
  31. min_value=2)
  32. SETTING_MIN_LENGTH = CogSetting('minlength',
  33. brief='minimum message length',
  34. description='The minimum number of characters in a message to be ' + \
  35. 'checked for duplicates. This can help ignore common short ' + \
  36. 'messages like "lol" or a single emoji. Set to 0 to count all ' + \
  37. 'message lengths.',
  38. usage='<character_count:int>',
  39. min_value=0)
  40. SETTING_TIMESPAN = CogSetting('timespan',
  41. brief='time window to look for dupe messages',
  42. description='The number of seconds of message history to look at ' + \
  43. 'when looking for duplicates. Shorter values are preferred, ' + \
  44. 'both to detect bots and avoid excessive memory usage.',
  45. usage='<seconds:int>',
  46. min_value=1)
  47. STATE_KEY_RECENT_MESSAGES = "crosspost_recent_messages"
  48. STATE_KEY_SPAM_CONTEXT = "crosspost_spam_context"
  49. CONFIG_KEY_WARN_COUNT = "crosspost_warn_count"
  50. CONFIG_KEY_BAN_COUNT = "crosspost_ban_count"
  51. CONFIG_KEY_MIN_MESSAGE_LENGTH = "crosspost_min_message_length"
  52. CONFIG_KEY_MESSAGE_AGE = "crosspost_message_age"
  53. def __init__(self, bot):
  54. super().__init__(bot)
  55. self.add_setting(CrossPostCog.SETTING_WARN_COUNT)
  56. self.add_setting(CrossPostCog.SETTING_BAN_COUNT)
  57. self.add_setting(CrossPostCog.SETTING_MIN_LENGTH)
  58. self.add_setting(CrossPostCog.SETTING_TIMESPAN)
  59. self.max_spam_contexts = 12
  60. # Config
  61. async def __record_message(self, message: Message) -> None:
  62. if message.author.permissions_in(message.channel).ban_members:
  63. # User exempt from spam detection
  64. return
  65. if len(message.content) < self.get_guild_setting(message.guild, self.SETTING_MIN_LENGTH):
  66. # Message too short to count towards spam total
  67. return
  68. max_age = timedelta(seconds=self.get_guild_setting(message.guild, self.SETTING_TIMESPAN))
  69. recent_messages = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES) \
  70. or AgeBoundList(max_age, lambda index, message : message.created_at)
  71. recent_messages.max_age = max_age
  72. recent_messages.append(message)
  73. Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
  74. # Get all recent messages by user
  75. member_messages = [m for m in recent_messages if m.author.id == message.author.id]
  76. if len(member_messages) < self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT):
  77. return
  78. # Look for repeats
  79. hash_to_count = {}
  80. max_count = 0
  81. for m in member_messages:
  82. key = hash(m.content)
  83. count = (hash_to_count.get(key) or 0) + 1
  84. hash_to_count[key] = count
  85. max_count = max(max_count, count)
  86. if max_count < self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT):
  87. return
  88. # Handle the spam
  89. spam_lookup = Storage.get_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT) \
  90. or SizeBoundDict(self.max_spam_contexts, lambda key, context : context.age)
  91. Storage.set_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
  92. for message_hash, count in hash_to_count.items():
  93. if count < self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT):
  94. continue
  95. key = f'{message.author.id}|{message_hash}'
  96. context = spam_lookup.get(key)
  97. is_new = context is None
  98. if context is None:
  99. context = SpamContext(message.author, message_hash)
  100. spam_lookup[key] = context
  101. context.age = message.created_at
  102. for m in member_messages:
  103. if hash(m.content) == message_hash:
  104. context.spam_messages.add(m)
  105. await self.__update_from_context(context)
  106. async def __update_from_context(self, context: SpamContext):
  107. if len(context.spam_messages) >= self.get_guild_setting(context.member.guild, self.SETTING_BAN_COUNT):
  108. if not context.is_banned:
  109. count = len(context.spam_messages)
  110. await context.member.ban(reason=f'Autobanned by Rocketbot for posting same message {count} times', delete_message_days=1)
  111. context.is_kicked = True
  112. context.is_banned = True
  113. context.is_autobanned = True
  114. context.deleted_messages |= context.spam_messages
  115. self.log(context.member.guild, f'Bot autobanned {context.member.name} ({context.member.id}) for spamming')
  116. else:
  117. # Already banned. Nothing to update in the message.
  118. return
  119. await self.__update_message_from_context(context)
  120. async def __update_message_from_context(self, context: SpamContext) -> None:
  121. first_spam_message = next(iter(context.spam_messages))
  122. spam_count = len(context.spam_messages)
  123. deleted_count = len(context.deleted_messages)
  124. message = context.bot_message
  125. if message is None:
  126. message = BotMessage(context.member.guild, '',
  127. BotMessage.TYPE_MOD_WARNING, context)
  128. message.quote = first_spam_message.content
  129. if context.is_autobanned:
  130. text = f'User {context.member.mention} auto banned for ' + \
  131. f'posting the same message {deleted_count} ' + \
  132. 'times. Messages from past 24 hours deleted.'
  133. await message.set_reactions([])
  134. await message.set_text(text)
  135. else:
  136. await message.set_text(f'User {context.member.mention} posted ' +
  137. f'the same message {spam_count} times.')
  138. await message.set_reactions(BotMessageReaction.standard_set(
  139. did_delete = deleted_count >= spam_count,
  140. message_count = spam_count,
  141. did_kick = context.is_kicked,
  142. did_ban = context.is_banned))
  143. if context.bot_message is None:
  144. await self.post_message(message)
  145. context.bot_message = message
  146. async def __delete_messages(self, context: SpamContext) -> None:
  147. for message in context.spam_messages - context.deleted_messages:
  148. await message.delete()
  149. context.deleted_messages.add(message)
  150. await self.__update_from_context(context)
  151. self.log(context.member.guild, f'Mod deleted messages from {context.member.name} ({context.member.id})')
  152. async def __kick(self, context: SpamContext) -> None:
  153. await context.member.kick(reason='Posting same message repeatedly')
  154. context.is_kicked = True
  155. await self.__update_from_context(context)
  156. self.log(context.member.guild, f'Mod kicked user {context.member.name} ({context.member.id})')
  157. async def __ban(self, context: SpamContext) -> None:
  158. await context.member.ban(reason='Posting same message repeatedly', delete_message_days=1)
  159. context.deleted_messages |= context.spam_messages
  160. context.is_kicked = True
  161. context.is_banned = True
  162. await self.__update_from_context(context)
  163. self.log(context.member.guild, f'Mod banned user {context.member.name} ({context.member.id})')
  164. async def on_mod_react(self,
  165. bot_message: BotMessage,
  166. reaction: BotMessageReaction,
  167. reacted_by: Member) -> None:
  168. context: SpamContext = bot_message.context
  169. if context is None:
  170. return
  171. if reaction.emoji == CONFIG['trash_emoji']:
  172. await self.__delete_messages(context)
  173. elif reaction.emoji == CONFIG['kick_emoji']:
  174. await self.__kick(context)
  175. elif reaction.emoji == CONFIG['ban_emoji']:
  176. await self.__ban(context)
  177. @commands.Cog.listener()
  178. async def on_message(self, message: Message):
  179. if message.author is None or \
  180. message.author.bot or \
  181. message.channel is None or \
  182. message.guild is None or \
  183. message.content is None or \
  184. message.content == '':
  185. return
  186. await self.__record_message(message)
  187. # -- Commands -----------------------------------------------------------
  188. @commands.group(
  189. brief='Manages crosspost/repeated post detection and handling',
  190. )
  191. @commands.has_permissions(ban_members=True)
  192. @commands.guild_only()
  193. async def crosspost(self, context: commands.Context):
  194. 'Command group'
  195. if context.invoked_subcommand is None:
  196. await context.send_help()