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

patterncog.py 21KB

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