|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+"""
|
|
|
2
|
+Cog for detecting username patterns.
|
|
|
3
|
+"""
|
|
|
4
|
+from discord import Guild, Member
|
|
|
5
|
+from discord.ext import commands
|
|
|
6
|
+
|
|
|
7
|
+from config import CONFIG
|
|
|
8
|
+from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
|
|
|
9
|
+from rocketbot.storage import Storage
|
|
|
10
|
+
|
|
|
11
|
+class UsernamePatternContext:
|
|
|
12
|
+ """
|
|
|
13
|
+ BotMessage context for a flagged username
|
|
|
14
|
+ """
|
|
|
15
|
+ def __init__(self, member: Member) -> None:
|
|
|
16
|
+ self.member: Member = member
|
|
|
17
|
+ self.kicked_by: Member = None
|
|
|
18
|
+ self.banned_by: Member = None
|
|
|
19
|
+ self.ignored_by: Member = None
|
|
|
20
|
+
|
|
|
21
|
+ def reactions(self) -> list[BotMessageReaction]:
|
|
|
22
|
+ """
|
|
|
23
|
+ Generates updated BotMessageReactions based on context state.
|
|
|
24
|
+ """
|
|
|
25
|
+ r: list[BotMessageReaction] = []
|
|
|
26
|
+ if self.ignored_by:
|
|
|
27
|
+ r.append(BotMessageReaction(CONFIG['ignore_emoji'], False, f'Ignored by {self.ignored_by.name}'))
|
|
|
28
|
+ elif self.banned_by:
|
|
|
29
|
+ r.append(BotMessageReaction(CONFIG['ban_emoji'], False, f'Banned by {self.banned_by.name}'))
|
|
|
30
|
+ elif self.kicked_by:
|
|
|
31
|
+ r.append(BotMessageReaction(CONFIG['kick_emoji'], False, f'Kicked by {self.kicked_by.name}'))
|
|
|
32
|
+ r.append(BotMessageReaction(CONFIG['ban_emoji'], True, 'Ban user'))
|
|
|
33
|
+ else:
|
|
|
34
|
+ r.append(BotMessageReaction(CONFIG['kick_emoji'], True, 'Kick user'))
|
|
|
35
|
+ r.append(BotMessageReaction(CONFIG['ban_emoji'], True, 'Ban user'))
|
|
|
36
|
+ r.append(BotMessageReaction(CONFIG['ignore_emoji'], True, 'Ignore warning'))
|
|
|
37
|
+ return r
|
|
|
38
|
+
|
|
|
39
|
+class UsernamePatternCog(BaseCog, name='Username Pattern'):
|
|
|
40
|
+ """
|
|
|
41
|
+ Detects usernames that match certain flagged patterns. Posts a mod warning
|
|
|
42
|
+ message on a match.
|
|
|
43
|
+ """
|
|
|
44
|
+
|
|
|
45
|
+ SETTING_ENABLED = CogSetting('enabled', bool,
|
|
|
46
|
+ brief='username pattern detection',
|
|
|
47
|
+ description='Whether new users are checked for common patterns.')
|
|
|
48
|
+
|
|
|
49
|
+ SETTING_PATTERNS = CogSetting('patterns', None)
|
|
|
50
|
+
|
|
|
51
|
+ def __init__(self, bot):
|
|
|
52
|
+ super().__init__(bot)
|
|
|
53
|
+ self.add_setting(UsernamePatternCog.SETTING_ENABLED)
|
|
|
54
|
+
|
|
|
55
|
+ def __get_patterns(self, guild: Guild) -> list[str]:
|
|
|
56
|
+ """
|
|
|
57
|
+ Returns an array of username patterns.
|
|
|
58
|
+ """
|
|
|
59
|
+ patterns: list[str] = self.get_guild_setting(guild, self.SETTING_PATTERNS)
|
|
|
60
|
+ if patterns is None:
|
|
|
61
|
+ patterns = []
|
|
|
62
|
+ Storage.set_config_value(guild, 'UsernamePatternCog.patterns', patterns)
|
|
|
63
|
+ return patterns
|
|
|
64
|
+
|
|
|
65
|
+ @classmethod
|
|
|
66
|
+ def __save_patterns(cls,
|
|
|
67
|
+ guild: Guild,
|
|
|
68
|
+ patterns: list[str]) -> None:
|
|
|
69
|
+ """
|
|
|
70
|
+ Saves username pattern array.
|
|
|
71
|
+ """
|
|
|
72
|
+ cls.set_guild_setting(guild, cls.SETTING_PATTERNS, patterns)
|
|
|
73
|
+
|
|
|
74
|
+ @commands.group(
|
|
|
75
|
+ brief='Manages username pattern detection'
|
|
|
76
|
+ )
|
|
|
77
|
+ @commands.has_permissions(ban_members=True)
|
|
|
78
|
+ @commands.guild_only()
|
|
|
79
|
+ async def username(self, context: commands.Context):
|
|
|
80
|
+ 'Username pattern command group'
|
|
|
81
|
+ if context.invoked_subcommand is None:
|
|
|
82
|
+ await context.send_help()
|
|
|
83
|
+
|
|
|
84
|
+ @username.command(
|
|
|
85
|
+ brief='Adds a username pattern',
|
|
|
86
|
+ description='Adds a username pattern.',
|
|
|
87
|
+ usage='<pattern>'
|
|
|
88
|
+ )
|
|
|
89
|
+ async def add(self, context: commands.Context, pattern: str) -> None:
|
|
|
90
|
+ 'Command handler'
|
|
|
91
|
+ norm_pattern = pattern.lower()
|
|
|
92
|
+ patterns: list[str] = self.__get_patterns(context.guild)
|
|
|
93
|
+ if norm_pattern in patterns:
|
|
|
94
|
+ await context.reply(f'Pattern `{norm_pattern}` already added.', mention_author=False)
|
|
|
95
|
+ return
|
|
|
96
|
+ patterns.append(norm_pattern)
|
|
|
97
|
+ self.__save_patterns(context.guild, patterns)
|
|
|
98
|
+ await context.reply(f'Pattern `{norm_pattern}` added.', mention_author=False)
|
|
|
99
|
+
|
|
|
100
|
+ @username.command(
|
|
|
101
|
+ brief='Removes a username pattern',
|
|
|
102
|
+ description='Removes an existing username pattern',
|
|
|
103
|
+ usage='<pattern>'
|
|
|
104
|
+ )
|
|
|
105
|
+ async def remove(self, context: commands.Context, pattern: str) -> None:
|
|
|
106
|
+ 'Command handler'
|
|
|
107
|
+ norm_pattern = pattern.lower()
|
|
|
108
|
+ guild: Guild = context.guild
|
|
|
109
|
+ patterns: list[str] = self.__get_patterns(guild)
|
|
|
110
|
+ len_before = len(patterns)
|
|
|
111
|
+ patterns = list(filter(lambda p: p != norm_pattern, patterns))
|
|
|
112
|
+ if len(patterns) == len_before:
|
|
|
113
|
+ await context.reply(f'Pattern `{norm_pattern}` not found.', mention_author=False)
|
|
|
114
|
+ return
|
|
|
115
|
+ self.__save_patterns(guild, patterns)
|
|
|
116
|
+ await context.reply(f'Pattern `{norm_pattern}` removed.', mention_author=False)
|
|
|
117
|
+
|
|
|
118
|
+ @username.command(
|
|
|
119
|
+ brief='Lists username patterns'
|
|
|
120
|
+ )
|
|
|
121
|
+ async def list(self, context: commands.Context) -> None:
|
|
|
122
|
+ 'Command handler'
|
|
|
123
|
+ guild: Guild = context.guild
|
|
|
124
|
+ patterns: list[str] = self.__get_patterns(guild)
|
|
|
125
|
+ if len(patterns) == 0:
|
|
|
126
|
+ await context.reply('No patterns defined', mention_author=False)
|
|
|
127
|
+ else:
|
|
|
128
|
+ msg = 'Patterns:\n\n> `' + '`\n> `'.join(patterns) + '`'
|
|
|
129
|
+ await context.reply(msg, mention_author=False)
|
|
|
130
|
+
|
|
|
131
|
+ @commands.Cog.listener()
|
|
|
132
|
+ async def on_member_join(self, member: Member) -> None:
|
|
|
133
|
+ 'Event handler'
|
|
|
134
|
+ for pattern in self.__get_patterns(member.guild):
|
|
|
135
|
+ if self.matches(pattern, member.name):
|
|
|
136
|
+ await self.handle_match(member, pattern)
|
|
|
137
|
+ elif self.matches(pattern, member.display_name):
|
|
|
138
|
+ await self.handle_match(member, pattern)
|
|
|
139
|
+
|
|
|
140
|
+ def matches(self, pattern: str, subject: str) -> bool:
|
|
|
141
|
+ 'Checks if a username matches a given pattern'
|
|
|
142
|
+ if pattern is None:
|
|
|
143
|
+ return False
|
|
|
144
|
+ if subject is None:
|
|
|
145
|
+ return False
|
|
|
146
|
+ return pattern.lower() in subject.lower()
|
|
|
147
|
+
|
|
|
148
|
+ async def handle_match(self, member: Member, pattern: str) -> None:
|
|
|
149
|
+ """
|
|
|
150
|
+ Handles a username match.
|
|
|
151
|
+ """
|
|
|
152
|
+ # TODO: Prevent double handling?
|
|
|
153
|
+ self.log(member.guild, f'User {member.id} {member.display_name} matches pattern "{pattern}"')
|
|
|
154
|
+ context = UsernamePatternContext(member)
|
|
|
155
|
+ bm = BotMessage(
|
|
|
156
|
+ member.guild,
|
|
|
157
|
+ f'User {member.mention} ({str(member.id)}, {member.display_name}) has ' +
|
|
|
158
|
+ f'username matching pattern `{pattern}`.',
|
|
|
159
|
+ BotMessage.TYPE_MOD_WARNING,
|
|
|
160
|
+ context)
|
|
|
161
|
+ await bm.set_reactions(context.reactions())
|
|
|
162
|
+ await self.post_message(bm)
|
|
|
163
|
+
|
|
|
164
|
+ async def on_mod_react(self,
|
|
|
165
|
+ bot_message: BotMessage,
|
|
|
166
|
+ reaction: BotMessageReaction,
|
|
|
167
|
+ reacted_by: Member) -> None:
|
|
|
168
|
+ context: UsernamePatternContext = bot_message.context
|
|
|
169
|
+ if reaction.emoji == CONFIG['kick_emoji']:
|
|
|
170
|
+ await context.member.kick(
|
|
|
171
|
+ reason=f'Rocketbot: Flagged username pattern. Kicked by {reacted_by.name}.')
|
|
|
172
|
+ context.kicked_by = reacted_by
|
|
|
173
|
+ self.log(context.member.guild, f'User {context.member.name} kicked by {reacted_by.name}')
|
|
|
174
|
+ await bot_message.set_reactions(context.reactions())
|
|
|
175
|
+ elif reaction.emoji == CONFIG['ban_emoji']:
|
|
|
176
|
+ await context.member.ban(
|
|
|
177
|
+ reason=f'Rocketbot: Flagged username pattern. Banned by {reacted_by.name}.',
|
|
|
178
|
+ delete_message_days=0)
|
|
|
179
|
+ context.banned_by = reacted_by
|
|
|
180
|
+ self.log(context.member.guild, f'User {context.member.name} banned by {reacted_by.name}')
|
|
|
181
|
+ await bot_message.set_reactions(context.reactions())
|
|
|
182
|
+ elif reaction.emoji == CONFIG['ignore_emoji']:
|
|
|
183
|
+ context.ignored_by = reacted_by
|
|
|
184
|
+ self.log(context.member.guild, f'Warning ignored by {reacted_by.name}')
|
|
|
185
|
+ await bot_message.set_reactions(context.reactions())
|