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 21KB

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