Experimental Discord bot written in Python
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

crosspostcog.py 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. """
  2. Cog for detecting spam messages posted in multiple channels.
  3. """
  4. import re
  5. from datetime import datetime, timedelta, timezone
  6. from typing import Optional
  7. from discord import Member, Message, TextChannel
  8. from discord import utils as discordutils
  9. from discord.ext.commands import Cog
  10. from config import CONFIG
  11. from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
  12. from rocketbot.collections import AgeBoundDict, AgeBoundList
  13. from rocketbot.storage import Storage
  14. from rocketbot.utils import norm_datetime, str_from_timedelta
  15. class SpamContext:
  16. """
  17. Data about a set of duplicate messages from a user.
  18. """
  19. def __init__(self, member: Member) -> None:
  20. self.member: Member = member
  21. self.age: datetime = datetime.now(timezone.utc)
  22. self.bot_message: Optional[BotMessage] = None
  23. self.is_kicked: bool = False
  24. self.is_banned: bool = False
  25. self.is_autobanned: bool = False
  26. self.spam_messages: set[Message] = set()
  27. self.deleted_messages: set[Message] = set()
  28. self.unique_channels: set[TextChannel] = set()
  29. self.duplicate_count: int = 0
  30. class CrossPostCog(BaseCog, name='Crosspost Detection'):
  31. """
  32. Detects a user posting in multiple channels in a short period
  33. of time: a common pattern for spammers.
  34. """
  35. SETTING_ENABLED = CogSetting(
  36. 'enabled',
  37. bool,
  38. default_value=False,
  39. brief='crosspost detection',
  40. description='Whether crosspost detection is enabled.',
  41. )
  42. SETTING_WARN_COUNT = CogSetting(
  43. 'warncount',
  44. int,
  45. default_value=5,
  46. brief='number of messages to trigger a warning',
  47. description='The number of unique channels messages are ' + \
  48. 'posted in by the same user to trigger a mod warning. The ' + \
  49. 'messages need not be identical (see dupewarncount).',
  50. min_value=2,
  51. )
  52. SETTING_DUPE_WARN_COUNT = CogSetting(
  53. 'dupewarncount',
  54. int,
  55. default_value=3,
  56. brief='number of identical messages to trigger a warning',
  57. description='The number of unique channels identical messages are ' + \
  58. 'posted in by the same user to trigger a mod warning.',
  59. min_value=2,
  60. )
  61. SETTING_BAN_COUNT = CogSetting(
  62. 'bancount',
  63. int,
  64. default_value=9999,
  65. brief='number of messages to trigger a ban',
  66. description='The number of unique channels messages are ' + \
  67. 'posted in by the same user to trigger an automatic ban. The ' + \
  68. 'messages need not be identical (see dupebancount). Set ' + \
  69. 'to a large value to effectively disable, e.g. 9999.',
  70. min_value=2,
  71. )
  72. SETTING_DUPE_BAN_COUNT = CogSetting(
  73. 'dupebancount',
  74. int,
  75. default_value=9999,
  76. brief='number of identical messages to trigger a ban',
  77. description='The number of unique channels identical messages are ' + \
  78. 'posted in by the same user to trigger an automatic ban. Set ' + \
  79. 'to a large value to effectively disable, e.g. 9999.',
  80. min_value=2,
  81. )
  82. SETTING_MIN_LENGTH = CogSetting(
  83. 'minlength',
  84. int,
  85. default_value=1,
  86. brief='minimum message length',
  87. description='The minimum number of characters in a message to be ' + \
  88. 'checked for duplicates. This can help ignore common short ' + \
  89. 'messages like "lol" or a single emoji.',
  90. min_value=1,
  91. )
  92. SETTING_TIMESPAN = CogSetting(
  93. 'timespan',
  94. timedelta,
  95. default_value=timedelta(seconds=60),
  96. brief='time window to look for dupe messages',
  97. description='The number of seconds of message history to look at '
  98. 'when looking for duplicates. Shorter values are preferred, '
  99. 'both to detect bots and avoid excessive memory usage.',
  100. min_value=timedelta(seconds=1),
  101. )
  102. STATE_KEY_RECENT_MESSAGES = "CrossPostCog.recent_messages"
  103. STATE_KEY_SPAM_CONTEXT = "CrossPostCog.spam_context"
  104. def __init__(self, bot):
  105. super().__init__(
  106. bot,
  107. config_prefix='crosspost',
  108. short_description='Manages crosspost detection and handling.',
  109. long_description='Detects a user posting in multiple channels in a short period of '
  110. 'time: a common pattern for spammers.\n'
  111. '\n'
  112. "These used to be identical text, but more recent attacks have had "
  113. "small variations, such as different imgur URLs. It's reasonable to "
  114. "treat posting in many channels in a short period as suspicious on its "
  115. "own, regardless of whether they are identical.\n"
  116. "\n"
  117. "Repeated posts in the same channel aren't currently detected, as "
  118. "this can often be for a reason or due to trying a failed post when "
  119. "connectivity is poor. Minimum message length can be enforced for "
  120. "detection.",
  121. )
  122. self.add_setting(CrossPostCog.SETTING_ENABLED)
  123. self.add_setting(CrossPostCog.SETTING_WARN_COUNT)
  124. self.add_setting(CrossPostCog.SETTING_DUPE_WARN_COUNT)
  125. self.add_setting(CrossPostCog.SETTING_BAN_COUNT)
  126. self.add_setting(CrossPostCog.SETTING_DUPE_BAN_COUNT)
  127. self.add_setting(CrossPostCog.SETTING_MIN_LENGTH)
  128. self.add_setting(CrossPostCog.SETTING_TIMESPAN)
  129. self.max_spam_contexts = 12
  130. async def __record_message(self, message: Message) -> None:
  131. if message.channel.permissions_for(message.author).ban_members:
  132. # User exempt from spam detection
  133. self.__trace(f"User {message.author.name} exempt from crosspost checks")
  134. return
  135. def compute_message_hash(m: Message) -> int:
  136. if self.bot.intents.message_content:
  137. to_hash = m.content
  138. # URLs sometimes differ per spam message, so simplify them
  139. url_regex = r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'
  140. to_hash = re.sub(url_regex, '<url>', to_hash)
  141. # Add attachment metadata
  142. for attachment in m.attachments:
  143. to_hash += f'\n[[ATT: ct={attachment.content_type} s={attachment.size} w={attachment.width} h={attachment.height}]]'
  144. else:
  145. # Without content, treat every message as unique
  146. to_hash = str(m.id)
  147. h = hash(to_hash)
  148. self.__trace(f"Hash for message #{m.id} by {m.author.name} is {h}\n\thash content: \"{to_hash}\"")
  149. return h
  150. min_length: int = self.get_guild_setting(message.guild, self.SETTING_MIN_LENGTH)
  151. if len(message.attachments) == 0 and len(message.content) < min_length:
  152. # Message too short to count towards spam total
  153. self.__trace(f"Message len {len(message.content)} < {min_length} configured min")
  154. return
  155. # Get config
  156. max_age: timedelta = self.get_guild_setting(message.guild, self.SETTING_TIMESPAN)
  157. warn_count: int = self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT)
  158. dupe_warn_count: int = self.get_guild_setting(message.guild, self.SETTING_DUPE_WARN_COUNT)
  159. # Record message
  160. recent_messages: AgeBoundList[Message, datetime, timedelta] = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES)
  161. if recent_messages is None:
  162. recent_messages = AgeBoundList(max_age, lambda index, message : norm_datetime(message.created_at))
  163. Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
  164. recent_messages.max_age = max_age
  165. recent_messages.append(message)
  166. self.__trace(f"Recent messages now length {len(recent_messages)} (max age {max_age.total_seconds()}s)")
  167. # Get all recent messages by user
  168. member_messages = [m for m in recent_messages if m.author.id == message.author.id]
  169. message_count = len(member_messages)
  170. self.__trace(f"Found {message_count} recent messages by {message.author.name}")
  171. if message_count < warn_count and message_count < dupe_warn_count:
  172. self.__trace(f"Bailing because total message count {message_count} < configured warn count {warn_count} and < dupe warn count {dupe_warn_count}")
  173. return
  174. # Look for identical(ish) messages and unique channels
  175. hash_to_channels: dict[int, set[TextChannel]] = {}
  176. unique_channels: set[TextChannel] = set()
  177. max_duplicate_count = 0
  178. for m in member_messages:
  179. message_hash = compute_message_hash(m)
  180. dupe_message_channels: set[TextChannel] = hash_to_channels.get(message_hash)
  181. if dupe_message_channels is None:
  182. dupe_message_channels = set()
  183. hash_to_channels[message_hash] = dupe_message_channels
  184. dupe_message_channels.add(m.channel)
  185. unique_channels.add(m.channel)
  186. max_duplicate_count = max(max_duplicate_count, len(dupe_message_channels))
  187. channel_count = len(unique_channels)
  188. self.__trace(f"Found {len(hash_to_channels)} unique messages, {channel_count} unique channels, configured {max_duplicate_count} duplicated messages")
  189. if channel_count < warn_count and max_duplicate_count < dupe_warn_count:
  190. self.__trace(f"Bailing because unique channels {channel_count} < configured warn count {warn_count} "
  191. f"and max dupes {max_duplicate_count} < configured dupe warn count {dupe_warn_count}")
  192. return
  193. # This person is a problem
  194. self.__trace(f"Spam detected by {message.author.name}")
  195. spam_lookup: AgeBoundDict[str, SpamContext, datetime, timedelta] = Storage.get_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT)
  196. if spam_lookup is None:
  197. spam_lookup = AgeBoundDict(
  198. max_age,
  199. lambda key, context : context.age)
  200. Storage.set_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
  201. key = f'{message.author.id}'
  202. context = spam_lookup.get(key)
  203. if context is not None and message.created_at.timestamp() - context.age.timestamp() > max_age.total_seconds():
  204. context = None
  205. if context is None:
  206. context = SpamContext(message.author)
  207. spam_lookup[key] = context
  208. self.log(message.guild,
  209. f'\u0007{message.author.name} ({message.author.id}) ' + \
  210. f'posted messages in {channel_count} channels.')
  211. context.age = norm_datetime(message.created_at)
  212. context.duplicate_count = max_duplicate_count
  213. context.spam_messages.update(member_messages)
  214. context.unique_channels.update(unique_channels)
  215. await self.__update_from_context(context)
  216. async def __update_from_context(self, context: SpamContext):
  217. ban_count: int = self.get_guild_setting(context.member.guild, self.SETTING_BAN_COUNT)
  218. dupe_ban_count: int = self.get_guild_setting(context.member.guild, self.SETTING_DUPE_BAN_COUNT)
  219. channel_count = len(context.unique_channels)
  220. if channel_count >= ban_count or context.duplicate_count >= dupe_ban_count:
  221. if not context.is_banned:
  222. max_age: timedelta = self.get_guild_setting(context.member.guild, self.SETTING_TIMESPAN)
  223. max_age_str = str_from_timedelta(max_age)
  224. await context.member.ban(
  225. reason=f'Rocketbot: Posted in {channel_count} channels within {max_age_str} ' + \
  226. f'({context.duplicate_count} identical). Banned by {self.bot.user.name}.',
  227. delete_message_days=1)
  228. context.is_kicked = True
  229. context.is_banned = True
  230. context.is_autobanned = True
  231. context.deleted_messages |= context.spam_messages
  232. self.__log_ban(context, self.bot.user.name)
  233. else:
  234. # Already banned. Nothing to update in the message.
  235. return
  236. await self.__update_message_from_context(context)
  237. async def __update_message_from_context(self, context: SpamContext) -> None:
  238. first_spam_message: Message = sorted(list(context.spam_messages), key=lambda m: m.created_at)[0]
  239. spam_count = len(context.spam_messages)
  240. channel_count = len(context.unique_channels)
  241. deleted_count = len(context.deleted_messages)
  242. duplicate_count = context.duplicate_count
  243. max_age: timedelta = self.get_guild_setting(context.member.guild, self.SETTING_TIMESPAN)
  244. max_age_str = str_from_timedelta(max_age)
  245. message = context.bot_message
  246. if message is None:
  247. message_type: int = BotMessage.TYPE_INFO if self.was_warned_recently(context.member) \
  248. else BotMessage.TYPE_MOD_WARNING
  249. message = BotMessage(context.member.guild, '', message_type, context)
  250. message.quote = discordutils.remove_markdown(first_spam_message.clean_content)
  251. self.record_warning(context.member)
  252. if context.is_autobanned:
  253. body = f'User {context.member.mention} auto banned for ' + \
  254. f'posting messages in {channel_count} channels within {max_age_str} ' + \
  255. f'({duplicate_count} identical). Messages from past 24 hours deleted.'
  256. body += f"\n\nFirst message:\n```\n{first_spam_message.content}\n```"
  257. await message.set_reactions([])
  258. await message.set_text(body)
  259. else:
  260. body: str = f'User {context.member.mention} posted '
  261. if duplicate_count == channel_count:
  262. body += f'identical messages in {channel_count} channels within {max_age_str} .'
  263. elif duplicate_count == 1:
  264. body += f'**different** messages in {channel_count} channels within ' + \
  265. f'{max_age_str}. (Showing first one).'
  266. else:
  267. body += f'messages in {channel_count} channels within {max_age_str} ' + \
  268. f'({duplicate_count} are identical, showing first one).'
  269. max_links = 10
  270. for msg in sorted(list(context.spam_messages), key=lambda m: m.created_at)[:max_links]:
  271. body += f'\n- {msg.jump_url}'
  272. if len(context.spam_messages) > max_links:
  273. body += f'\n- ...{len(context.spam_messages) - max_links} more...'
  274. await message.set_text(body)
  275. await message.set_reactions(BotMessageReaction.standard_set(
  276. did_delete = deleted_count >= spam_count,
  277. message_count = spam_count,
  278. did_kick = context.is_kicked,
  279. did_ban = context.is_banned))
  280. if context.bot_message is None:
  281. await self.post_message(message)
  282. context.bot_message = message
  283. async def on_mod_react(self,
  284. bot_message: BotMessage,
  285. reaction: BotMessageReaction,
  286. reacted_by: Member) -> None:
  287. context: SpamContext = bot_message.context
  288. if context is None:
  289. return
  290. channel_count = len(context.unique_channels)
  291. if reaction.emoji == CONFIG['trash_emoji']:
  292. for message in context.spam_messages - context.deleted_messages:
  293. await message.delete()
  294. context.deleted_messages.add(message)
  295. await self.__update_from_context(context)
  296. self.__log_deletion(context, reacted_by.name)
  297. elif reaction.emoji == CONFIG['kick_emoji']:
  298. await context.member.kick(
  299. reason=f'Rocketbot: Posted messages in {channel_count} ' + \
  300. f'channels. Kicked by {reacted_by.name}.')
  301. context.is_kicked = True
  302. await self.__update_from_context(context)
  303. self.__log_kick(context, reacted_by.name)
  304. elif reaction.emoji == CONFIG['ban_emoji']:
  305. await context.member.ban(
  306. reason=f'Rocketbot: Posted messages in {channel_count} ' + \
  307. f'channels. Banned by {reacted_by.name}.',
  308. delete_message_days=1)
  309. context.deleted_messages |= context.spam_messages
  310. context.is_kicked = True
  311. context.is_banned = True
  312. await self.__update_from_context(context)
  313. self.__log_ban(context, reacted_by.name)
  314. def __log_deletion(self, context: SpamContext, by_who: str) -> None:
  315. max_age: timedelta = self.get_guild_setting(context.member.guild, self.SETTING_TIMESPAN)
  316. max_age_str = str_from_timedelta(max_age)
  317. channel_count = len(context.unique_channels)
  318. duplicate_count = context.duplicate_count
  319. self.log(context.member.guild,
  320. f'{context.member.name} ({context.member.id}) posted ' + \
  321. f'messages in {channel_count} channels withint {max_age_str} ' + \
  322. f'({duplicate_count} identical). Deleted by {by_who}.')
  323. def __log_kick(self, context: SpamContext, by_who: str) -> None:
  324. max_age: timedelta = self.get_guild_setting(context.member.guild, self.SETTING_TIMESPAN)
  325. max_age_str = str_from_timedelta(max_age)
  326. channel_count = len(context.unique_channels)
  327. duplicate_count = context.duplicate_count
  328. self.log(context.member.guild,
  329. f'{context.member.name} ({context.member.id}) posted ' + \
  330. f'messages in {channel_count} channels within {max_age_str} ' + \
  331. f'({duplicate_count} identical). Kicked by {by_who}.')
  332. def __log_ban(self, context: SpamContext, by_who: str) -> None:
  333. max_age: timedelta = self.get_guild_setting(context.member.guild, self.SETTING_TIMESPAN)
  334. max_age_str = str_from_timedelta(max_age)
  335. channel_count = len(context.unique_channels)
  336. duplicate_count = context.duplicate_count
  337. self.log(context.member.guild,
  338. f'{context.member.name} ({context.member.id}) posted ' + \
  339. f'messages in {channel_count} channels within {max_age_str} ' + \
  340. f'({duplicate_count} identical). Banned by {by_who}.')
  341. def __trace(self, message):
  342. # print(f"CrossPostCog: {message}")
  343. pass
  344. @Cog.listener()
  345. async def on_message(self, message: Message):
  346. """Event handler"""
  347. if message.author is None or \
  348. message.author.bot or \
  349. message.channel is None or \
  350. message.guild is None:
  351. return
  352. if not self.get_guild_setting(message.guild, self.SETTING_ENABLED):
  353. return
  354. self.__trace("--ON MESSAGE--")
  355. await self.__record_message(message)