Experimental Discord bot written in Python
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. """
  2. Cog for detecting username patterns.
  3. """
  4. from discord import Guild, Intents, Interaction, Member
  5. from discord.app_commands import Group
  6. from discord.ext.commands import Cog
  7. from config import CONFIG
  8. from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
  9. from rocketbot.storage import Storage
  10. from rocketbot.utils import MOD_PERMISSIONS
  11. class UsernamePatternContext:
  12. """
  13. BotMessage context for a flagged username
  14. """
  15. def __init__(self, member: Member) -> None:
  16. self.member: Member = member
  17. self.kicked_by: Member | None = None
  18. self.banned_by: Member | None = None
  19. self.ignored_by: Member | None = None
  20. def reactions(self) -> list[BotMessageReaction]:
  21. """
  22. Generates updated BotMessageReactions based on context state.
  23. """
  24. r: list[BotMessageReaction] = []
  25. if self.ignored_by:
  26. r.append(BotMessageReaction(CONFIG['ignore_emoji'], False, f'Ignored by {self.ignored_by.name}'))
  27. elif self.banned_by:
  28. r.append(BotMessageReaction(CONFIG['ban_emoji'], False, f'Banned by {self.banned_by.name}'))
  29. elif self.kicked_by:
  30. r.append(BotMessageReaction(CONFIG['kick_emoji'], False, f'Kicked by {self.kicked_by.name}'))
  31. r.append(BotMessageReaction(CONFIG['ban_emoji'], True, 'Ban user'))
  32. else:
  33. r.append(BotMessageReaction(CONFIG['kick_emoji'], True, 'Kick user'))
  34. r.append(BotMessageReaction(CONFIG['ban_emoji'], True, 'Ban user'))
  35. r.append(BotMessageReaction(CONFIG['ignore_emoji'], True, 'Ignore warning'))
  36. return r
  37. class UsernamePatternCog(BaseCog, name='Username Pattern'):
  38. """
  39. Detects usernames that match certain flagged patterns. Posts a mod warning
  40. message on a match.
  41. """
  42. SETTING_ENABLED = CogSetting(
  43. 'enabled',
  44. bool,
  45. default_value=False,
  46. brief='username pattern detection',
  47. description='Whether new users are checked for username patterns.',
  48. )
  49. SETTING_PATTERNS = CogSetting('patterns', None, default_value=None)
  50. def __init__(self, bot):
  51. super().__init__(
  52. bot,
  53. config_prefix='username',
  54. short_description='Manages username pattern detection.',
  55. long_description='When new users join, if their username matches '
  56. 'a configured pattern the mods will be alerted.'
  57. )
  58. self.add_setting(UsernamePatternCog.SETTING_ENABLED)
  59. def __get_patterns(self, guild: Guild) -> list[str]:
  60. """
  61. Returns an array of username patterns.
  62. """
  63. patterns: list[str] = self.get_guild_setting(guild, self.SETTING_PATTERNS)
  64. if patterns is None:
  65. patterns = []
  66. Storage.set_config_value(guild, 'UsernamePatternCog.patterns', patterns)
  67. return patterns
  68. @classmethod
  69. def __save_patterns(cls,
  70. guild: Guild,
  71. patterns: list[str]) -> None:
  72. """
  73. Saves username pattern array.
  74. """
  75. cls.set_guild_setting(guild, cls.SETTING_PATTERNS, patterns)
  76. username = Group(
  77. name='username',
  78. description='Manages username pattern detection.',
  79. guild_only=True,
  80. default_permissions=MOD_PERMISSIONS,
  81. extras={
  82. 'long_description': 'When new users join, if their username matches '
  83. 'a configured pattern the mods will be alerted.',
  84. },
  85. )
  86. @username.command(
  87. description='Adds a username pattern.',
  88. extras={
  89. 'long_description': 'When a user joins the server, if their username '
  90. 'matches a configured pattern the mods will be alerted. '
  91. 'Matching is currently a simple substring test.',
  92. 'usage': '<pattern>',
  93. },
  94. )
  95. async def add(self, interaction: Interaction, pattern: str) -> None:
  96. """
  97. Adds a username pattern to match against new members.
  98. Parameters
  99. ----------
  100. interaction : Interaction
  101. pattern : str
  102. a substring to look for in usernames
  103. """
  104. norm_pattern = pattern.lower()
  105. patterns: list[str] = self.__get_patterns(interaction.guild)
  106. if norm_pattern in patterns:
  107. await interaction.response.send_message(
  108. f'{CONFIG["warning_emoji"]} Pattern `{norm_pattern}` already added.',
  109. ephemeral=True
  110. )
  111. return
  112. patterns.append(norm_pattern)
  113. self.__save_patterns(interaction.guild, patterns)
  114. await interaction.response.send_message(
  115. f'{CONFIG["success_emoji"]} Pattern `{norm_pattern}` added.',
  116. ephemeral=True
  117. )
  118. @username.command(
  119. description='Removes a username pattern.',
  120. extras={
  121. 'usage': '<pattern>',
  122. },
  123. )
  124. async def remove(self, interaction: Interaction, pattern: str) -> None:
  125. """
  126. Removes a username pattern.
  127. Parameters
  128. ----------
  129. interaction : Interaction
  130. pattern : str
  131. the existing username pattern to remove
  132. """
  133. norm_pattern = pattern.lower()
  134. guild: Guild = interaction.guild
  135. patterns: list[str] = self.__get_patterns(guild)
  136. len_before = len(patterns)
  137. patterns = list(filter(lambda p: p != norm_pattern, patterns))
  138. if len(patterns) == len_before:
  139. await interaction.response.send_message(
  140. f'{CONFIG["warning_emoji"]} Pattern `{norm_pattern}` not found.',
  141. ephemeral=True,
  142. )
  143. return
  144. self.__save_patterns(guild, patterns)
  145. await interaction.response.send_message(
  146. f'{CONFIG["success_emoji"]} Pattern `{norm_pattern}` removed.',
  147. ephemeral=True,
  148. )
  149. @username.command(
  150. description='Lists existing username patterns.'
  151. )
  152. async def list(self, interaction: Interaction) -> None:
  153. """Command handler"""
  154. guild: Guild = interaction.guild
  155. patterns: list[str] = self.__get_patterns(guild)
  156. if len(patterns) == 0:
  157. await interaction.response.send_message(
  158. f'{CONFIG["success_emoji"]} No patterns defined.',
  159. ephemeral=True,
  160. )
  161. else:
  162. msg = f'{CONFIG["info_emoji"]} Patterns:\n\n> `' + '`\n> `'.join(patterns) + '`'
  163. await interaction.response.send_message(
  164. msg,
  165. ephemeral=True,
  166. )
  167. @Cog.listener()
  168. async def on_member_join(self, member: Member) -> None:
  169. """Event handler"""
  170. for pattern in self.__get_patterns(member.guild):
  171. if self.matches(pattern, member.name) or self.matches(pattern, member.display_name):
  172. await self.handle_match(member, pattern)
  173. def matches(self, pattern: str, subject: str) -> bool:
  174. """Checks if a username matches a given pattern"""
  175. if pattern is None:
  176. return False
  177. if subject is None:
  178. return False
  179. return pattern.lower() in subject.lower()
  180. async def handle_match(self, member: Member, pattern: str) -> None:
  181. """
  182. Handles a username match.
  183. """
  184. # TODO: Prevent double handling?
  185. self.log(member.guild, f'User {member.id} {member.display_name} matches pattern "{pattern}"')
  186. context = UsernamePatternContext(member)
  187. bm = BotMessage(
  188. member.guild,
  189. f'User {member.mention} ({member.id}, {member.display_name}) has ' +
  190. f'username matching pattern `{pattern}`.',
  191. BotMessage.TYPE_INFO if self.was_warned_recently(member) else BotMessage.TYPE_MOD_WARNING,
  192. context)
  193. self.record_warning(member)
  194. await bm.set_reactions(context.reactions())
  195. await self.post_message(bm)
  196. async def on_mod_react(self,
  197. bot_message: BotMessage,
  198. reaction: BotMessageReaction,
  199. reacted_by: Member) -> None:
  200. context: UsernamePatternContext = bot_message.context
  201. if reaction.emoji == CONFIG['kick_emoji']:
  202. await context.member.kick(
  203. reason=f'Rocketbot: Flagged username pattern. Kicked by {reacted_by.name}.')
  204. context.kicked_by = reacted_by
  205. self.log(context.member.guild, f'User {context.member.name} kicked by {reacted_by.name}')
  206. await bot_message.set_reactions(context.reactions())
  207. elif reaction.emoji == CONFIG['ban_emoji']:
  208. await context.member.ban(
  209. reason=f'Rocketbot: Flagged username pattern. Banned by {reacted_by.name}.',
  210. delete_message_days=0)
  211. context.banned_by = reacted_by
  212. self.log(context.member.guild, f'User {context.member.name} banned by {reacted_by.name}')
  213. await bot_message.set_reactions(context.reactions())
  214. elif reaction.emoji == CONFIG['ignore_emoji']:
  215. context.ignored_by = reacted_by
  216. self.log(context.member.guild, f'Warning ignored by {reacted_by.name}')
  217. await bot_message.set_reactions(context.reactions())
  218. @classmethod
  219. def supports_intents(cls, intents: Intents) -> bool:
  220. return intents.members