Experimental Discord bot written in Python
Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

patterncog.py 22KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. import re
  2. from abc import ABC, abstractmethod
  3. from discord import Guild, Member, Message
  4. from discord.ext import commands
  5. from cogs.basecog import BaseCog, BotMessage, BotMessageReaction
  6. from config import CONFIG
  7. from rbutils import parse_timedelta
  8. from storage import Storage
  9. class PatternAction:
  10. """
  11. Describes one action to take on a matched message or its author.
  12. """
  13. def __init__(self, action: str, args: list):
  14. self.action = action
  15. self.arguments = list(args)
  16. def __str__(self) -> str:
  17. arg_str = ', '.join(self.arguments)
  18. return f'{self.action}({arg_str})'
  19. class PatternExpression(ABC):
  20. """
  21. Abstract message matching expression.
  22. """
  23. def __init__(self):
  24. pass
  25. @abstractmethod
  26. def matches(self, message: Message) -> bool:
  27. """
  28. Whether a message matches this expression.
  29. """
  30. return False
  31. class PatternSimpleExpression(PatternExpression):
  32. """
  33. Message matching expression with a simple "<field> <operator> <value>"
  34. structure.
  35. """
  36. def __init__(self, field: str, operator: str, value):
  37. super().__init__()
  38. self.field = field
  39. self.operator = operator
  40. self.value = value
  41. def __field_value(self, message: Message):
  42. if self.field == 'content':
  43. return message.content
  44. if self.field == 'author':
  45. return str(message.author.id)
  46. if self.field == 'author.id':
  47. return str(message.author.id)
  48. if self.field == 'author.joinage':
  49. return message.created_at - message.author.joined_at
  50. if self.field == 'author.name':
  51. return message.author.name
  52. else:
  53. raise ValueError(f'Bad field name {self.field}')
  54. def matches(self, message: Message) -> bool:
  55. field_value = self.__field_value(message)
  56. if self.operator == '==':
  57. if isinstance(field_value, str) and isinstance(self.value, str):
  58. return field_value.lower() == self.value.lower()
  59. return field_value == self.value
  60. if self.operator == '!=':
  61. if isinstance(field_value, str) and isinstance(self.value, str):
  62. return field_value.lower() != self.value.lower()
  63. return field_value != self.value
  64. if self.operator == '<':
  65. return field_value < self.value
  66. if self.operator == '>':
  67. return field_value > self.value
  68. if self.operator == '<=':
  69. return field_value <= self.value
  70. if self.operator == '>=':
  71. return field_value >= self.value
  72. if self.operator == 'contains':
  73. return self.value.lower() in field_value.lower()
  74. if self.operator == '!contains':
  75. return self.value.lower() not in field_value.lower()
  76. if self.operator == 'matches':
  77. p = re.compile(self.value.lower())
  78. return p.match(field_value.lower()) is not None
  79. if self.operator == '!matches':
  80. p = re.compile(self.value.lower())
  81. return p.match(field_value.lower()) is None
  82. raise ValueError(f'Bad operator {self.operator}')
  83. def __str__(self) -> str:
  84. return f'({self.field} {self.operator} {self.value})'
  85. class PatternCompoundExpression(PatternExpression):
  86. """
  87. Message matching expression that combines several child expressions with
  88. a boolean operator.
  89. """
  90. def __init__(self, operator: str, operands: list):
  91. super().__init__()
  92. self.operator = operator
  93. self.operands = list(operands)
  94. def matches(self, message: Message) -> bool:
  95. if self.operator == '!':
  96. return not self.operands[0].matches(message)
  97. if self.operator == 'and':
  98. for op in self.operands:
  99. if not op.matches(message):
  100. return False
  101. return True
  102. if self.operator == 'or':
  103. for op in self.operands:
  104. if op.matches(message):
  105. return True
  106. return False
  107. raise RuntimeError(f'Bad operator "{self.operator}"')
  108. def __str__(self) -> str:
  109. if self.operator == '!':
  110. return f'(!( {self.operands[0]} ))'
  111. strs = map(str, self.operands)
  112. joined = f' {self.operator} '.join(strs)
  113. return f'( {joined} )'
  114. class PatternStatement:
  115. """
  116. A full message match statement. If a message matches the given expression,
  117. the given actions should be performed.
  118. """
  119. def __init__(self, name: str, actions: list, expression: PatternExpression, original: str):
  120. self.name = name
  121. self.actions = list(actions) # PatternAction[]
  122. self.expression = expression
  123. self.original = original
  124. class PatternContext:
  125. """
  126. Data about a message that has matched a configured statement and what
  127. actions have been carried out.
  128. """
  129. def __init__(self, message: Message, statement: PatternStatement):
  130. self.message = message
  131. self.statement = statement
  132. self.is_deleted = False
  133. self.is_kicked = False
  134. self.is_banned = False
  135. class PatternCog(BaseCog, name='Pattern Matching'):
  136. """
  137. Highly flexible cog for performing various actions on messages that match
  138. various critera. Patterns can be defined by mods for each guild.
  139. """
  140. def __get_patterns(self, guild: Guild) -> dict:
  141. patterns = Storage.get_state_value(guild, 'PatternCog.patterns')
  142. if patterns is None:
  143. patterns = {}
  144. patterns_encoded = Storage.get_config_value(guild, 'PatternCog.patterns')
  145. if patterns_encoded:
  146. for pe in patterns_encoded:
  147. name = pe.get('name')
  148. statement = pe.get('statement')
  149. try:
  150. ps = PatternCompiler.parse_statement(name, statement)
  151. patterns[name] = ps
  152. except Exception as e:
  153. self.log(guild, f'Error parsing saved statement "{name}". Skipping: {statement}. Error: {e}')
  154. Storage.set_state_value(guild, 'PatternCog.patterns', patterns)
  155. return patterns
  156. @classmethod
  157. def __save_patterns(cls, guild: Guild, patterns: dict) -> None:
  158. to_save = []
  159. for name, statement in patterns.items():
  160. to_save.append({
  161. 'name': name,
  162. 'statement': statement.original,
  163. })
  164. Storage.set_config_value(guild, 'PatternCog.patterns', to_save)
  165. @commands.Cog.listener()
  166. async def on_message(self, message: Message) -> None:
  167. 'Event listener'
  168. if message.author is None or \
  169. message.author.bot or \
  170. message.channel is None or \
  171. message.guild is None or \
  172. message.content is None or \
  173. message.content == '':
  174. return
  175. if message.author.permissions_in(message.channel).ban_members:
  176. # Ignore mods
  177. return
  178. patterns = self.__get_patterns(message.guild)
  179. for _, statement in patterns.items():
  180. if statement.expression.matches(message):
  181. await self.__trigger_actions(message, statement)
  182. break
  183. async def __trigger_actions(self, message: Message, statement: PatternStatement) -> None:
  184. context = PatternContext(message, statement)
  185. should_alert_mods = False
  186. action_descriptions = []
  187. self.log(message.guild, f'Message from {message.author.name} matched pattern "{statement.name}"')
  188. for action in statement.actions:
  189. if action.action == 'ban':
  190. await message.author.ban(
  191. reason=f'Rocketbot: Message matched custom pattern named "{statement.name}"',
  192. delete_message_days=0)
  193. context.is_banned = True
  194. context.is_kicked = True
  195. action_descriptions.append('Author banned')
  196. self.log(message.guild, f'{message.author.name} banned')
  197. elif action.action == 'delete':
  198. await message.delete()
  199. context.is_deleted = True
  200. action_descriptions.append('Message deleted')
  201. self.log(message.guild, f'{message.author.name}\'s message deleted')
  202. elif action.action == 'kick':
  203. await message.author.kick(
  204. reason=f'Rocketbot: Message matched custom pattern named "{statement.name}"')
  205. context.is_kicked = True
  206. action_descriptions.append('Author kicked')
  207. self.log(message.guild, f'{message.author.name} kicked')
  208. elif action.action == 'modwarn':
  209. should_alert_mods = True
  210. action_descriptions.append('Mods alerted')
  211. elif action.action == 'reply':
  212. await message.reply(
  213. f'{action.arguments[0]}',
  214. mention_author=False)
  215. action_descriptions.append('Autoreplied')
  216. self.log(message.guild, f'{message.author.name} autoreplied to')
  217. bm = BotMessage(
  218. message.guild,
  219. f'User {message.author.name} tripped custom pattern ' + \
  220. f'`{statement.name}`.\n\nAutomatic actions taken:\n• ' + \
  221. ('\n• '.join(action_descriptions)),
  222. type=BotMessage.TYPE_MOD_WARNING if should_alert_mods else BotMessage.TYPE_INFO,
  223. context=context)
  224. bm.quote = message.content
  225. await bm.set_reactions(BotMessageReaction.standard_set(
  226. did_delete=context.is_deleted,
  227. did_kick=context.is_kicked,
  228. did_ban=context.is_banned))
  229. await self.post_message(bm)
  230. async def on_mod_react(self,
  231. bot_message: BotMessage,
  232. reaction: BotMessageReaction,
  233. reacted_by: Member) -> None:
  234. context: PatternContext = bot_message.context
  235. if reaction.emoji == CONFIG['trash_emoji']:
  236. await context.message.delete()
  237. context.is_deleted = True
  238. elif reaction.emoji == CONFIG['kick_emoji']:
  239. await context.message.author.kick(
  240. reason='Rocketbot: Message matched custom pattern named ' + \
  241. f'"{context.statement.name}". Kicked by {reacted_by.name}.')
  242. context.is_kicked = True
  243. elif reaction.emoji == CONFIG['ban_emoji']:
  244. await context.message.author.ban(
  245. reason='Rocketbot: Message matched custom pattern named ' + \
  246. f'"{context.statement.name}". Banned by {reacted_by.name}.',
  247. delete_message_days=1)
  248. context.is_banned = True
  249. await bot_message.set_reactions(BotMessageReaction.standard_set(
  250. did_delete=context.is_deleted,
  251. did_kick=context.is_kicked,
  252. did_ban=context.is_banned))
  253. @commands.group(
  254. brief='Manages message pattern matching',
  255. )
  256. @commands.has_permissions(ban_members=True)
  257. @commands.guild_only()
  258. async def pattern(self, context: commands.Context):
  259. 'Message pattern matching command group'
  260. if context.invoked_subcommand is None:
  261. await context.send_help()
  262. @pattern.command(
  263. brief='Adds a custom pattern',
  264. description='Adds a custom pattern. Patterns use a simplified ' + \
  265. 'expression language. Full documentation found here: ' + \
  266. 'https://git.rixafrix.com/ialbert/python-app-rocketbot/src/branch/master/patterns.md',
  267. usage='<pattern_name> <expression...>',
  268. ignore_extra=True
  269. )
  270. async def add(self, context: commands.Context, name: str):
  271. 'Command handler'
  272. pattern_str = PatternCompiler.expression_str_from_context(context, name)
  273. try:
  274. statement = PatternCompiler.parse_statement(name, pattern_str)
  275. patterns = self.__get_patterns(context.guild)
  276. patterns[name] = statement
  277. self.__save_patterns(context.guild, patterns)
  278. await context.message.reply(
  279. f'{CONFIG["success_emoji"]} Pattern `{name}` added.',
  280. mention_author=False)
  281. except Exception as e:
  282. await context.message.reply(
  283. f'{CONFIG["failure_emoji"]} Error parsing statement. {e}',
  284. mention_author=False)
  285. @pattern.command(
  286. brief='Removes a custom pattern',
  287. usage='<pattern_name>'
  288. )
  289. async def remove(self, context: commands.Context, name: str):
  290. 'Command handler'
  291. patterns = self.__get_patterns(context.guild)
  292. if patterns.get(name) is not None:
  293. del patterns[name]
  294. self.__save_patterns(context.guild, patterns)
  295. await context.message.reply(
  296. f'{CONFIG["success_emoji"]} Pattern `{name}` deleted.',
  297. mention_author=False)
  298. else:
  299. await context.message.reply(
  300. f'{CONFIG["failure_emoji"]} No pattern named `{name}`.',
  301. mention_author=False)
  302. @pattern.command(
  303. brief='Lists all patterns'
  304. )
  305. async def list(self, context: commands.Context) -> None:
  306. 'Command handler'
  307. patterns = self.__get_patterns(context.guild)
  308. if len(patterns) == 0:
  309. await context.message.reply('No patterns defined.', mention_author=False)
  310. return
  311. msg = ''
  312. for name, statement in sorted(patterns.items()):
  313. msg += f'Pattern `{name}`:\n```\n{statement.original}\n```\n'
  314. await context.message.reply(msg, mention_author=False)
  315. class PatternCompiler:
  316. """
  317. Parses a user-provided message filter statement into a PatternStatement.
  318. """
  319. TYPE_ID = 'id'
  320. TYPE_MEMBER = 'Member'
  321. TYPE_TEXT = 'text'
  322. TYPE_INT = 'int'
  323. TYPE_FLOAT = 'float'
  324. TYPE_TIMESPAN = 'timespan'
  325. FIELD_TO_TYPE = {
  326. 'content': TYPE_TEXT,
  327. 'author': TYPE_MEMBER,
  328. 'author.id': TYPE_ID,
  329. 'author.name': TYPE_TEXT,
  330. 'author.joinage': TYPE_TIMESPAN,
  331. }
  332. ACTION_TO_ARGS = {
  333. 'ban': [],
  334. 'delete': [],
  335. 'kick': [],
  336. 'modwarn': [],
  337. 'reply': [ TYPE_TEXT ],
  338. }
  339. OPERATORS_IDENTITY = set([ '==', '!=' ])
  340. OPERATORS_COMPARISON = set([ '<', '>', '<=', '>=' ])
  341. OPERATORS_NUMERIC = OPERATORS_IDENTITY | OPERATORS_COMPARISON
  342. OPERATORS_TEXT = OPERATORS_IDENTITY | set([ 'contains', '!contains', 'matches', '!matches' ])
  343. OPERATORS_ALL = OPERATORS_IDENTITY | OPERATORS_COMPARISON | OPERATORS_TEXT
  344. TYPE_TO_OPERATORS = {
  345. TYPE_ID: OPERATORS_IDENTITY,
  346. TYPE_MEMBER: OPERATORS_IDENTITY,
  347. TYPE_TEXT: OPERATORS_TEXT,
  348. TYPE_INT: OPERATORS_NUMERIC,
  349. TYPE_FLOAT: OPERATORS_NUMERIC,
  350. TYPE_TIMESPAN: OPERATORS_NUMERIC,
  351. }
  352. WHITESPACE_CHARS = ' \t\n\r'
  353. STRING_QUOTE_CHARS = '\'"'
  354. SYMBOL_CHARS = 'abcdefghijklmnopqrstuvwxyz.'
  355. VALUE_CHARS = '0123456789dhms<@!>'
  356. OP_CHARS = '<=>!(),'
  357. @classmethod
  358. def expression_str_from_context(cls, context: commands.Context, name: str) -> str:
  359. """
  360. Extracts the statement string from an "add" command context.
  361. """
  362. pattern_str = context.message.content
  363. command_chain = [ name ]
  364. cmd = context.command
  365. while cmd:
  366. command_chain.insert(0, cmd.name)
  367. cmd = cmd.parent
  368. command_chain[0] = f'{context.prefix}{command_chain[0]}'
  369. for cmd in command_chain:
  370. if pattern_str.startswith(cmd):
  371. pattern_str = pattern_str[len(cmd):].lstrip()
  372. elif pattern_str.startswith(f'"{cmd}"'):
  373. pattern_str = pattern_str[len(cmd) + 2:].lstrip()
  374. return pattern_str
  375. @classmethod
  376. def parse_statement(cls, name: str, statement: str) -> PatternStatement:
  377. """
  378. Parses a user-provided message filter statement into a PatternStatement.
  379. """
  380. tokens = cls.tokenize(statement)
  381. token_index = 0
  382. actions, token_index = cls.read_actions(tokens, token_index)
  383. expression, token_index = cls.read_expression(tokens, token_index)
  384. return PatternStatement(name, actions, expression, statement)
  385. @classmethod
  386. def tokenize(cls, statement: str) -> list:
  387. """
  388. Converts a message filter statement into a list of tokens.
  389. """
  390. tokens = []
  391. in_quote = False
  392. in_escape = False
  393. all_token_types = set([ 'sym', 'op', 'val' ])
  394. possible_token_types = set(all_token_types)
  395. current_token = ''
  396. for ch in statement:
  397. if in_quote:
  398. if in_escape:
  399. if ch == 'n':
  400. current_token += '\n'
  401. elif ch == 't':
  402. current_token += '\t'
  403. else:
  404. current_token += ch
  405. in_escape = False
  406. elif ch == '\\':
  407. in_escape = True
  408. elif ch == in_quote:
  409. current_token += ch
  410. tokens.append(current_token)
  411. current_token = ''
  412. possible_token_types |= all_token_types
  413. in_quote = False
  414. else:
  415. current_token += ch
  416. else:
  417. if ch in cls.STRING_QUOTE_CHARS:
  418. if len(current_token) > 0:
  419. tokens.append(current_token)
  420. current_token = ''
  421. possible_token_types |= all_token_types
  422. in_quote = ch
  423. current_token = ch
  424. elif ch == '\\':
  425. raise RuntimeError("Unexpected \\")
  426. elif ch in cls.WHITESPACE_CHARS:
  427. if len(current_token) > 0:
  428. tokens.append(current_token)
  429. current_token = ''
  430. possible_token_types |= all_token_types
  431. else:
  432. possible_ch_types = set()
  433. if ch in cls.SYMBOL_CHARS:
  434. possible_ch_types.add('sym')
  435. if ch in cls.VALUE_CHARS:
  436. possible_ch_types.add('val')
  437. if ch in cls.OP_CHARS:
  438. possible_ch_types.add('op')
  439. if len(current_token) > 0 and possible_ch_types.isdisjoint(possible_token_types):
  440. if len(current_token) > 0:
  441. tokens.append(current_token)
  442. current_token = ''
  443. possible_token_types |= all_token_types
  444. possible_token_types &= possible_ch_types
  445. current_token += ch
  446. if len(current_token) > 0:
  447. tokens.append(current_token)
  448. # Some symbols might be glommed onto other tokens. Split 'em up.
  449. prefixes_to_split = [ '!', '(', ',' ]
  450. suffixes_to_split = [ ')', ',' ]
  451. i = 0
  452. while i < len(tokens):
  453. token = tokens[i]
  454. mutated = False
  455. for prefix in prefixes_to_split:
  456. if token.startswith(prefix) and len(token) > len(prefix):
  457. tokens.insert(i, prefix)
  458. tokens[i + 1] = token[len(prefix):]
  459. i += 1
  460. mutated = True
  461. break
  462. if mutated:
  463. continue
  464. for suffix in suffixes_to_split:
  465. if token.endswith(suffix) and len(token) > len(suffix):
  466. tokens[i] = token[0:-len(suffix)]
  467. tokens.insert(i + 1, suffix)
  468. mutated = True
  469. break
  470. if mutated:
  471. continue
  472. i += 1
  473. return tokens
  474. @classmethod
  475. def read_actions(cls, tokens: list, token_index: int) -> tuple:
  476. """
  477. Reads the actions from a list of statement tokens. Returns a tuple
  478. containing a list of PatternActions and the token index this method
  479. left off at (the token after the "if").
  480. """
  481. actions = []
  482. current_action_tokens = []
  483. while token_index < len(tokens):
  484. token = tokens[token_index]
  485. if token == 'if':
  486. if len(current_action_tokens) > 0:
  487. a = PatternAction(current_action_tokens[0], current_action_tokens[1:])
  488. cls.__validate_action(a)
  489. actions.append(a)
  490. token_index += 1
  491. return (actions, token_index)
  492. elif token == ',':
  493. if len(current_action_tokens) < 1:
  494. raise RuntimeError('Unexpected ,')
  495. a = PatternAction(current_action_tokens[0], current_action_tokens[1:])
  496. cls.__validate_action(a)
  497. actions.append(a)
  498. current_action_tokens = []
  499. else:
  500. current_action_tokens.append(token)
  501. token_index += 1
  502. raise RuntimeError('Unexpected end of line')
  503. @classmethod
  504. def __validate_action(cls, action: PatternAction) -> None:
  505. args = cls.ACTION_TO_ARGS.get(action.action)
  506. if args is None:
  507. raise RuntimeError(f'Unknown action "{action.action}"')
  508. if len(action.arguments) != len(args):
  509. if len(args) == 0:
  510. raise RuntimeError(f'Action "{action.action}" expects no arguments, ' + \
  511. f'got {len(action.arguments)}.')
  512. else:
  513. raise RuntimeError(f'Action "{action.action}" expects {len(args)} ' + \
  514. f'arguments, got {len(action.arguments)}.')
  515. for i, datatype in enumerate(args):
  516. action.arguments[i] = cls.parse_value(action.arguments[i], datatype)
  517. @classmethod
  518. def read_expression(cls,
  519. tokens: list,
  520. token_index: int,
  521. depth: int = 0,
  522. one_subexpression: bool = False) -> tuple:
  523. """
  524. Reads an expression from a list of statement tokens. Returns a tuple
  525. containing the PatternExpression and the token index it left off at.
  526. If one_subexpression is True then it will return after reading a
  527. single expression instead of joining multiples (for readong the
  528. subject of a NOT expression).
  529. """
  530. subexpressions = []
  531. last_compound_operator = None
  532. while token_index < len(tokens):
  533. if one_subexpression:
  534. if len(subexpressions) == 1:
  535. return (subexpressions[0], token_index)
  536. if len(subexpressions) > 1:
  537. raise RuntimeError('Too many subexpressions')
  538. compound_operator = None
  539. if tokens[token_index] == ')':
  540. if len(subexpressions) == 0:
  541. raise RuntimeError('No subexpressions')
  542. if len(subexpressions) == 1:
  543. return (subexpressions[0], token_index)
  544. return (PatternCompoundExpression(last_compound_operator, subexpressions), token_index)
  545. if tokens[token_index] in set(["and", "or"]):
  546. compound_operator = tokens[token_index]
  547. if last_compound_operator and compound_operator != last_compound_operator:
  548. subexpressions = [ PatternCompoundExpression(last_compound_operator, subexpressions) ]
  549. last_compound_operator = compound_operator
  550. else:
  551. last_compound_operator = compound_operator
  552. token_index += 1
  553. if tokens[token_index] == '!':
  554. (exp, next_index) = cls.read_expression(tokens, token_index + 1, \
  555. depth + 1, one_subexpression=True)
  556. subexpressions.append(PatternCompoundExpression('!', [exp]))
  557. token_index = next_index
  558. elif tokens[token_index] == '(':
  559. (exp, next_index) = cls.read_expression(tokens, token_index + 1, depth + 1)
  560. if tokens[next_index] != ')':
  561. raise RuntimeError('Expected )')
  562. subexpressions.append(exp)
  563. token_index = next_index + 1
  564. else:
  565. (simple, next_index) = cls.read_simple_expression(tokens, token_index, depth)
  566. subexpressions.append(simple)
  567. token_index = next_index
  568. if len(subexpressions) == 0:
  569. raise RuntimeError('No subexpressions')
  570. elif len(subexpressions) == 1:
  571. return (subexpressions[0], token_index)
  572. else:
  573. return (PatternCompoundExpression(last_compound_operator, subexpressions), token_index)
  574. @classmethod
  575. def read_simple_expression(cls, tokens: list, token_index: int, depth: int = 0) -> tuple:
  576. """
  577. Reads a simple expression consisting of a field name, operator, and
  578. comparison value. Returns a tuple of the PatternSimpleExpression and
  579. the token index it left off at.
  580. """
  581. if depth > 8:
  582. raise RuntimeError('Expression nests too deeply')
  583. if token_index >= len(tokens):
  584. raise RuntimeError('Expected field name, found EOL')
  585. field = tokens[token_index]
  586. token_index += 1
  587. datatype = cls.FIELD_TO_TYPE.get(field)
  588. if datatype is None:
  589. raise RuntimeError(f'No such field "{field}"')
  590. if token_index >= len(tokens):
  591. raise RuntimeError('Expected operator, found EOL')
  592. op = tokens[token_index]
  593. token_index += 1
  594. if op == '!':
  595. if token_index >= len(tokens):
  596. raise RuntimeError('Expected operator, found EOL')
  597. op = '!' + tokens[token_index]
  598. token_index += 1
  599. allowed_ops = cls.TYPE_TO_OPERATORS[datatype]
  600. if op not in allowed_ops:
  601. if op in cls.OPERATORS_ALL:
  602. raise RuntimeError(f'Operator {op} cannot be used with field "{field}"')
  603. else:
  604. raise RuntimeError(f'Unrecognized operator "{op}" - allowed: {list(allowed_ops)}')
  605. if token_index >= len(tokens):
  606. raise RuntimeError('Expected value, found EOL')
  607. value = tokens[token_index]
  608. value = cls.parse_value(value, datatype)
  609. token_index += 1
  610. exp = PatternSimpleExpression(field, op, value)
  611. return (exp, token_index)
  612. @classmethod
  613. def parse_value(cls, value: str, datatype: str):
  614. """
  615. Converts a value token to its Python value.
  616. """
  617. if datatype == cls.TYPE_ID:
  618. p = re.compile('^[0-9]+$')
  619. if p.match(value) is None:
  620. raise ValueError(f'Illegal id value "{value}"')
  621. # Store it as a str so it can be larger than an int
  622. return value
  623. if datatype == cls.TYPE_MEMBER:
  624. p = re.compile('^<@!?([0-9]+)>$')
  625. m = p.match(value)
  626. if m is None:
  627. raise ValueError('Illegal member value. Must be an @ mention.')
  628. return m.group(1)
  629. if datatype == cls.TYPE_TEXT:
  630. # Must be quoted.
  631. if len(value) < 2 or \
  632. value[0:1] not in cls.STRING_QUOTE_CHARS or \
  633. value[-1:] not in cls.STRING_QUOTE_CHARS or \
  634. value[0:1] != value[-1:]:
  635. raise ValueError(f'Not a quoted string value: {value}')
  636. return value[1:-1]
  637. if datatype == cls.TYPE_INT:
  638. return int(value)
  639. if datatype == cls.TYPE_FLOAT:
  640. return float(value)
  641. if datatype == cls.TYPE_TIMESPAN:
  642. return parse_timedelta(value)
  643. raise ValueError(f'Unhandled datatype {datatype}')