|
|
@@ -4,13 +4,15 @@ automated actions on them.
|
|
4
|
4
|
"""
|
|
5
|
5
|
import re
|
|
6
|
6
|
from abc import ABCMeta, abstractmethod
|
|
7
|
|
-from discord import Guild, Member, Message
|
|
|
7
|
+from discord import Guild, Member, Message, utils as discordutils
|
|
8
|
8
|
from discord.ext import commands
|
|
9
|
9
|
|
|
10
|
10
|
from config import CONFIG
|
|
11
|
11
|
from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction
|
|
|
12
|
+from rocketbot.cogsetting import CogSetting
|
|
12
|
13
|
from rocketbot.storage import Storage
|
|
13
|
|
-from rocketbot.utils import parse_timedelta
|
|
|
14
|
+from rocketbot.utils import is_user_id, str_from_quoted_str, timedelta_from_str, \
|
|
|
15
|
+ user_id_from_mention
|
|
14
|
16
|
|
|
15
|
17
|
class PatternAction:
|
|
16
|
18
|
"""
|
|
|
@@ -50,8 +52,10 @@ class PatternSimpleExpression(PatternExpression):
|
|
50
|
52
|
self.value = value
|
|
51
|
53
|
|
|
52
|
54
|
def __field_value(self, message: Message):
|
|
53
|
|
- if self.field == 'content':
|
|
|
55
|
+ if self.field in ('content.markdown', 'content'):
|
|
54
|
56
|
return message.content
|
|
|
57
|
+ if self.field == 'content.plain':
|
|
|
58
|
+ return discordutils.remove_markdown(message.clean_content)
|
|
55
|
59
|
if self.field == 'author':
|
|
56
|
60
|
return str(message.author.id)
|
|
57
|
61
|
if self.field == 'author.id':
|
|
|
@@ -119,7 +123,7 @@ class PatternCompoundExpression(PatternExpression):
|
|
119
|
123
|
if op.matches(message):
|
|
120
|
124
|
return True
|
|
121
|
125
|
return False
|
|
122
|
|
- raise RuntimeError(f'Bad operator "{self.operator}"')
|
|
|
126
|
+ raise ValueError(f'Bad operator "{self.operator}"')
|
|
123
|
127
|
|
|
124
|
128
|
def __str__(self) -> str:
|
|
125
|
129
|
if self.operator == '!':
|
|
|
@@ -133,12 +137,32 @@ class PatternStatement:
|
|
133
|
137
|
A full message match statement. If a message matches the given expression,
|
|
134
|
138
|
the given actions should be performed.
|
|
135
|
139
|
"""
|
|
136
|
|
- def __init__(self, name: str, actions: list, expression: PatternExpression, original: str):
|
|
|
140
|
+ def __init__(self,
|
|
|
141
|
+ name: str,
|
|
|
142
|
+ actions: list,
|
|
|
143
|
+ expression: PatternExpression,
|
|
|
144
|
+ original: str):
|
|
137
|
145
|
self.name = name
|
|
138
|
146
|
self.actions = list(actions) # PatternAction[]
|
|
139
|
147
|
self.expression = expression
|
|
140
|
148
|
self.original = original
|
|
141
|
149
|
|
|
|
150
|
+ def to_json(self) -> dict:
|
|
|
151
|
+ """
|
|
|
152
|
+ Returns a JSON representation of this statement.
|
|
|
153
|
+ """
|
|
|
154
|
+ return {
|
|
|
155
|
+ 'name': self.name,
|
|
|
156
|
+ 'statement': self.original,
|
|
|
157
|
+ }
|
|
|
158
|
+
|
|
|
159
|
+ @classmethod
|
|
|
160
|
+ def from_json(cls, json: dict):
|
|
|
161
|
+ """
|
|
|
162
|
+ Gets a PatternStatement from its JSON representation.
|
|
|
163
|
+ """
|
|
|
164
|
+ return PatternCompiler.parse_statement(json['name'], json['statement'])
|
|
|
165
|
+
|
|
142
|
166
|
class PatternContext:
|
|
143
|
167
|
"""
|
|
144
|
168
|
Data about a message that has matched a configured statement and what
|
|
|
@@ -157,33 +181,30 @@ class PatternCog(BaseCog, name='Pattern Matching'):
|
|
157
|
181
|
various critera. Patterns can be defined by mods for each guild.
|
|
158
|
182
|
"""
|
|
159
|
183
|
|
|
160
|
|
- def __get_patterns(self, guild: Guild) -> dict:
|
|
161
|
|
- patterns = Storage.get_state_value(guild, 'PatternCog.patterns')
|
|
|
184
|
+ SETTING_PATTERNS = CogSetting('patterns', None)
|
|
|
185
|
+
|
|
|
186
|
+ def __get_patterns(self, guild: Guild) -> dict[str, PatternStatement]:
|
|
|
187
|
+ """
|
|
|
188
|
+ Returns a name -> PatternStatement lookup for the guild.
|
|
|
189
|
+ """
|
|
|
190
|
+ patterns: dict[str, PatternStatement] = Storage.get_state_value(guild,
|
|
|
191
|
+ 'PatternCog.patterns')
|
|
162
|
192
|
if patterns is None:
|
|
163
|
|
- patterns = {}
|
|
164
|
|
- patterns_encoded = Storage.get_config_value(guild, 'PatternCog.patterns')
|
|
165
|
|
- if patterns_encoded:
|
|
166
|
|
- for pe in patterns_encoded:
|
|
167
|
|
- name = pe.get('name')
|
|
168
|
|
- statement = pe.get('statement')
|
|
169
|
|
- try:
|
|
170
|
|
- ps = PatternCompiler.parse_statement(name, statement)
|
|
171
|
|
- patterns[name] = ps
|
|
172
|
|
- except PatternError as e:
|
|
173
|
|
- self.log(guild, 'Error parsing saved statement ' + \
|
|
174
|
|
- f'"{name}": "{e}" Statement: {statement}')
|
|
|
193
|
+ jsons: list[dict] = self.get_guild_setting(guild, self.SETTING_PATTERNS)
|
|
|
194
|
+ pattern_list: list[PatternStatement] = []
|
|
|
195
|
+ for json in jsons:
|
|
|
196
|
+ try:
|
|
|
197
|
+ pattern_list.append(PatternStatement.from_json(json))
|
|
|
198
|
+ except PatternError as e:
|
|
|
199
|
+ self.log(guild, f'Error decoding pattern "{json["name"]}": {e}')
|
|
|
200
|
+ patterns = { p.name:p for p in pattern_list}
|
|
175
|
201
|
Storage.set_state_value(guild, 'PatternCog.patterns', patterns)
|
|
176
|
202
|
return patterns
|
|
177
|
203
|
|
|
178
|
204
|
@classmethod
|
|
179
|
|
- def __save_patterns(cls, guild: Guild, patterns: dict) -> None:
|
|
180
|
|
- to_save = []
|
|
181
|
|
- for name, statement in patterns.items():
|
|
182
|
|
- to_save.append({
|
|
183
|
|
- 'name': name,
|
|
184
|
|
- 'statement': statement.original,
|
|
185
|
|
- })
|
|
186
|
|
- Storage.set_config_value(guild, 'PatternCog.patterns', to_save)
|
|
|
205
|
+ def __save_patterns(cls, guild: Guild, patterns: dict[str, PatternStatement]) -> None:
|
|
|
206
|
+ to_save: list[dict] = list(map(PatternStatement.to_json, patterns.values()))
|
|
|
207
|
+ cls.set_guild_setting(guild, cls.SETTING_PATTERNS, to_save)
|
|
187
|
208
|
|
|
188
|
209
|
@commands.Cog.listener()
|
|
189
|
210
|
async def on_message(self, message: Message) -> None:
|
|
|
@@ -200,12 +221,11 @@ class PatternCog(BaseCog, name='Pattern Matching'):
|
|
200
|
221
|
return
|
|
201
|
222
|
|
|
202
|
223
|
patterns = self.__get_patterns(message.guild)
|
|
203
|
|
- for _, statement in patterns.items():
|
|
|
224
|
+ for statement in patterns.values():
|
|
204
|
225
|
if statement.expression.matches(message):
|
|
205
|
226
|
await self.__trigger_actions(message, statement)
|
|
206
|
227
|
break
|
|
207
|
228
|
|
|
208
|
|
-
|
|
209
|
229
|
async def __trigger_actions(self, message: Message, statement: PatternStatement) -> None:
|
|
210
|
230
|
context = PatternContext(message, statement)
|
|
211
|
231
|
should_alert_mods = False
|
|
|
@@ -239,7 +259,7 @@ class PatternCog(BaseCog, name='Pattern Matching'):
|
|
239
|
259
|
f'{action.arguments[0]}',
|
|
240
|
260
|
mention_author=False)
|
|
241
|
261
|
action_descriptions.append('Autoreplied')
|
|
242
|
|
- self.log(message.guild, f'{message.author.name} autoreplied to')
|
|
|
262
|
+ self.log(message.guild, f'autoreplied to {message.author.name}')
|
|
243
|
263
|
bm = BotMessage(
|
|
244
|
264
|
message.guild,
|
|
245
|
265
|
f'User {message.author.name} tripped custom pattern ' + \
|
|
|
@@ -247,7 +267,7 @@ class PatternCog(BaseCog, name='Pattern Matching'):
|
|
247
|
267
|
('\n• '.join(action_descriptions)),
|
|
248
|
268
|
type=BotMessage.TYPE_MOD_WARNING if should_alert_mods else BotMessage.TYPE_INFO,
|
|
249
|
269
|
context=context)
|
|
250
|
|
- bm.quote = message.content
|
|
|
270
|
+ bm.quote = discordutils.remove_markdown(message.clean_content)
|
|
251
|
271
|
await bm.set_reactions(BotMessageReaction.standard_set(
|
|
252
|
272
|
did_delete=context.is_deleted,
|
|
253
|
273
|
did_kick=context.is_kicked,
|
|
|
@@ -361,11 +381,14 @@ class PatternCompiler:
|
|
361
|
381
|
TYPE_TIMESPAN = 'timespan'
|
|
362
|
382
|
|
|
363
|
383
|
FIELD_TO_TYPE = {
|
|
364
|
|
- 'content': TYPE_TEXT,
|
|
|
384
|
+ 'content.plain': TYPE_TEXT,
|
|
|
385
|
+ 'content.markdown': TYPE_TEXT,
|
|
365
|
386
|
'author': TYPE_MEMBER,
|
|
366
|
387
|
'author.id': TYPE_ID,
|
|
367
|
388
|
'author.name': TYPE_TEXT,
|
|
368
|
389
|
'author.joinage': TYPE_TIMESPAN,
|
|
|
390
|
+
|
|
|
391
|
+ 'content': TYPE_TEXT, # deprecated, use content.markdown or content.plain
|
|
369
|
392
|
}
|
|
370
|
393
|
|
|
371
|
394
|
ACTION_TO_ARGS = {
|
|
|
@@ -675,29 +698,17 @@ class PatternCompiler:
|
|
675
|
698
|
Converts a value token to its Python value. Raises ValueError on failure.
|
|
676
|
699
|
"""
|
|
677
|
700
|
if datatype == cls.TYPE_ID:
|
|
678
|
|
- p = re.compile('^[0-9]+$')
|
|
679
|
|
- if p.match(value) is None:
|
|
680
|
|
- raise ValueError(f'Illegal id value "{value}"')
|
|
681
|
|
- # Store it as a str so it can be larger than an int
|
|
|
701
|
+ if not is_user_id(value):
|
|
|
702
|
+ raise ValueError(f'Illegal user id value: {value}')
|
|
682
|
703
|
return value
|
|
683
|
704
|
if datatype == cls.TYPE_MEMBER:
|
|
684
|
|
- p = re.compile('^<@!?([0-9]+)>$')
|
|
685
|
|
- m = p.match(value)
|
|
686
|
|
- if m is None:
|
|
687
|
|
- raise ValueError('Illegal member value. Must be an @ mention.')
|
|
688
|
|
- return m.group(1)
|
|
|
705
|
+ return user_id_from_mention(value)
|
|
689
|
706
|
if datatype == cls.TYPE_TEXT:
|
|
690
|
|
- # Must be quoted.
|
|
691
|
|
- if len(value) < 2 or \
|
|
692
|
|
- value[0:1] not in cls.STRING_QUOTE_CHARS or \
|
|
693
|
|
- value[-1:] not in cls.STRING_QUOTE_CHARS or \
|
|
694
|
|
- value[0:1] != value[-1:]:
|
|
695
|
|
- raise ValueError(f'Not a quoted string value: {value}')
|
|
696
|
|
- return value[1:-1]
|
|
|
707
|
+ return str_from_quoted_str(value)
|
|
697
|
708
|
if datatype == cls.TYPE_INT:
|
|
698
|
709
|
return int(value)
|
|
699
|
710
|
if datatype == cls.TYPE_FLOAT:
|
|
700
|
711
|
return float(value)
|
|
701
|
712
|
if datatype == cls.TYPE_TIMESPAN:
|
|
702
|
|
- return parse_timedelta(value)
|
|
|
713
|
+ return timedelta_from_str(value)
|
|
703
|
714
|
raise ValueError(f'Unhandled datatype {datatype}')
|