Experimental Discord bot written in Python
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

patterncog.py 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. """
  2. Cog for matching messages against guild-configurable criteria and taking
  3. automated actions on them.
  4. """
  5. import re
  6. from datetime import datetime
  7. from typing import Optional
  8. from discord import Guild, Intents, Interaction, Member, Message
  9. from discord import utils as discordutils
  10. from discord.app_commands import Choice, Group, autocomplete
  11. from discord.errors import DiscordException
  12. from discord.ext.commands import Cog
  13. from config import CONFIG
  14. from rocketbot.bot import Rocketbot
  15. from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction
  16. from rocketbot.cogsetting import CogSetting
  17. from rocketbot.pattern import (
  18. PatternCompiler,
  19. PatternDeprecationError,
  20. PatternError,
  21. PatternStatement,
  22. )
  23. from rocketbot.storage import Storage
  24. from rocketbot.utils import MOD_PERMISSIONS, dump_stacktrace, norm_datetime
  25. class PatternContext:
  26. """
  27. Data about a message that has matched a configured statement and what
  28. actions have been carried out.
  29. """
  30. def __init__(self, message: Message, statement: PatternStatement):
  31. self.message: Message = message
  32. self.statement: PatternStatement = statement
  33. self.is_deleted: bool = False
  34. self.is_kicked: bool = False
  35. self.is_banned: bool = False
  36. async def pattern_name_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  37. choices: list[Choice[str]] = []
  38. try:
  39. if interaction.guild is None:
  40. return []
  41. patterns: dict[str, PatternStatement] = PatternCog.shared.get_patterns(interaction.guild)
  42. current_normal = current.lower().strip()
  43. for name in sorted(patterns.keys()):
  44. if len(current_normal) == 0 or current_normal.startswith(name.lower()):
  45. choices.append(Choice(name=name, value=name))
  46. except DiscordException as e:
  47. dump_stacktrace(e)
  48. return choices
  49. async def action_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  50. # FIXME: WORK IN PROGRESS
  51. print(f'autocomplete action - current = "{current}"')
  52. regex = re.compile('^(.*?)([a-zA-Z]+)$')
  53. match: re.Match[str] | None = regex.match(current)
  54. initial: str = ''
  55. stub: str = current
  56. if match:
  57. initial = match.group(1).strip()
  58. stub = match.group(2)
  59. if PatternCompiler.ACTION_TO_ARGS.get(stub, None) is not None:
  60. # Matches perfectly. Suggest another instead of completing the current.
  61. initial = current.strip() + ', '
  62. stub = ''
  63. print(f'initial = "{initial}", stub = "{stub}"')
  64. options: list[Choice[str]] = []
  65. for action in sorted(PatternCompiler.ACTION_TO_ARGS.keys()):
  66. if len(stub) == 0 or action.startswith(stub.lower()):
  67. arg_types = PatternCompiler.ACTION_TO_ARGS[action]
  68. arg_type_strs = []
  69. for arg_type in arg_types:
  70. if arg_type == PatternCompiler.TYPE_TEXT:
  71. arg_type_strs.append('"message"')
  72. else:
  73. raise ValueError(f'Argument type {arg_type} not yet supported')
  74. suffix = '' if len(arg_type_strs) == 0 else ' ' + (' '.join(arg_type_strs))
  75. options.append(Choice(name=action, value=f'{initial.strip()} {action}{suffix}'))
  76. return options
  77. async def priority_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  78. return [
  79. Choice(name='very low (50)', value=50),
  80. Choice(name='low (75)', value=75),
  81. Choice(name='normal (100)', value=100),
  82. Choice(name='high (125)', value=125),
  83. Choice(name='very high (150)', value=150),
  84. ]
  85. _long_help = \
  86. """Patterns are a powerful but complex topic. See <https://git.rixafrix.com/ialbert/python-app-rocketbot/src/branch/main/docs/patterns.md> for full documentation.
  87. ### Quick cheat sheet
  88. > `/pattern add` _pattern\\_name_ _action\\_list_ `if` _expression_
  89. - _pattern\\_name_ is a brief name for identifying the pattern later (not shown to user)
  90. - _action\\_list_ is a comma-delimited list of actions to take on matching messages and is any of:
  91. - `ban`
  92. - `delete`
  93. - `kick`
  94. - `modinfo` - logs a message but doesn't tag mods
  95. - `modwarn` - tags mods
  96. - `reply` "message text"
  97. - _expression_ determines which messages match, of the form _field_ _op_ _value_.
  98. - Fields:
  99. - `content.markdown`: string
  100. - `content.plain`: string
  101. - `author`: user
  102. - `author.id`: id
  103. - `author.joinage`: timespan
  104. - `author.name`: string
  105. - `lastmatched`: timespan
  106. - Operators: `==`, `!=`, `<`, `>`, `<=`, `>=`, `contains`, `!contains`, `matches`, `!matches`, `containsword`, `!containsword`
  107. - Can combine multiple expressions with `!`, `and`, `or`, and parentheses."""
  108. class PatternCog(BaseCog, name='Pattern Matching'):
  109. """
  110. Highly flexible cog for performing various actions on messages that match
  111. various criteria. Patterns can be defined by mods for each guild.
  112. """
  113. SETTING_PATTERNS = CogSetting('patterns', None, default_value=None)
  114. shared: Optional['PatternCog'] = None
  115. def __init__(self, bot: Rocketbot):
  116. super().__init__(
  117. bot,
  118. config_prefix='patterns',
  119. short_description='Manages message pattern matching.',
  120. long_description=_long_help
  121. )
  122. PatternCog.shared = self
  123. def get_patterns(self, guild: Guild) -> dict[str, PatternStatement]:
  124. """
  125. Returns a name -> PatternStatement lookup for the guild.
  126. """
  127. patterns: dict[str, PatternStatement] = Storage.get_state_value(guild,
  128. 'PatternCog.patterns')
  129. if patterns is None:
  130. jsons: list[dict] = self.get_guild_setting(guild, self.SETTING_PATTERNS) or []
  131. pattern_list: list[PatternStatement] = []
  132. for json in jsons:
  133. try:
  134. ps = PatternStatement.from_json(json)
  135. pattern_list.append(ps)
  136. try:
  137. ps.check_deprecations()
  138. except PatternDeprecationError as e:
  139. self.log(guild, f'Pattern {ps.name}: {e}')
  140. except PatternError as e:
  141. self.log(guild, f'Error decoding pattern "{json["name"]}": {e}')
  142. patterns = { p.name:p for p in pattern_list}
  143. Storage.set_state_value(guild, 'PatternCog.patterns', patterns)
  144. return patterns
  145. @classmethod
  146. def __save_patterns(cls,
  147. guild: Guild,
  148. patterns: dict[str, PatternStatement]) -> None:
  149. to_save: list[dict] = [ps.to_json() for ps in patterns.values()]
  150. cls.set_guild_setting(guild, cls.SETTING_PATTERNS, to_save)
  151. @classmethod
  152. def __get_last_matched(cls, guild: Guild, name: str) -> datetime | None:
  153. last_matched: dict[str, datetime] = Storage.get_state_value(guild, 'PatternCog.last_matched')
  154. if last_matched:
  155. return last_matched.get(name)
  156. return None
  157. @classmethod
  158. def __set_last_matched(cls, guild: Guild, name: str, time: datetime) -> None:
  159. last_matched: dict[str, datetime] = Storage.get_state_value(guild, 'PatternCog.last_matched')
  160. if last_matched is None:
  161. last_matched = {}
  162. Storage.set_state_value(guild, 'PatternCog.last_matched', last_matched)
  163. last_matched[name] = norm_datetime(time)
  164. @Cog.listener()
  165. async def on_message(self, message: Message) -> None:
  166. """Event listener"""
  167. if message.author is None or \
  168. message.author.bot or \
  169. message.channel is None or \
  170. message.guild is None or \
  171. message.content is None or \
  172. message.content == '':
  173. return
  174. if message.channel.permissions_for(message.author).ban_members:
  175. # Ignore mods
  176. return
  177. patterns = self.get_patterns(message.guild)
  178. for statement in sorted(patterns.values(), key=lambda s : s.priority, reverse=True):
  179. other_fields = {
  180. 'last_matched': self.__get_last_matched(message.guild, statement.name),
  181. }
  182. if statement.expression.matches(message, other_fields):
  183. self.__set_last_matched(message.guild, statement.name, message.created_at)
  184. await self.__trigger_actions(message, statement)
  185. break
  186. async def __trigger_actions(self,
  187. message: Message,
  188. statement: PatternStatement) -> None:
  189. context = PatternContext(message, statement)
  190. should_post_message = False
  191. message_type: int = BotMessage.TYPE_DEFAULT
  192. action_descriptions = []
  193. self.log(message.guild, f'Message from {message.author.name} matched ' + \
  194. f'pattern "{statement.name}"')
  195. for action in statement.actions:
  196. if action.action == 'ban':
  197. await message.author.ban(
  198. reason='Rocketbot: Message matched custom pattern named ' + \
  199. f'"{statement.name}"',
  200. delete_message_days=0)
  201. context.is_banned = True
  202. context.is_kicked = True
  203. action_descriptions.append('Author banned')
  204. self.log(message.guild, f'{message.author.name} banned')
  205. elif action.action == 'delete':
  206. await message.delete()
  207. context.is_deleted = True
  208. action_descriptions.append('Message deleted')
  209. self.log(message.guild, f'{message.author.name}\'s message deleted')
  210. elif action.action == 'kick':
  211. await message.author.kick(
  212. reason='Rocketbot: Message matched custom pattern named ' + \
  213. f'"{statement.name}"')
  214. context.is_kicked = True
  215. action_descriptions.append('Author kicked')
  216. self.log(message.guild, f'{message.author.name} kicked')
  217. elif action.action == 'modinfo':
  218. should_post_message = True
  219. message_type = BotMessage.TYPE_INFO
  220. action_descriptions.append('Message logged')
  221. elif action.action == 'modwarn':
  222. should_post_message = not self.was_warned_recently(message.author)
  223. message_type = BotMessage.TYPE_MOD_WARNING
  224. action_descriptions.append('Mods alerted')
  225. elif action.action == 'reply':
  226. await message.reply(
  227. f'{action.arguments[0]}',
  228. mention_author=False)
  229. action_descriptions.append('Autoreplied')
  230. self.log(message.guild, f'autoreplied to {message.author.name}')
  231. if should_post_message:
  232. bm = BotMessage(
  233. message.guild,
  234. f'User {message.author.name} tripped custom pattern ' + \
  235. f'`{statement.name}` at {message.jump_url}.\n\n' + \
  236. 'Automatic actions taken:\n• ' + ('\n• '.join(action_descriptions)),
  237. type=message_type,
  238. context=context)
  239. self.record_warning(message.author)
  240. bm.quote = discordutils.remove_markdown(message.clean_content)
  241. await bm.set_reactions(BotMessageReaction.standard_set(
  242. did_delete=context.is_deleted,
  243. did_kick=context.is_kicked,
  244. did_ban=context.is_banned))
  245. await self.post_message(bm)
  246. async def on_mod_react(self,
  247. bot_message: BotMessage,
  248. reaction: BotMessageReaction,
  249. reacted_by: Member) -> None:
  250. context: PatternContext = bot_message.context
  251. if reaction.emoji == CONFIG['trash_emoji']:
  252. await context.message.delete()
  253. context.is_deleted = True
  254. elif reaction.emoji == CONFIG['kick_emoji']:
  255. await context.message.author.kick(
  256. reason='Rocketbot: Message matched custom pattern named ' + \
  257. f'"{context.statement.name}". Kicked by {reacted_by.name}.')
  258. context.is_kicked = True
  259. elif reaction.emoji == CONFIG['ban_emoji']:
  260. await context.message.author.ban(
  261. reason='Rocketbot: Message matched custom pattern named ' + \
  262. f'"{context.statement.name}". Banned by {reacted_by.name}.',
  263. delete_message_days=1)
  264. context.is_banned = True
  265. await bot_message.set_reactions(BotMessageReaction.standard_set(
  266. did_delete=context.is_deleted,
  267. did_kick=context.is_kicked,
  268. did_ban=context.is_banned))
  269. pattern = Group(
  270. name='pattern',
  271. description='Manages message pattern matching.',
  272. guild_only=True,
  273. default_permissions=MOD_PERMISSIONS,
  274. extras={
  275. 'long_description': _long_help,
  276. },
  277. )
  278. @pattern.command(
  279. description='Adds or updates a custom pattern.',
  280. extras={
  281. 'long_description': _long_help,
  282. },
  283. )
  284. @autocomplete(
  285. name=pattern_name_autocomplete,
  286. # actions=action_autocomplete
  287. )
  288. async def add(
  289. self,
  290. interaction: Interaction,
  291. name: str,
  292. actions: str,
  293. expression: str
  294. ) -> None:
  295. """
  296. Adds a custom pattern.
  297. Parameters
  298. ----------
  299. interaction : Interaction
  300. name : str
  301. a name for the new or existing pattern
  302. actions : str
  303. actions to take when a message matches
  304. expression : str
  305. criteria for matching chat messages
  306. """
  307. pattern_str = f'{actions} if {expression}'
  308. guild = interaction.guild
  309. try:
  310. statement = PatternCompiler.parse_statement(name, pattern_str)
  311. statement.check_deprecations()
  312. patterns = self.get_patterns(guild)
  313. patterns[name] = statement
  314. self.__save_patterns(guild, patterns)
  315. await interaction.response.send_message(
  316. f'{CONFIG["success_emoji"]} Pattern `{name}` added.',
  317. ephemeral=True,
  318. )
  319. except PatternError as e:
  320. await interaction.response.send_message(
  321. f'{CONFIG["failure_emoji"]} Error parsing statement. {e}',
  322. ephemeral=True,
  323. )
  324. @pattern.command(
  325. description='Removes a custom pattern.',
  326. extras={
  327. 'usage': '<pattern_name>',
  328. },
  329. )
  330. @autocomplete(name=pattern_name_autocomplete)
  331. async def remove(self, interaction: Interaction, name: str):
  332. """
  333. Removes a custom pattern.
  334. Parameters
  335. ----------
  336. interaction: Interaction
  337. name: str
  338. name of the pattern to remove
  339. """
  340. guild = interaction.guild
  341. patterns = self.get_patterns(guild)
  342. if patterns.get(name) is not None:
  343. del patterns[name]
  344. self.__save_patterns(guild, patterns)
  345. await interaction.response.send_message(
  346. f'{CONFIG["success_emoji"]} Pattern `{name}` deleted.',
  347. ephemeral=True,
  348. )
  349. else:
  350. await interaction.response.send_message(
  351. f'{CONFIG["failure_emoji"]} No pattern named `{name}`.',
  352. ephemeral=True,
  353. )
  354. @pattern.command(
  355. description='Lists all patterns.',
  356. )
  357. async def list(self, interaction: Interaction) -> None:
  358. guild = interaction.guild
  359. patterns = self.get_patterns(guild)
  360. if len(patterns) == 0:
  361. await interaction.response.send_message(
  362. 'No patterns defined.',
  363. ephemeral=True,
  364. )
  365. return
  366. msg = ''
  367. for name, statement in sorted(patterns.items()):
  368. msg += f'Pattern `{name}` (priority={statement.priority}):\n```\n{statement.original}\n```\n'
  369. await interaction.response.send_message(msg, ephemeral=True)
  370. @pattern.command(
  371. description="Sets a pattern's priority level.",
  372. extras={
  373. 'long_description': 'Messages are checked against patterns with the '
  374. 'highest priority first. Patterns with the same '
  375. 'priority may be checked in arbitrary order. Default '
  376. 'priority is 100.',
  377. },
  378. )
  379. @autocomplete(name=pattern_name_autocomplete, priority=priority_autocomplete)
  380. async def setpriority(self, interaction: Interaction, name: str, priority: int) -> None:
  381. """
  382. Sets a pattern's priority level.
  383. Parameters
  384. ----------
  385. interaction: Interaction
  386. name: str
  387. the name of the pattern
  388. priority: int
  389. evaluation priority
  390. """
  391. guild = interaction.guild
  392. patterns = self.get_patterns(guild)
  393. statement = patterns.get(name)
  394. if statement is None:
  395. await interaction.response.send_message(
  396. f'{CONFIG["failure_emoji"]} No such pattern `{name}`',
  397. ephemeral=True,
  398. )
  399. return
  400. statement.priority = priority
  401. self.__save_patterns(guild, patterns)
  402. await interaction.response.send_message(
  403. f'{CONFIG["success_emoji"]} Priority for pattern `{name}` ' + \
  404. f'updated to `{priority}`.',
  405. ephemeral=True,
  406. )
  407. @classmethod
  408. def supports_intents(cls, intents: Intents) -> bool:
  409. return intents.message_content