Experimental Discord bot written in Python
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

patterncog.py 21KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  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, 'PatternsCog.patterns')
  124. if patterns is None:
  125. patterns = {}
  126. patterns_encoded = Storage.get_config_value(guild, 'PatternsCog.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, 'PatternsCog.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, 'PatternsCog.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 patterns(self, context: commands.Context):
  239. 'Message pattern matching'
  240. if context.invoked_subcommand is None:
  241. await context.send_help()
  242. @patterns.command(
  243. brief='Adds a custom pattern',
  244. description='Adds a custom pattern. Patterns use a simplified expression language. Full documentation found here: https://git.rixafrix.com/ialbert/python-app-rocketbot/src/branch/master/patterns.md',
  245. usage='<pattern_name> <expression...>',
  246. ignore_extra=True
  247. )
  248. async def add(self, context: commands.Context, name: str):
  249. pattern_str = PatternCompiler.expression_str_from_context(context, name)
  250. try:
  251. statement = PatternCompiler.parse_statement(name, pattern_str)
  252. patterns = self.__get_patterns(context.guild)
  253. patterns[name] = statement
  254. self.__save_patterns(context.guild, patterns)
  255. await context.message.reply(
  256. f'{CONFIG["success_emoji"]} Pattern `{name}` added.',
  257. mention_author=False)
  258. except Exception as e:
  259. await context.message.reply(
  260. f'{CONFIG["failure_emoji"]} Error parsing statement. {e}',
  261. mention_author=False)
  262. @patterns.command(
  263. brief='Removes a custom pattern',
  264. usage='<pattern_name>'
  265. )
  266. async def remove(self, context: commands.Context, name: str):
  267. patterns = self.__get_patterns(context.guild)
  268. if patterns.get(name) is not None:
  269. del patterns[name]
  270. self.__save_patterns(context.guild, patterns)
  271. await context.message.reply(
  272. f'{CONFIG["success_emoji"]} Pattern `{name}` deleted.',
  273. mention_author=False)
  274. else:
  275. await context.message.reply(
  276. f'{CONFIG["failure_emoji"]} No pattern named `{name}`.',
  277. mention_author=False)
  278. @patterns.command(
  279. brief='Lists all patterns'
  280. )
  281. async def list(self, context: commands.Context) -> None:
  282. patterns = self.__get_patterns(context.guild)
  283. if len(patterns) == 0:
  284. await context.message.reply('No patterns defined.', mention_author=False)
  285. return
  286. msg = ''
  287. for name, statement in sorted(patterns.items()):
  288. msg += f'Pattern `{name}`:\n```\n{statement.original}\n```\n'
  289. await context.message.reply(msg, mention_author=False)
  290. class PatternCompiler:
  291. TYPE_ID = 'id'
  292. TYPE_MEMBER = 'Member'
  293. TYPE_TEXT = 'text'
  294. TYPE_INT = 'int'
  295. TYPE_FLOAT = 'float'
  296. TYPE_TIMESPAN = 'timespan'
  297. FIELD_TO_TYPE = {
  298. 'content': TYPE_TEXT,
  299. 'author': TYPE_MEMBER,
  300. 'author.id': TYPE_ID,
  301. 'author.name': TYPE_TEXT,
  302. 'author.joinage': TYPE_TIMESPAN,
  303. }
  304. ACTION_TO_ARGS = {
  305. 'ban': [],
  306. 'delete': [],
  307. 'kick': [],
  308. 'modwarn': [],
  309. 'reply': [ TYPE_TEXT ],
  310. }
  311. OPERATORS_IDENTITY = set([ '==', '!=' ])
  312. OPERATORS_COMPARISON = set([ '<', '>', '<=', '>=' ])
  313. OPERATORS_NUMERIC = OPERATORS_IDENTITY | OPERATORS_COMPARISON
  314. OPERATORS_TEXT = OPERATORS_IDENTITY | set([ 'contains', '!contains', 'matches', '!matches' ])
  315. OPERATORS_ALL = OPERATORS_IDENTITY | OPERATORS_COMPARISON | OPERATORS_TEXT
  316. TYPE_TO_OPERATORS = {
  317. TYPE_ID: OPERATORS_IDENTITY,
  318. TYPE_MEMBER: OPERATORS_IDENTITY,
  319. TYPE_TEXT: OPERATORS_TEXT,
  320. TYPE_INT: OPERATORS_NUMERIC,
  321. TYPE_FLOAT: OPERATORS_NUMERIC,
  322. TYPE_TIMESPAN: OPERATORS_NUMERIC,
  323. }
  324. WHITESPACE_CHARS = ' \t\n\r'
  325. STRING_QUOTE_CHARS = '\'"'
  326. SYMBOL_CHARS = 'abcdefghijklmnopqrstuvwxyz.'
  327. VALUE_CHARS = '0123456789dhms<@!>'
  328. OP_CHARS = '<=>!(),'
  329. @classmethod
  330. def expression_str_from_context(cls, context: commands.Context, name: str) -> str:
  331. pattern_str = context.message.content
  332. command_chain = [ name ]
  333. cmd = context.command
  334. while cmd:
  335. command_chain.insert(0, cmd.name)
  336. cmd = cmd.parent
  337. command_chain[0] = f'{context.prefix}{command_chain[0]}'
  338. for cmd in command_chain:
  339. if pattern_str.startswith(cmd):
  340. pattern_str = pattern_str[len(cmd):].lstrip()
  341. return pattern_str
  342. @classmethod
  343. def parse_statement(cls, name: str, statement: str) -> PatternStatement:
  344. tokens = cls.tokenize(statement)
  345. token_index = 0
  346. actions, token_index = cls.read_actions(tokens, token_index)
  347. expression, token_index = cls.read_expression(tokens, token_index)
  348. return PatternStatement(name, actions, expression, statement)
  349. @classmethod
  350. def tokenize(cls, statement: str) -> list:
  351. tokens = []
  352. in_quote = False
  353. in_escape = False
  354. all_token_types = set([ 'sym', 'op', 'val' ])
  355. possible_token_types = set(all_token_types)
  356. current_token = ''
  357. for ch in statement:
  358. if in_quote:
  359. if in_escape:
  360. if ch == 'n':
  361. current_token += '\n'
  362. elif ch == 't':
  363. current_token += '\t'
  364. else:
  365. current_token += ch
  366. in_escape = False
  367. elif ch == '\\':
  368. in_escape = True
  369. elif ch == in_quote:
  370. current_token += ch
  371. tokens.append(current_token)
  372. current_token = ''
  373. possible_token_types |= all_token_types
  374. in_quote = False
  375. else:
  376. current_token += ch
  377. else:
  378. if ch in cls.STRING_QUOTE_CHARS:
  379. if len(current_token) > 0:
  380. tokens.append(current_token)
  381. current_token = ''
  382. possible_token_types |= all_token_types
  383. in_quote = ch
  384. current_token = ch
  385. elif ch == '\\':
  386. raise RuntimeError("Unexpected \\")
  387. elif ch in cls.WHITESPACE_CHARS:
  388. if len(current_token) > 0:
  389. tokens.append(current_token)
  390. current_token = ''
  391. possible_token_types |= all_token_types
  392. else:
  393. possible_ch_types = set()
  394. if ch in cls.SYMBOL_CHARS:
  395. possible_ch_types.add('sym')
  396. if ch in cls.VALUE_CHARS:
  397. possible_ch_types.add('val')
  398. if ch in cls.OP_CHARS:
  399. possible_ch_types.add('op')
  400. if len(current_token) > 0 and possible_ch_types.isdisjoint(possible_token_types):
  401. if len(current_token) > 0:
  402. tokens.append(current_token)
  403. current_token = ''
  404. possible_token_types |= all_token_types
  405. possible_token_types &= possible_ch_types
  406. current_token += ch
  407. if len(current_token) > 0:
  408. tokens.append(current_token)
  409. # Some symbols might be glommed onto other tokens. Split 'em up.
  410. prefixes_to_split = [ '!', '(', ',' ]
  411. suffixes_to_split = [ ')', ',' ]
  412. i = 0
  413. while i < len(tokens):
  414. token = tokens[i]
  415. mutated = False
  416. for prefix in prefixes_to_split:
  417. if token.startswith(prefix) and len(token) > len(prefix):
  418. tokens.insert(i, prefix)
  419. tokens[i + 1] = token[len(prefix):]
  420. i += 1
  421. mutated = True
  422. break
  423. if mutated:
  424. continue
  425. for suffix in suffixes_to_split:
  426. if token.endswith(suffix) and len(token) > len(suffix):
  427. tokens[i] = token[0:-len(suffix)]
  428. tokens.insert(i + 1, suffix)
  429. mutated = True
  430. break
  431. if mutated:
  432. continue
  433. i += 1
  434. return tokens
  435. @classmethod
  436. def read_actions(cls, tokens: list, token_index: int) -> tuple:
  437. actions = []
  438. current_action_tokens = []
  439. while token_index < len(tokens):
  440. token = tokens[token_index]
  441. if token == 'if':
  442. if len(current_action_tokens) > 0:
  443. a = PatternAction(current_action_tokens[0], current_action_tokens[1:])
  444. cls.__validate_action(a)
  445. actions.append(a)
  446. token_index += 1
  447. return (actions, token_index)
  448. elif token == ',':
  449. if len(current_action_tokens) < 1:
  450. raise RuntimeError('Unexpected ,')
  451. a = PatternAction(current_action_tokens[0], current_action_tokens[1:])
  452. cls.__validate_action(a)
  453. actions.append(a)
  454. current_action_tokens = []
  455. else:
  456. current_action_tokens.append(token)
  457. token_index += 1
  458. raise RuntimeError('Unexpected end of line')
  459. @classmethod
  460. def __validate_action(cls, action: PatternAction) -> None:
  461. args = cls.ACTION_TO_ARGS.get(action.type)
  462. if args is None:
  463. raise RuntimeError(f'Unknown action "{action.type}"')
  464. if len(action.arguments) != len(args):
  465. arg_list = ', '.join(args)
  466. if len(args) == 0:
  467. raise RuntimeError(f'Action "{action.type}" expects no arguments, got {len(action.arguments)}.')
  468. else:
  469. raise RuntimeError(f'Action "{action.type}" expects {len(args)} arguments, got {len(action.arguments)}.')
  470. for i in range(len(args)):
  471. datatype = args[i]
  472. action.arguments[i] = cls.parse_value(action.arguments[i], datatype)
  473. @classmethod
  474. def read_expression(cls, tokens: list, token_index: int, depth: int = 0, one_subexpression: bool = False) -> tuple:
  475. # field op value
  476. # (field op value)
  477. # !(field op value)
  478. # field op value and field op value
  479. # (field op value and field op value) or field op value
  480. indent = '\t' * depth
  481. subexpressions = []
  482. last_compound_operator = None
  483. while token_index < len(tokens):
  484. if one_subexpression:
  485. if len(subexpressions) == 1:
  486. return (subexpressions[0], token_index)
  487. elif len(subexpressions) > 1:
  488. raise RuntimeError('Too many subexpressions')
  489. compound_operator = None
  490. if tokens[token_index] == ')':
  491. if len(subexpressions) == 0:
  492. raise RuntimeError('No subexpressions')
  493. elif len(subexpressions) == 1:
  494. return (subexpressions[0], token_index)
  495. else:
  496. return (PatternCompoundExpression(last_compound_operator, subexpressions), token_index)
  497. if tokens[token_index] in set(["and", "or"]):
  498. compound_operator = tokens[token_index]
  499. if last_compound_operator and compound_operator != last_compound_operator:
  500. subexpressions = [ PatternCompoundExpression(last_compound_operator, subexpressions) ]
  501. last_compound_operator = compound_operator
  502. else:
  503. last_compound_operator = compound_operator
  504. token_index += 1
  505. if tokens[token_index] == '!':
  506. (exp, next_index) = cls.read_expression(tokens, token_index + 1, depth + 1, one_subexpression=True)
  507. subexpressions.append(PatternCompoundExpression('!', [exp]))
  508. token_index = next_index
  509. elif tokens[token_index] == '(':
  510. (exp, next_index) = cls.read_expression(tokens, token_index + 1, depth + 1)
  511. if tokens[next_index] != ')':
  512. raise RuntimeError('Expected )')
  513. subexpressions.append(exp)
  514. token_index = next_index + 1
  515. else:
  516. (simple, next_index) = cls.read_simple_expression(tokens, token_index, depth)
  517. subexpressions.append(simple)
  518. token_index = next_index
  519. if len(subexpressions) == 0:
  520. raise RuntimeError('No subexpressions')
  521. elif len(subexpressions) == 1:
  522. return (subexpressions[0], token_index)
  523. else:
  524. return (PatternCompoundExpression(last_compound_operator, subexpressions), token_index)
  525. @classmethod
  526. def read_simple_expression(cls, tokens: list, token_index: int, depth: int = 0) -> tuple:
  527. indent = '\t' * depth
  528. if token_index >= len(tokens):
  529. raise RuntimeError('Expected field name, found EOL')
  530. field = tokens[token_index]
  531. token_index += 1
  532. datatype = cls.FIELD_TO_TYPE.get(field)
  533. if datatype is None:
  534. raise RuntimeError(f'No such field "{field}"')
  535. if token_index >= len(tokens):
  536. raise RuntimeError('Expected operator, found EOL')
  537. op = tokens[token_index]
  538. token_index += 1
  539. if op == '!':
  540. if token_index >= len(tokens):
  541. raise RuntimeError('Expected operator, found EOL')
  542. op = '!' + tokens[token_index]
  543. token_index += 1
  544. allowed_ops = cls.TYPE_TO_OPERATORS[datatype]
  545. if op not in allowed_ops:
  546. if op in cls.OPERATORS_ALL:
  547. raise RuntimeError(f'Operator {op} cannot be used with field "{field}"')
  548. else:
  549. raise RuntimeError(f'Unrecognized operator "{op}" - allowed: {list(allowed_ops)}')
  550. if token_index >= len(tokens):
  551. raise RuntimeError('Expected value, found EOL')
  552. value = tokens[token_index]
  553. value = cls.parse_value(value, datatype)
  554. token_index += 1
  555. exp = PatternSimpleExpression(field, op, value)
  556. return (exp, token_index)
  557. @classmethod
  558. def parse_value(cls, value: str, type: str):
  559. if type == cls.TYPE_ID:
  560. p = re.compile('^[0-9]+$')
  561. if p.match(value) is None:
  562. raise ValueError(f'Illegal id value "{value}"')
  563. # Store it as a str so it can be larger than an int
  564. return value
  565. if type == cls.TYPE_MEMBER:
  566. p = re.compile('^<@!?([0-9]+)>$')
  567. m = p.match(value)
  568. if m is None:
  569. raise ValueError(f'Illegal member value. Must be an @ mention.')
  570. return m.group(1)
  571. if type == cls.TYPE_TEXT:
  572. # Must be quoted.
  573. if len(value) < 2 or \
  574. value[0:1] not in cls.STRING_QUOTE_CHARS or \
  575. value[-1:] not in cls.STRING_QUOTE_CHARS or \
  576. value[0:1] != value[-1:]:
  577. raise ValueError(f'Not a quoted string value: {value}')
  578. return value[1:-1]
  579. if type == cls.TYPE_INT:
  580. return int(value)
  581. if type == cls.TYPE_FLOAT:
  582. return float(value)
  583. if type == cls.TYPE_TIMESPAN:
  584. p = re.compile('^(?:[0-9]+[dhms])+$')
  585. if p.match(value) is None:
  586. raise RuntimeError("Illegal timespan value \"{value}\". Must be like \"100d\", \"5m30s\", etc.")
  587. p = re.compile('([0-9]+)([dhms])')
  588. days = 0
  589. hours = 0
  590. minutes = 0
  591. seconds = 0
  592. for m in p.finditer(value):
  593. scalar = int(m.group(1))
  594. unit = m.group(2)
  595. if unit == 'd':
  596. days = scalar
  597. elif unit == 'h':
  598. hours = scalar
  599. elif unit == 'm':
  600. minutes = scalar
  601. elif unit == 's':
  602. seconds = scalar
  603. return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
  604. raise ValueError(f'Unhandled datatype {datatype}')