Experimental Discord bot written in Python
Du kannst nicht mehr als 25 Themen auswählen Themen müssen mit entweder einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

patterncog.py 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. """
  2. Cog for matching messages against guild-configurable criteria and taking
  3. automated actions on them.
  4. """
  5. from datetime import datetime
  6. from typing import Optional, Literal
  7. from discord import Guild, Member, Message, utils as discordutils, Permissions, Interaction
  8. from discord.app_commands import Group, rename
  9. from discord.ext import commands
  10. from config import CONFIG
  11. from rocketbot.bot import Rocketbot
  12. from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction
  13. from rocketbot.cogsetting import CogSetting
  14. from rocketbot.pattern import PatternCompiler, PatternDeprecationError, \
  15. PatternError, PatternStatement
  16. from rocketbot.storage import Storage
  17. class PatternContext:
  18. """
  19. Data about a message that has matched a configured statement and what
  20. actions have been carried out.
  21. """
  22. def __init__(self, message: Message, statement: PatternStatement):
  23. self.message = message
  24. self.statement = statement
  25. self.is_deleted = False
  26. self.is_kicked = False
  27. self.is_banned = False
  28. class PatternCog(BaseCog, name='Pattern Matching'):
  29. """
  30. Highly flexible cog for performing various actions on messages that match
  31. various criteria. Patterns can be defined by mods for each guild.
  32. """
  33. SETTING_PATTERNS = CogSetting('patterns', None)
  34. def __init__(self, bot: Rocketbot):
  35. super().__init__(
  36. bot,
  37. config_prefix='patterns',
  38. name='patterns',
  39. short_description='Manages message pattern matching.',
  40. )
  41. def __get_patterns(self, guild: Guild) -> dict[str, PatternStatement]:
  42. """
  43. Returns a name -> PatternStatement lookup for the guild.
  44. """
  45. patterns: dict[str, PatternStatement] = Storage.get_state_value(guild,
  46. 'PatternCog.patterns')
  47. if patterns is None:
  48. jsons: list[dict] = self.get_guild_setting(guild, self.SETTING_PATTERNS) or []
  49. pattern_list: list[PatternStatement] = []
  50. for json in jsons:
  51. try:
  52. ps = PatternStatement.from_json(json)
  53. pattern_list.append(ps)
  54. try:
  55. ps.check_deprecations()
  56. except PatternDeprecationError as e:
  57. self.log(guild, f'Pattern {ps.name}: {e}')
  58. except PatternError as e:
  59. self.log(guild, f'Error decoding pattern "{json["name"]}": {e}')
  60. patterns = { p.name:p for p in pattern_list}
  61. Storage.set_state_value(guild, 'PatternCog.patterns', patterns)
  62. return patterns
  63. @classmethod
  64. def __save_patterns(cls,
  65. guild: Guild,
  66. patterns: dict[str, PatternStatement]) -> None:
  67. to_save: list[dict] = list(map(lambda ps: ps.to_json(), patterns.values()))
  68. cls.set_guild_setting(guild, cls.SETTING_PATTERNS, to_save)
  69. @classmethod
  70. def __get_last_matched(cls, guild: Guild, name: str) -> Optional[datetime]:
  71. last_matched: dict[str, datetime] = Storage.get_state_value(guild, 'PatternCog.last_matched')
  72. if last_matched:
  73. return last_matched.get(name)
  74. return None
  75. @classmethod
  76. def __set_last_matched(cls, guild: Guild, name: str, time: datetime) -> None:
  77. last_matched: dict[str, datetime] = Storage.get_state_value(guild, 'PatternCog.last_matched')
  78. if last_matched is None:
  79. last_matched = {}
  80. Storage.set_state_value(guild, 'PatternCog.last_matched', last_matched)
  81. last_matched[name] = time
  82. @commands.Cog.listener()
  83. async def on_message(self, message: Message) -> None:
  84. """Event listener"""
  85. if message.author is None or \
  86. message.author.bot or \
  87. message.channel is None or \
  88. message.guild is None or \
  89. message.content is None or \
  90. message.content == '':
  91. return
  92. if message.channel.permissions_for(message.author).ban_members:
  93. # Ignore mods
  94. return
  95. patterns = self.__get_patterns(message.guild)
  96. for statement in sorted(patterns.values(), key=lambda s : s.priority, reverse=True):
  97. other_fields = {
  98. 'last_matched': self.__get_last_matched(message.guild, statement.name),
  99. }
  100. if statement.expression.matches(message, other_fields):
  101. self.__set_last_matched(message.guild, statement.name, message.created_at)
  102. await self.__trigger_actions(message, statement)
  103. break
  104. async def __trigger_actions(self,
  105. message: Message,
  106. statement: PatternStatement) -> None:
  107. context = PatternContext(message, statement)
  108. should_post_message = False
  109. message_type: int = BotMessage.TYPE_DEFAULT
  110. action_descriptions = []
  111. self.log(message.guild, f'Message from {message.author.name} matched ' + \
  112. f'pattern "{statement.name}"')
  113. for action in statement.actions:
  114. if action.action == 'ban':
  115. await message.author.ban(
  116. reason='Rocketbot: Message matched custom pattern named ' + \
  117. f'"{statement.name}"',
  118. delete_message_days=0)
  119. context.is_banned = True
  120. context.is_kicked = True
  121. action_descriptions.append('Author banned')
  122. self.log(message.guild, f'{message.author.name} banned')
  123. elif action.action == 'delete':
  124. await message.delete()
  125. context.is_deleted = True
  126. action_descriptions.append('Message deleted')
  127. self.log(message.guild, f'{message.author.name}\'s message deleted')
  128. elif action.action == 'kick':
  129. await message.author.kick(
  130. reason='Rocketbot: Message matched custom pattern named ' + \
  131. f'"{statement.name}"')
  132. context.is_kicked = True
  133. action_descriptions.append('Author kicked')
  134. self.log(message.guild, f'{message.author.name} kicked')
  135. elif action.action == 'modinfo':
  136. should_post_message = True
  137. message_type = BotMessage.TYPE_INFO
  138. action_descriptions.append('Message logged')
  139. elif action.action == 'modwarn':
  140. should_post_message = not self.was_warned_recently(message.author)
  141. message_type = BotMessage.TYPE_MOD_WARNING
  142. action_descriptions.append('Mods alerted')
  143. elif action.action == 'reply':
  144. await message.reply(
  145. f'{action.arguments[0]}',
  146. mention_author=False)
  147. action_descriptions.append('Autoreplied')
  148. self.log(message.guild, f'autoreplied to {message.author.name}')
  149. if should_post_message:
  150. bm = BotMessage(
  151. message.guild,
  152. f'User {message.author.name} tripped custom pattern ' + \
  153. f'`{statement.name}` at {message.jump_url}.\n\n' + \
  154. 'Automatic actions taken:\n• ' + ('\n• '.join(action_descriptions)),
  155. type=message_type,
  156. context=context)
  157. self.record_warning(message.author)
  158. bm.quote = discordutils.remove_markdown(message.clean_content)
  159. await bm.set_reactions(BotMessageReaction.standard_set(
  160. did_delete=context.is_deleted,
  161. did_kick=context.is_kicked,
  162. did_ban=context.is_banned))
  163. await self.post_message(bm)
  164. async def on_mod_react(self,
  165. bot_message: BotMessage,
  166. reaction: BotMessageReaction,
  167. reacted_by: Member) -> None:
  168. context: PatternContext = bot_message.context
  169. if reaction.emoji == CONFIG['trash_emoji']:
  170. await context.message.delete()
  171. context.is_deleted = True
  172. elif reaction.emoji == CONFIG['kick_emoji']:
  173. await context.message.author.kick(
  174. reason='Rocketbot: Message matched custom pattern named ' + \
  175. f'"{context.statement.name}". Kicked by {reacted_by.name}.')
  176. context.is_kicked = True
  177. elif reaction.emoji == CONFIG['ban_emoji']:
  178. await context.message.author.ban(
  179. reason='Rocketbot: Message matched custom pattern named ' + \
  180. f'"{context.statement.name}". Banned by {reacted_by.name}.',
  181. delete_message_days=1)
  182. context.is_banned = True
  183. await bot_message.set_reactions(BotMessageReaction.standard_set(
  184. did_delete=context.is_deleted,
  185. did_kick=context.is_kicked,
  186. did_ban=context.is_banned))
  187. spattern = Group(
  188. name='pattern',
  189. description='Manages message pattern matching.',
  190. guild_only=True,
  191. default_permissions=Permissions(Permissions.manage_messages.flag),
  192. )
  193. @spattern.command()
  194. @rename(expression='if')
  195. async def test(
  196. self,
  197. interaction: Interaction,
  198. actions: str,
  199. expression: str
  200. ) -> None:
  201. vals = [ actions, expression ]
  202. arg_list = ''
  203. for arg in vals:
  204. if arg is not None:
  205. arg_list += f'- "`{arg}`"\n'
  206. await interaction.response.send_message(
  207. "Got /pattern test call with arguments\n" + arg_list,
  208. ephemeral=True,
  209. )
  210. @commands.group(
  211. brief='Manages message pattern matching',
  212. )
  213. @commands.has_permissions(ban_members=True)
  214. @commands.guild_only()
  215. async def pattern(self, context: commands.Context):
  216. """Message pattern matching command group"""
  217. if context.invoked_subcommand is None:
  218. await context.send_help()
  219. @pattern.command(
  220. brief='Adds a custom pattern',
  221. description='Adds a custom pattern. Patterns use a simplified ' + \
  222. 'expression language. Full documentation found here: ' + \
  223. 'https://git.rixafrix.com/ialbert/python-app-rocketbot/src/' + \
  224. 'branch/main/docs/patterns.md',
  225. usage='<pattern_name> <expression...>',
  226. ignore_extra=True
  227. )
  228. async def add(self, context: commands.Context, name: str):
  229. """Command handler"""
  230. pattern_str = PatternCompiler.expression_str_from_context(context, name)
  231. try:
  232. statement = PatternCompiler.parse_statement(name, pattern_str)
  233. statement.check_deprecations()
  234. patterns = self.__get_patterns(context.guild)
  235. patterns[name] = statement
  236. self.__save_patterns(context.guild, patterns)
  237. await context.message.reply(
  238. f'{CONFIG["success_emoji"]} Pattern `{name}` added.',
  239. mention_author=False)
  240. except PatternError as e:
  241. await context.message.reply(
  242. f'{CONFIG["failure_emoji"]} Error parsing statement. {e}',
  243. mention_author=False)
  244. @pattern.command(
  245. brief='Removes a custom pattern',
  246. usage='<pattern_name>'
  247. )
  248. async def remove(self, context: commands.Context, name: str):
  249. """Command handler"""
  250. patterns = self.__get_patterns(context.guild)
  251. if patterns.get(name) is not None:
  252. del patterns[name]
  253. self.__save_patterns(context.guild, patterns)
  254. await context.message.reply(
  255. f'{CONFIG["success_emoji"]} Pattern `{name}` deleted.',
  256. mention_author=False)
  257. else:
  258. await context.message.reply(
  259. f'{CONFIG["failure_emoji"]} No pattern named `{name}`.',
  260. mention_author=False)
  261. @pattern.command(
  262. brief='Lists all patterns'
  263. )
  264. async def list(self, context: commands.Context) -> None:
  265. """Command handler"""
  266. patterns = self.__get_patterns(context.guild)
  267. if len(patterns) == 0:
  268. await context.message.reply('No patterns defined.', mention_author=False)
  269. return
  270. msg = ''
  271. for name, statement in sorted(patterns.items()):
  272. msg += f'Pattern `{name}` (priority={statement.priority}):\n```\n{statement.original}\n```\n'
  273. await context.message.reply(msg, mention_author=False)
  274. @pattern.command(
  275. brief='Sets a pattern\'s priority level',
  276. description='Sets the priority for a pattern. Messages are checked ' +
  277. 'against patterns with the highest priority first. Patterns with ' +
  278. 'the same priority may be checked in arbitrary order. Default ' +
  279. 'priority is 100.',
  280. )
  281. async def setpriority(self, context: commands.Context, name: str, priority: int) -> None:
  282. """Command handler"""
  283. patterns = self.__get_patterns(context.guild)
  284. statement = patterns.get(name)
  285. if statement is None:
  286. await context.message.reply(
  287. f'{CONFIG["failure_emoji"]} No such pattern `{name}`',
  288. mention_author=False)
  289. return
  290. statement.priority = priority
  291. self.__save_patterns(context.guild, patterns)
  292. await context.message.reply(
  293. f'{CONFIG["success_emoji"]} Priority for pattern `{name}` ' + \
  294. f'updated to `{priority}`.',
  295. mention_author=False)