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.

pattern.py 22KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. """
  2. Statements that match messages based on an expression and have a list of actions
  3. to take on them.
  4. """
  5. import re
  6. from abc import ABCMeta, abstractmethod
  7. from datetime import datetime, timezone
  8. from typing import Any, ClassVar, Literal
  9. from discord import Message
  10. from discord import utils as discordutils
  11. from discord.ext.commands import Context
  12. from rocketbot.utils import (
  13. is_user_id,
  14. norm_datetime,
  15. str_from_quoted_str,
  16. timedelta_from_str,
  17. user_id_from_mention,
  18. )
  19. PatternField = Literal['content.markdown', 'content', 'content.plain', 'author', 'author.id', 'author.joinage', 'author.name', 'lastmatched']
  20. PatternComparisonOperator = Literal['==', '!=', '<', '>', '<=', '>=', 'contains', '!contains', 'matches', '!matches', 'containsword', '!containsword']
  21. PatternBooleanOperator = Literal['!', 'and', 'or']
  22. PatternActionType = Literal['ban', 'delete', 'kick', 'modinfo', 'modwarn', 'reply']
  23. class PatternError(RuntimeError):
  24. """
  25. Error thrown when parsing a pattern statement.
  26. """
  27. class PatternDeprecationError(PatternError):
  28. """
  29. Error raised by PatternStatement.check_deprecated_syntax.
  30. """
  31. class PatternAction:
  32. """
  33. Describes one action to take on a matched message or its author.
  34. """
  35. TYPE_BAN: PatternActionType = 'ban'
  36. TYPE_DELETE: PatternActionType = 'delete'
  37. TYPE_KICK: PatternActionType = 'kick'
  38. TYPE_INFORM_MODS: PatternActionType = 'modinfo'
  39. TYPE_WARN_MODS: PatternActionType = 'modwarn'
  40. TYPE_REPLY: PatternActionType = 'reply'
  41. def __init__(self, action: str, args: list[Any]):
  42. self.action = action
  43. self.arguments = list(args)
  44. def __str__(self) -> str:
  45. arg_str = ', '.join(self.arguments)
  46. return f'{self.action}({arg_str})'
  47. class PatternExpression(metaclass=ABCMeta):
  48. """
  49. Abstract message matching expression.
  50. """
  51. def __init__(self):
  52. pass
  53. @abstractmethod
  54. def matches(self, message: Message, other_fields: dict[str, Any]) -> bool:
  55. """
  56. Whether a message matches this expression. other_fields are additional
  57. fields that can be queried not contained in the message itself.
  58. """
  59. return False
  60. class PatternSimpleExpression(PatternExpression):
  61. """
  62. Message matching expression with a simple "<field> <operator> <value>"
  63. structure.
  64. """
  65. FIELD_CONTENT_MARKDOWN: PatternField = 'content.markdown'
  66. FIELD_CONTENT_PLAIN: PatternField = 'content.plain'
  67. FIELD_AUTHOR_ID: PatternField = 'author.id'
  68. FIELD_AUTHOR_JOINAGE: PatternField = 'author.joinage'
  69. FIELD_AUTHOR_NAME: PatternField = 'author.name'
  70. FIELD_LAST_MATCHED: PatternField = 'lastmatched'
  71. # Less preferred but recognized field aliases
  72. ALIAS_FIELD_CONTENT_MARKDOWN: PatternField = 'content'
  73. ALIAS_FIELD_AUTHOR_ID: PatternField = 'author'
  74. OP_EQUALS: PatternComparisonOperator = '=='
  75. OP_NOT_EQUALS: PatternComparisonOperator = '!='
  76. OP_LESS_THAN: PatternComparisonOperator = '<'
  77. OP_GREATER_THAN: PatternComparisonOperator = '>'
  78. OP_LESS_THAN_OR_EQUALS: PatternComparisonOperator = '<='
  79. OP_GREATER_THAN_OR_EQUALS: PatternComparisonOperator = '>='
  80. OP_CONTAINS: PatternComparisonOperator = 'contains'
  81. OP_NOT_CONTAINS: PatternComparisonOperator = '!contains'
  82. OP_MATCHES: PatternComparisonOperator = 'matches'
  83. OP_NOT_MATCHES: PatternComparisonOperator = '!matches'
  84. OP_CONTAINS_WORD: PatternComparisonOperator = 'containsword'
  85. OP_NOT_CONTAINS_WORD: PatternComparisonOperator = '!containsword'
  86. def __init__(self, field: PatternField, operator: PatternComparisonOperator, value: Any):
  87. super().__init__()
  88. self.field: PatternField = field
  89. self.operator: PatternComparisonOperator = operator
  90. self.value: Any = value
  91. def __field_value(self, message: Message, other_fields: dict[str, Any]) -> Any:
  92. cls = PatternSimpleExpression
  93. if self.field in (cls.FIELD_CONTENT_MARKDOWN, cls.ALIAS_FIELD_CONTENT_MARKDOWN):
  94. return message.content
  95. if self.field == cls.FIELD_CONTENT_PLAIN:
  96. return discordutils.remove_markdown(message.clean_content)
  97. if self.field in (cls.FIELD_AUTHOR_ID, cls.ALIAS_FIELD_AUTHOR_ID):
  98. return str(message.author.id)
  99. if self.field == cls.FIELD_AUTHOR_JOINAGE:
  100. return norm_datetime(message.created_at) - norm_datetime(message.author.joined_at)
  101. if self.field == cls.FIELD_AUTHOR_NAME:
  102. return message.author.name
  103. if self.field == cls.FIELD_LAST_MATCHED:
  104. long_ago = datetime(year=1900, month=1, day=1, hour=0, minute=0, second=0, tzinfo=timezone.utc)
  105. last_matched = other_fields.get('last_matched') or long_ago
  106. return norm_datetime(message.created_at) - norm_datetime(last_matched)
  107. raise ValueError(f'Bad field name "{self.field}"')
  108. def matches(self, message: Message, other_fields: dict[str, Any]) -> bool:
  109. cls = PatternSimpleExpression
  110. field_value = self.__field_value(message, other_fields)
  111. if self.operator == cls.OP_EQUALS:
  112. if isinstance(field_value, str) and isinstance(self.value, str):
  113. return field_value.lower() == self.value.lower()
  114. return field_value == self.value
  115. if self.operator == cls.OP_NOT_EQUALS:
  116. if isinstance(field_value, str) and isinstance(self.value, str):
  117. return field_value.lower() != self.value.lower()
  118. return field_value != self.value
  119. if self.operator == cls.OP_LESS_THAN:
  120. return field_value < self.value
  121. if self.operator == cls.OP_GREATER_THAN:
  122. return field_value > self.value
  123. if self.operator == cls.OP_LESS_THAN_OR_EQUALS:
  124. return field_value <= self.value
  125. if self.operator == cls.OP_GREATER_THAN_OR_EQUALS:
  126. return field_value >= self.value
  127. if self.operator == cls.OP_CONTAINS:
  128. return self.value.lower() in field_value.lower()
  129. if self.operator == cls.OP_NOT_CONTAINS:
  130. return self.value.lower() not in field_value.lower()
  131. if self.operator in (cls.OP_MATCHES, cls.OP_CONTAINS_WORD):
  132. return self.value.search(field_value.lower()) is not None
  133. if self.operator in (cls.OP_NOT_MATCHES, cls.OP_NOT_CONTAINS_WORD):
  134. return self.value.search(field_value.lower()) is None
  135. raise ValueError(f'Bad operator {self.operator}')
  136. def __str__(self) -> str:
  137. return f'({self.field} {self.operator} {self.value})'
  138. class PatternCompoundExpression(PatternExpression):
  139. """
  140. Message matching expression that combines several child expressions with
  141. a boolean operator.
  142. """
  143. OP_NOT = '!'
  144. OP_AND = 'and'
  145. OP_OR = 'or'
  146. def __init__(self, operator: PatternBooleanOperator, operands: list[PatternExpression]):
  147. super().__init__()
  148. self.operator: PatternBooleanOperator = operator
  149. self.operands = list(operands)
  150. def matches(self, message: Message, other_fields: dict[str, Any]) -> bool:
  151. if self.operator == PatternCompoundExpression.OP_NOT:
  152. return not self.operands[0].matches(message, other_fields)
  153. if self.operator == PatternCompoundExpression.OP_AND:
  154. for op in self.operands:
  155. if not op.matches(message, other_fields):
  156. return False
  157. return True
  158. if self.operator == PatternCompoundExpression.OP_OR:
  159. for op in self.operands:
  160. if op.matches(message, other_fields):
  161. return True
  162. return False
  163. raise ValueError(f'Bad operator "{self.operator}"')
  164. def __str__(self) -> str:
  165. if self.operator == PatternCompoundExpression.OP_NOT:
  166. return f'(!( {self.operands[0]} ))'
  167. strs = map(str, self.operands)
  168. joined = f' {self.operator} '.join(strs)
  169. return f'( {joined} )'
  170. class PatternStatement:
  171. """
  172. A full message match statement. If a message matches the given expression,
  173. the given actions should be performed.
  174. """
  175. DEFAULT_PRIORITY: int = 100
  176. def __init__(self,
  177. name: str,
  178. actions: list[PatternAction],
  179. expression: PatternExpression,
  180. original: str,
  181. priority: int = DEFAULT_PRIORITY):
  182. self.name: str = name
  183. self.actions: list[PatternAction] = list(actions) # PatternAction[]
  184. self.expression: PatternExpression = expression
  185. self.original: str = original
  186. self.priority: int = priority
  187. def check_deprecations(self) -> None:
  188. """
  189. Tests whether this statement uses any deprecated syntax. Will raise a
  190. PatternDeprecationError if one is found.
  191. """
  192. self.__check_deprecations(self.expression)
  193. @classmethod
  194. def __check_deprecations(cls, expression: PatternExpression) -> None:
  195. if isinstance(expression, PatternSimpleExpression):
  196. s: PatternSimpleExpression = expression
  197. if s.field in PatternCompiler.DEPRECATED_FIELDS:
  198. raise PatternDeprecationError(f'"{s.field}" field is deprecated')
  199. elif isinstance(expression, PatternCompoundExpression):
  200. c: PatternCompoundExpression = expression
  201. for oper in c.operands:
  202. cls.__check_deprecations(oper)
  203. def to_json(self) -> dict[str, Any]:
  204. """
  205. Returns a JSON representation of this statement.
  206. """
  207. return {
  208. 'name': self.name,
  209. 'priority': self.priority,
  210. 'statement': self.original,
  211. }
  212. @classmethod
  213. def from_json(cls, json: dict[str, Any]):
  214. """
  215. Gets a PatternStatement from its JSON representation.
  216. """
  217. ps = PatternCompiler.parse_statement(json['name'], json['statement'])
  218. ps.priority = json.get('priority', cls.DEFAULT_PRIORITY)
  219. return ps
  220. class PatternCompiler:
  221. """
  222. Parses a user-provided message filter statement into a PatternStatement.
  223. """
  224. DATATYPE_FLOAT: str = 'float'
  225. DATATYPE_ID: str = 'id'
  226. DATATYPE_INT: str = 'int'
  227. DATATYPE_MEMBER: str = 'Member'
  228. DATATYPE_REGEX: str = 'regex'
  229. DATATYPE_TEXT: str = 'text'
  230. DATATYPE_TIMESPAN: str = 'timespan'
  231. FIELD_TO_DATATYPE: ClassVar[dict[PatternField, str]] = {
  232. PatternSimpleExpression.ALIAS_FIELD_AUTHOR_ID: DATATYPE_MEMBER,
  233. PatternSimpleExpression.FIELD_AUTHOR_ID: DATATYPE_ID,
  234. PatternSimpleExpression.FIELD_AUTHOR_JOINAGE: DATATYPE_TIMESPAN,
  235. PatternSimpleExpression.FIELD_AUTHOR_NAME: DATATYPE_TEXT,
  236. PatternSimpleExpression.ALIAS_FIELD_CONTENT_MARKDOWN: DATATYPE_TEXT, # deprecated, use content.markdown or content.plain
  237. PatternSimpleExpression.FIELD_CONTENT_MARKDOWN: DATATYPE_TEXT,
  238. PatternSimpleExpression.FIELD_CONTENT_PLAIN: DATATYPE_TEXT,
  239. PatternSimpleExpression.FIELD_LAST_MATCHED: DATATYPE_TIMESPAN,
  240. }
  241. DEPRECATED_FIELDS: ClassVar[set[PatternField]] = { 'content' }
  242. ACTION_TO_ARGS: ClassVar[dict[PatternActionType, list[str]]] = {
  243. PatternAction.TYPE_BAN: [],
  244. PatternAction.TYPE_DELETE: [],
  245. PatternAction.TYPE_KICK: [],
  246. PatternAction.TYPE_INFORM_MODS: [],
  247. PatternAction.TYPE_WARN_MODS: [],
  248. PatternAction.TYPE_REPLY: [ DATATYPE_TEXT ],
  249. }
  250. OPERATORS_IDENTITY: ClassVar[set[PatternComparisonOperator]] = {
  251. PatternSimpleExpression.OP_EQUALS,
  252. PatternSimpleExpression.OP_NOT_EQUALS,
  253. }
  254. OPERATORS_COMPARISON: ClassVar[set[PatternComparisonOperator]] = {
  255. PatternSimpleExpression.OP_LESS_THAN,
  256. PatternSimpleExpression.OP_GREATER_THAN,
  257. PatternSimpleExpression.OP_LESS_THAN_OR_EQUALS,
  258. PatternSimpleExpression.OP_GREATER_THAN_OR_EQUALS,
  259. }
  260. OPERATORS_NUMERIC: ClassVar[set[PatternComparisonOperator]] = OPERATORS_IDENTITY | OPERATORS_COMPARISON
  261. OPERATORS_TEXT: ClassVar[set[PatternComparisonOperator]] = OPERATORS_IDENTITY | {
  262. PatternSimpleExpression.OP_CONTAINS,
  263. PatternSimpleExpression.OP_NOT_CONTAINS,
  264. PatternSimpleExpression.OP_CONTAINS_WORD,
  265. PatternSimpleExpression.OP_NOT_CONTAINS_WORD,
  266. PatternSimpleExpression.OP_MATCHES,
  267. PatternSimpleExpression.OP_NOT_MATCHES,
  268. }
  269. OPERATORS_ALL: set[str] = OPERATORS_IDENTITY | OPERATORS_COMPARISON | OPERATORS_TEXT
  270. DATATYPE_TO_OPERATORS: ClassVar[dict[str, set[PatternComparisonOperator]]] = {
  271. DATATYPE_ID: OPERATORS_IDENTITY,
  272. DATATYPE_MEMBER: OPERATORS_IDENTITY,
  273. DATATYPE_TEXT: OPERATORS_TEXT,
  274. DATATYPE_INT: OPERATORS_NUMERIC,
  275. DATATYPE_FLOAT: OPERATORS_NUMERIC,
  276. DATATYPE_TIMESPAN: OPERATORS_NUMERIC,
  277. }
  278. WHITESPACE_CHARS: str = ' \t\n\r'
  279. STRING_QUOTE_CHARS: str = '\'"'
  280. SYMBOL_CHARS: str = 'abcdefghijklmnopqrstuvwxyz.'
  281. VALUE_CHARS: str = '0123456789dhms<@!>'
  282. OP_CHARS: str = '<=>!(),'
  283. MAX_EXPRESSION_NESTING: int = 8
  284. @classmethod
  285. def expression_str_from_context(cls, context: Context, name: str) -> str:
  286. """
  287. Extracts the statement string from an "add" command context.
  288. """
  289. pattern_str: str = context.message.content
  290. command_chain = [ name ]
  291. cmd = context.command
  292. while cmd:
  293. command_chain.insert(0, cmd.name)
  294. cmd = cmd.parent
  295. command_chain[0] = f'{context.prefix}{command_chain[0]}'
  296. for cmd in command_chain:
  297. if pattern_str.startswith(cmd):
  298. pattern_str = pattern_str[len(cmd):].lstrip()
  299. elif pattern_str.startswith(f'"{cmd}"'):
  300. pattern_str = pattern_str[len(cmd) + 2:].lstrip()
  301. return pattern_str
  302. @classmethod
  303. def parse_statement(cls, name: str, statement: str) -> PatternStatement:
  304. """
  305. Parses a user-provided message filter statement into a PatternStatement.
  306. Raises PatternError on failure.
  307. """
  308. tokens: list[str] = cls.__tokenize(statement)
  309. token_index: int = 0
  310. actions, token_index = cls.__read_actions(tokens, token_index)
  311. expression, token_index = cls.__read_expression(tokens, token_index)
  312. return PatternStatement(name, actions, expression, statement)
  313. @classmethod
  314. def __tokenize(cls, statement: str) -> list[str]:
  315. """
  316. Converts a message filter statement into a list of tokens.
  317. """
  318. tokens: list[str] = []
  319. in_quote: bool | str = False
  320. in_escape: bool = False
  321. all_token_types: set[str] = { 'sym', 'op', 'val' }
  322. possible_token_types: set[str] = set(all_token_types)
  323. current_token: str = ''
  324. for ch in statement:
  325. if in_quote:
  326. if in_escape:
  327. if ch == 'n':
  328. current_token += '\n'
  329. elif ch == 't':
  330. current_token += '\t'
  331. else:
  332. current_token += ch
  333. in_escape = False
  334. elif ch == '\\':
  335. in_escape = True
  336. elif ch == in_quote:
  337. current_token += ch
  338. tokens.append(current_token)
  339. current_token = ''
  340. possible_token_types |= all_token_types
  341. in_quote = False
  342. else:
  343. current_token += ch
  344. else:
  345. if ch in cls.STRING_QUOTE_CHARS:
  346. if len(current_token) > 0:
  347. tokens.append(current_token)
  348. possible_token_types |= all_token_types
  349. in_quote = ch
  350. current_token = ch
  351. elif ch == '\\':
  352. raise PatternError("Unexpected \\ outside quoted string")
  353. elif ch in cls.WHITESPACE_CHARS:
  354. if len(current_token) > 0:
  355. tokens.append(current_token)
  356. current_token = ''
  357. possible_token_types |= all_token_types
  358. else:
  359. possible_ch_types = set()
  360. if ch in cls.SYMBOL_CHARS:
  361. possible_ch_types.add('sym')
  362. if ch in cls.VALUE_CHARS:
  363. possible_ch_types.add('val')
  364. if ch in cls.OP_CHARS:
  365. possible_ch_types.add('op')
  366. if len(current_token) > 0 and \
  367. possible_ch_types.isdisjoint(possible_token_types) and \
  368. len(current_token) > 0:
  369. tokens.append(current_token)
  370. current_token = ''
  371. possible_token_types |= all_token_types
  372. possible_token_types &= possible_ch_types
  373. current_token += ch
  374. if len(current_token) > 0:
  375. tokens.append(current_token)
  376. # Some symbols might be glommed onto other tokens. Split 'em up.
  377. prefixes_to_split = [ '!', '(', ',' ]
  378. suffixes_to_split = [ ')', ',' ]
  379. i = 0
  380. while i < len(tokens):
  381. token = tokens[i]
  382. mutated = False
  383. for prefix in prefixes_to_split:
  384. if token.startswith(prefix) and len(token) > len(prefix):
  385. tokens.insert(i, prefix)
  386. tokens[i + 1] = token[len(prefix):]
  387. i += 1
  388. mutated = True
  389. break
  390. if mutated:
  391. continue
  392. for suffix in suffixes_to_split:
  393. if token.endswith(suffix) and len(token) > len(suffix):
  394. tokens[i] = token[0:-len(suffix)]
  395. tokens.insert(i + 1, suffix)
  396. mutated = True
  397. break
  398. if mutated:
  399. continue
  400. i += 1
  401. return tokens
  402. @classmethod
  403. def __read_actions(cls,
  404. tokens: list[str],
  405. token_index: int) -> tuple[list[PatternAction], int]:
  406. """
  407. Reads the actions from a list of statement tokens. Returns a tuple
  408. containing a list of PatternActions and the token index this method
  409. left off at (the token after the "if").
  410. """
  411. actions: list[PatternAction] = []
  412. current_action_tokens = []
  413. while token_index < len(tokens):
  414. token = tokens[token_index]
  415. if token == 'if':
  416. if len(current_action_tokens) > 0:
  417. a = PatternAction(current_action_tokens[0],
  418. current_action_tokens[1:])
  419. cls.__validate_action(a)
  420. actions.append(a)
  421. token_index += 1
  422. return actions, token_index
  423. elif token == ',':
  424. if len(current_action_tokens) < 1:
  425. raise PatternError('Unexpected ,')
  426. a = PatternAction(current_action_tokens[0],
  427. current_action_tokens[1:])
  428. cls.__validate_action(a)
  429. actions.append(a)
  430. current_action_tokens = []
  431. else:
  432. current_action_tokens.append(token)
  433. token_index += 1
  434. raise PatternError('Unexpected end of line in action list')
  435. @classmethod
  436. def __validate_action(cls, action: PatternAction) -> None:
  437. args: list[str] = cls.ACTION_TO_ARGS.get(action.action)
  438. if args is None:
  439. raise PatternError(f'Unknown action "{action.action}"')
  440. if len(action.arguments) != len(args):
  441. if len(args) == 0:
  442. raise PatternError(f'Action "{action.action}" expects no ' + \
  443. f'arguments, got {len(action.arguments)}.')
  444. raise PatternError(f'Action "{action.action}" expects ' + \
  445. f'{len(args)} arguments, got {len(action.arguments)}.')
  446. for i, datatype in enumerate(args):
  447. action.arguments[i] = cls.__parse_value(action.arguments[i], datatype)
  448. @classmethod
  449. def __read_expression(cls,
  450. tokens: list[str],
  451. token_index: int,
  452. depth: int = 0,
  453. one_subexpression: bool = False) -> tuple[PatternExpression, int]:
  454. """
  455. Reads an expression from a list of statement tokens. Returns a tuple
  456. containing the PatternExpression and the token index it left off at.
  457. If one_subexpression is True then it will return after reading a
  458. single expression instead of joining multiples (for reading the
  459. subject of a NOT expression).
  460. """
  461. subexpressions = []
  462. last_compound_operator = None
  463. while token_index < len(tokens):
  464. if one_subexpression:
  465. if len(subexpressions) == 1:
  466. return subexpressions[0], token_index
  467. if len(subexpressions) > 1:
  468. raise PatternError('Too many subexpressions')
  469. if tokens[token_index] == ')':
  470. if len(subexpressions) == 0:
  471. raise PatternError('No subexpressions')
  472. if len(subexpressions) == 1:
  473. return subexpressions[0], token_index
  474. return (PatternCompoundExpression(last_compound_operator,
  475. subexpressions), token_index)
  476. if tokens[token_index] in { PatternCompoundExpression.OP_AND, PatternCompoundExpression.OP_OR }:
  477. compound_operator = tokens[token_index]
  478. if last_compound_operator and \
  479. compound_operator != last_compound_operator:
  480. subexpressions = [
  481. PatternCompoundExpression(last_compound_operator,
  482. subexpressions),
  483. ]
  484. last_compound_operator = compound_operator
  485. token_index += 1
  486. if tokens[token_index] == PatternCompoundExpression.OP_NOT:
  487. (exp, next_index) = cls.__read_expression(tokens,
  488. token_index + 1, depth + 1, one_subexpression=True)
  489. subexpressions.append(PatternCompoundExpression('!', [exp]))
  490. token_index = next_index
  491. elif tokens[token_index] == '(':
  492. (exp, next_index) = cls.__read_expression(tokens,
  493. token_index + 1, depth + 1)
  494. if tokens[next_index] != ')':
  495. raise PatternError('Expected )')
  496. subexpressions.append(exp)
  497. token_index = next_index + 1
  498. else:
  499. (simple, next_index) = cls.__read_simple_expression(tokens,
  500. token_index, depth)
  501. subexpressions.append(simple)
  502. token_index = next_index
  503. if len(subexpressions) == 0:
  504. raise PatternError('No subexpressions')
  505. elif len(subexpressions) == 1:
  506. return subexpressions[0], token_index
  507. else:
  508. return PatternCompoundExpression(last_compound_operator,
  509. subexpressions), token_index
  510. @classmethod
  511. def __read_simple_expression(cls,
  512. tokens: list[str],
  513. token_index: int,
  514. depth: int = 0) -> tuple[PatternExpression, int]:
  515. """
  516. Reads a simple expression consisting of a field name, operator, and
  517. comparison value. Returns a tuple of the PatternSimpleExpression and
  518. the token index it left off at.
  519. """
  520. if depth > cls.MAX_EXPRESSION_NESTING:
  521. raise PatternError('Expression nests too deeply')
  522. if token_index >= len(tokens):
  523. raise PatternError('Expected field name, found EOL')
  524. field: PatternField = tokens[token_index]
  525. token_index += 1
  526. datatype = cls.FIELD_TO_DATATYPE.get(field, None)
  527. if datatype is None:
  528. raise PatternError(f'No such field "{field}"')
  529. if token_index >= len(tokens):
  530. raise PatternError('Expected operator, found EOL')
  531. op = tokens[token_index]
  532. token_index += 1
  533. if op == PatternCompoundExpression.OP_NOT:
  534. if token_index >= len(tokens):
  535. raise PatternError('Expected operator, found EOL')
  536. op = '!' + tokens[token_index]
  537. token_index += 1
  538. allowed_ops = cls.DATATYPE_TO_OPERATORS[datatype]
  539. if op not in allowed_ops:
  540. if op in cls.OPERATORS_ALL:
  541. raise PatternError(f'Operator {op} cannot be used with ' + \
  542. f'field "{field}"')
  543. raise PatternError(f'Unrecognized operator "{op}" - allowed: ' + \
  544. f'{sorted(allowed_ops)}')
  545. if token_index >= len(tokens):
  546. raise PatternError('Expected value, found EOL')
  547. value_str = tokens[token_index]
  548. try:
  549. value = cls.__parse_value(value_str, datatype, op)
  550. except ValueError as cause:
  551. raise PatternError(f'Bad value {value_str}') from cause
  552. token_index += 1
  553. exp = PatternSimpleExpression(field, op, value)
  554. return exp, token_index
  555. @classmethod
  556. def __parse_value(cls, value: str, datatype: str, op: str | None = None) -> Any:
  557. """
  558. Converts a value token to its Python value. Raises ValueError on failure.
  559. """
  560. if datatype == cls.DATATYPE_ID:
  561. if not is_user_id(value):
  562. raise ValueError(f'Illegal user id value: {value}')
  563. return value
  564. if datatype == cls.DATATYPE_MEMBER:
  565. return user_id_from_mention(value)
  566. if datatype == cls.DATATYPE_TEXT:
  567. s = str_from_quoted_str(value)
  568. if op in ('matches', '!matches'):
  569. try:
  570. return re.compile(s.lower())
  571. except re.error as e:
  572. raise ValueError(f'Invalid regex: {e}') from e
  573. if op in ('containsword', '!containsword'):
  574. try:
  575. return re.compile(f'\\b{re.escape(s.lower())}\\b')
  576. except re.error as e:
  577. raise ValueError(f'Invalid regex: {e}') from e
  578. return s
  579. if datatype == cls.DATATYPE_INT:
  580. return int(value)
  581. if datatype == cls.DATATYPE_FLOAT:
  582. return float(value)
  583. if datatype == cls.DATATYPE_TIMESPAN:
  584. return timedelta_from_str(value)
  585. raise ValueError(f'Unhandled datatype {datatype}')