Experimental Discord bot written in Python
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

patterncog.py 14KB

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