Просмотр исходного кода

PyCharm linter stuff. URLs now handled better in edit log message diffs

master
Rocketsoup 2 месяцев назад
Родитель
Сommit
f064428bd2

+ 3
- 0
.gitignore Просмотреть файл

120
 # Rocketbot stuff
120
 # Rocketbot stuff
121
 /config.py
121
 /config.py
122
 /config/**
122
 /config/**
123
+
124
+# PyCharm
125
+.idea

+ 5
- 0
README.md Просмотреть файл

2
 
2
 
3
 Experimental Discord bot written in Python.
3
 Experimental Discord bot written in Python.
4
 
4
 
5
+## Requirements
6
+
7
+* Written for Python 3.9
8
+* Install dependencies with `pip3.9 install -r requirements.txt`
9
+
5
 ## Usage
10
 ## Usage
6
 
11
 
7
 * To see the list of commands, type `$rb_help`.
12
 * To see the list of commands, type `$rb_help`.

+ 1
- 0
requirements.txt Просмотреть файл

1
+discord.py == 2.3.2

+ 2
- 1
rocketbot/botmessage.py Просмотреть файл

317
 		s += self.text
317
 		s += self.text
318
 
318
 
319
 		if self.quote:
319
 		if self.quote:
320
-			s += f'\n\n> {self.quote}'
320
+			quoted = '\n> '.join(self.quote.splitlines())
321
+			s += f'\n\n> {quoted}'
321
 
322
 
322
 		if len(self.__reactions) > 0:
323
 		if len(self.__reactions) > 0:
323
 			s += '\n\nAvailable actions:'
324
 			s += '\n\nAvailable actions:'

+ 8
- 8
rocketbot/cogs/autokickcog.py Просмотреть файл

9
 from rocketbot.cogs.basecog import BaseCog, BotMessage, CogSetting
9
 from rocketbot.cogs.basecog import BaseCog, BotMessage, CogSetting
10
 from rocketbot.collections import AgeBoundDict
10
 from rocketbot.collections import AgeBoundDict
11
 from rocketbot.storage import Storage
11
 from rocketbot.storage import Storage
12
-from rocketbot.utils import bot_log
13
 
12
 
14
 class AutoKickContext:
13
 class AutoKickContext:
15
 	"""
14
 	"""
83
 			self.log(guild, f'New member {member.name} status is {member.status}')
82
 			self.log(guild, f'New member {member.name} status is {member.status}')
84
 			self.status_check_members.append(StatusCheckContext(member))
83
 			self.status_check_members.append(StatusCheckContext(member))
85
 			return
84
 			return
86
-		self.__kick_or_ban_if_needed(member)
85
+		await self.__kick_or_ban_if_needed(member)
87
 
86
 
88
 	@tasks.loop(seconds=5.0)
87
 	@tasks.loop(seconds=5.0)
89
 	async def status_check_timer(self):
88
 	async def status_check_timer(self):
148
 	@staticmethod
147
 	@staticmethod
149
 	def ordinal(val: int):
148
 	def ordinal(val: int):
150
 		'Formats an integer with an ordinal suffix (English only)'
149
 		'Formats an integer with an ordinal suffix (English only)'
151
-		if val % 10 == 1:
152
-			return f'{val}st'
153
-		if val % 10 == 2:
154
-			return f'{val}nd'
155
-		if val % 10 == 3:
156
-			return f'{val}rd'
150
+		if val % 100 < 10 or val % 100 > 20:
151
+			if val % 10 == 1:
152
+				return f'{val}st'
153
+			if val % 10 == 2:
154
+				return f'{val}nd'
155
+			if val % 10 == 3:
156
+				return f'{val}rd'
157
 		return f'{val}th'
157
 		return f'{val}th'

+ 11
- 6
rocketbot/cogs/basecog.py Просмотреть файл

1
 """
1
 """
2
 Base cog class and helper classes.
2
 Base cog class and helper classes.
3
 """
3
 """
4
-from datetime import datetime, timedelta
5
-from discord import Guild, Member, Message, RawReactionActionEvent
4
+from datetime import datetime, timedelta, timezone
5
+from typing import Optional
6
+
7
+from discord import Guild, Member, Message, RawReactionActionEvent, TextChannel
6
 from discord.abc import GuildChannel
8
 from discord.abc import GuildChannel
7
 from discord.ext import commands
9
 from discord.ext import commands
8
 
10
 
158
 	def __bot_messages(cls, guild: Guild) -> AgeBoundDict:
160
 	def __bot_messages(cls, guild: Guild) -> AgeBoundDict:
159
 		bm = Storage.get_state_value(guild, 'bot_messages')
161
 		bm = Storage.get_state_value(guild, 'bot_messages')
160
 		if bm is None:
162
 		if bm is None:
161
-			far_future = datetime.utcnow() + timedelta(days=1000)
163
+			far_future = datetime.now(timezone.utc) + timedelta(days=1000)
162
 			bm = AgeBoundDict(timedelta(seconds=600),
164
 			bm = AgeBoundDict(timedelta(seconds=600),
163
 				lambda k, v : v.message_sent_at() or far_future)
165
 				lambda k, v : v.message_sent_at() or far_future)
164
 			Storage.set_state_value(guild, 'bot_messages', bm)
166
 			Storage.set_state_value(guild, 'bot_messages', bm)
204
 			# Can't use this reaction with this message
206
 			# Can't use this reaction with this message
205
 			return
207
 			return
206
 
208
 
207
-		channel: GuildChannel = guild.get_channel(payload.channel_id) or await guild.fetch_channel(payload.channel_id)
208
-		if channel is None:
209
+		g_channel: GuildChannel = guild.get_channel(payload.channel_id) or await guild.fetch_channel(payload.channel_id)
210
+		if g_channel is None:
209
 			# Possibly a DM
211
 			# Possibly a DM
210
 			return
212
 			return
213
+		if not isinstance(g_channel, TextChannel):
214
+			return
215
+		channel: TextChannel = g_channel
211
 		member: Member = payload.member
216
 		member: Member = payload.member
212
 		if member is None:
217
 		if member is None:
213
 			return
218
 			return
237
 	# Helpers
242
 	# Helpers
238
 
243
 
239
 	@classmethod
244
 	@classmethod
240
-	def log(cls, guild: Guild, message) -> None:
245
+	def log(cls, guild: Optional[Guild], message) -> None:
241
 		"""
246
 		"""
242
 		Writes a message to the console. Intended for significant events only.
247
 		Writes a message to the console. Intended for significant events only.
243
 		"""
248
 		"""

+ 5
- 5
rocketbot/cogs/configcog.py Просмотреть файл

18
 	@commands.has_permissions(ban_members=True)
18
 	@commands.has_permissions(ban_members=True)
19
 	@commands.guild_only()
19
 	@commands.guild_only()
20
 	async def config(self, context: commands.Context):
20
 	async def config(self, context: commands.Context):
21
-		'General guild configuration command group'
21
+		"""General guild configuration command group"""
22
 		if context.invoked_subcommand is None:
22
 		if context.invoked_subcommand is None:
23
 			await context.send_help()
23
 			await context.send_help()
24
 
24
 
31
 			'will not be posted!',
31
 			'will not be posted!',
32
 	)
32
 	)
33
 	async def setwarningchannel(self, context: commands.Context) -> None:
33
 	async def setwarningchannel(self, context: commands.Context) -> None:
34
-		'Command handler'
34
+		"""Command handler"""
35
 		guild: Guild = context.guild
35
 		guild: Guild = context.guild
36
 		channel: TextChannel = context.channel
36
 		channel: TextChannel = context.channel
37
 		Storage.set_config_value(guild, ConfigKey.WARNING_CHANNEL_ID,
37
 		Storage.set_config_value(guild, ConfigKey.WARNING_CHANNEL_ID,
46
 			'warnings will be posted.',
46
 			'warnings will be posted.',
47
 	)
47
 	)
48
 	async def getwarningchannel(self, context: commands.Context) -> None:
48
 	async def getwarningchannel(self, context: commands.Context) -> None:
49
-		'Command handler'
49
+		"""Command handler"""
50
 		guild: Guild = context.guild
50
 		guild: Guild = context.guild
51
 		channel_id = Storage.get_config_value(guild, ConfigKey.WARNING_CHANNEL_ID)
51
 		channel_id = Storage.get_config_value(guild, ConfigKey.WARNING_CHANNEL_ID)
52
 		if channel_id is None:
52
 		if channel_id is None:
70
 	async def setwarningmention(self,
70
 	async def setwarningmention(self,
71
 			context: commands.Context,
71
 			context: commands.Context,
72
 			mention: str = None) -> None:
72
 			mention: str = None) -> None:
73
-		'Command handler'
73
+		"""Command handler"""
74
 		guild: Guild = context.guild
74
 		guild: Guild = context.guild
75
 		Storage.set_config_value(guild, ConfigKey.WARNING_MENTION, mention)
75
 		Storage.set_config_value(guild, ConfigKey.WARNING_MENTION, mention)
76
 		if mention is None:
76
 		if mention is None:
88
 			'warning messages.'
88
 			'warning messages.'
89
 	)
89
 	)
90
 	async def getwarningmention(self, context: commands.Context) -> None:
90
 	async def getwarningmention(self, context: commands.Context) -> None:
91
-		'Command handler'
91
+		"""Command handler"""
92
 		guild: Guild = context.guild
92
 		guild: Guild = context.guild
93
 		mention: str = Storage.get_config_value(guild, ConfigKey.WARNING_MENTION)
93
 		mention: str = Storage.get_config_value(guild, ConfigKey.WARNING_MENTION)
94
 		if mention is None:
94
 		if mention is None:

+ 1
- 1
rocketbot/cogs/crosspostcog.py Просмотреть файл

172
 			message_type: int = BotMessage.TYPE_INFO if self.was_warned_recently(context.member) \
172
 			message_type: int = BotMessage.TYPE_INFO if self.was_warned_recently(context.member) \
173
 				else BotMessage.TYPE_MOD_WARNING
173
 				else BotMessage.TYPE_MOD_WARNING
174
 			message = BotMessage(context.member.guild, '', message_type, context)
174
 			message = BotMessage(context.member.guild, '', message_type, context)
175
-			message.quote = discordutils.remove_markdown(first_spam_message.clean_content)
175
+			message.quote = discordutils.remove_markdown(first_spam_message.clean_content())
176
 			self.record_warning(context.member)
176
 			self.record_warning(context.member)
177
 		if context.is_autobanned:
177
 		if context.is_autobanned:
178
 			text = f'User {context.member.mention} auto banned for ' + \
178
 			text = f'User {context.member.mention} auto banned for ' + \

+ 5
- 3
rocketbot/cogs/generalcog.py Просмотреть файл

2
 Cog for handling most ungrouped commands and basic behaviors.
2
 Cog for handling most ungrouped commands and basic behaviors.
3
 """
3
 """
4
 import re
4
 import re
5
-from datetime import datetime, timedelta
5
+from datetime import datetime, timedelta, timezone
6
+from typing import Optional
7
+
6
 from discord import Message
8
 from discord import Message
7
 from discord.errors import DiscordException
9
 from discord.errors import DiscordException
8
 from discord.ext import commands
10
 from discord.ext import commands
122
 				f'{CONFIG["failure_emoji"]} age must be a timespan, like "30s", "10m", "1h30m"',
124
 				f'{CONFIG["failure_emoji"]} age must be a timespan, like "30s", "10m", "1h30m"',
123
 				mention_author=False)
125
 				mention_author=False)
124
 			return
126
 			return
125
-		cutoff: datetime = datetime.utcnow() - age_delta
127
+		cutoff: datetime = datetime.now(timezone.utc) - age_delta
126
 		def predicate(message: Message) -> bool:
128
 		def predicate(message: Message) -> bool:
127
 			return str(message.author.id) == member_id and message.created_at >= cutoff
129
 			return str(message.author.id) == member_id and message.created_at >= cutoff
128
 		deleted_messages = []
130
 		deleted_messages = []
137
 			f'messages by <@!{member_id}> from the past {describe_timedelta(age_delta)}.',
139
 			f'messages by <@!{member_id}> from the past {describe_timedelta(age_delta)}.',
138
 			mention_author=False)
140
 			mention_author=False)
139
 
141
 
140
-	def __parse_member_id(self, arg: str) -> str:
142
+	def __parse_member_id(self, arg: str) -> Optional[str]:
141
 		p = re.compile('^<@!?([0-9]+)>$')
143
 		p = re.compile('^<@!?([0-9]+)>$')
142
 		m = p.match(arg)
144
 		m = p.match(arg)
143
 		if m:
145
 		if m:

+ 6
- 6
rocketbot/cogs/joinagecog.py Просмотреть файл

1
 import weakref
1
 import weakref
2
 
2
 
3
-from datetime import datetime, timedelta
3
+from datetime import datetime, timedelta, timezone
4
 from discord import Guild, Member
4
 from discord import Guild, Member
5
 from discord.ext import commands
5
 from discord.ext import commands
6
 
6
 
47
 	@commands.has_permissions(ban_members=True)
47
 	@commands.has_permissions(ban_members=True)
48
 	@commands.guild_only()
48
 	@commands.guild_only()
49
 	async def joinage(self, context: commands.Context):
49
 	async def joinage(self, context: commands.Context):
50
-		'Join age tracking'
50
+		"""Join age tracking"""
51
 		if context.invoked_subcommand is None:
51
 		if context.invoked_subcommand is None:
52
 			await context.send_help()
52
 			await context.send_help()
53
 
53
 
58
 		usage='<time_period>'
58
 		usage='<time_period>'
59
 	)
59
 	)
60
 	async def search(self, context: commands.Context, timespan: str):
60
 	async def search(self, context: commands.Context, timespan: str):
61
-		'Command handler'
61
+		"""Command handler"""
62
 		guild: Guild = context.guild
62
 		guild: Guild = context.guild
63
 		recent_joins: AgeBoundList = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
63
 		recent_joins: AgeBoundList = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
64
 		if recent_joins is None:
64
 		if recent_joins is None:
65
 			max_age: timedelta = timedelta(seconds=self.get_guild_setting(guild, self.SETTING_JOIN_TIME))
65
 			max_age: timedelta = timedelta(seconds=self.get_guild_setting(guild, self.SETTING_JOIN_TIME))
66
-			recent_joins = AgeBoundList(max_age, lambda i, member : member.joined_at)
66
+			recent_joins = AgeBoundList(max_age, lambda i, member0 : member0.joined_at)
67
 			Storage.set_state_value(guild, self.STATE_KEY_RECENT_JOINS, recent_joins)
67
 			Storage.set_state_value(guild, self.STATE_KEY_RECENT_JOINS, recent_joins)
68
 		results: list = []
68
 		results: list = []
69
 		ts: timedelta = timedelta_from_str(timespan)
69
 		ts: timedelta = timedelta_from_str(timespan)
70
-		cutoff: datetime = datetime.utcnow() - ts
70
+		cutoff: datetime = datetime.now(timezone.utc) - ts
71
 		for member in recent_joins:
71
 		for member in recent_joins:
72
 			if member.joined_at > cutoff:
72
 			if member.joined_at > cutoff:
73
 				results.append(member)
73
 				results.append(member)
106
 
106
 
107
 	@commands.Cog.listener()
107
 	@commands.Cog.listener()
108
 	async def on_member_join(self, member: Member) -> None:
108
 	async def on_member_join(self, member: Member) -> None:
109
-		'Event handler'
109
+		"""Event handler"""
110
 		guild: Guild = member.guild
110
 		guild: Guild = member.guild
111
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
111
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
112
 			return
112
 			return

+ 3
- 3
rocketbot/cogs/joinraidcog.py Просмотреть файл

22
 		self.warning_message_ref = None
22
 		self.warning_message_ref = None
23
 
23
 
24
 	def last_join_time(self) -> datetime:
24
 	def last_join_time(self) -> datetime:
25
-		'Returns when the most recent member join was, in UTC'
25
+		"""Returns when the most recent member join was, in UTC"""
26
 		return self.join_members[-1].joined_at
26
 		return self.join_members[-1].joined_at
27
 
27
 
28
 class JoinRaidCog(BaseCog, name='Join Raids'):
28
 class JoinRaidCog(BaseCog, name='Join Raids'):
62
 	@commands.has_permissions(ban_members=True)
62
 	@commands.has_permissions(ban_members=True)
63
 	@commands.guild_only()
63
 	@commands.guild_only()
64
 	async def joinraid(self, context: commands.Context):
64
 	async def joinraid(self, context: commands.Context):
65
-		'Join raid detection command group'
65
+		"""Join raid detection command group"""
66
 		if context.invoked_subcommand is None:
66
 		if context.invoked_subcommand is None:
67
 			await context.send_help()
67
 			await context.send_help()
68
 
68
 
92
 
92
 
93
 	@commands.Cog.listener()
93
 	@commands.Cog.listener()
94
 	async def on_member_join(self, member: Member) -> None:
94
 	async def on_member_join(self, member: Member) -> None:
95
-		'Event handler'
95
+		"""Event handler"""
96
 		guild: Guild = member.guild
96
 		guild: Guild = member.guild
97
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
97
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
98
 			return
98
 			return

+ 30
- 9
rocketbot/cogs/logcog.py Просмотреть файл

1
 """
1
 """
2
 Cog for detecting large numbers of guild joins in a short period of time.
2
 Cog for detecting large numbers of guild joins in a short period of time.
3
 """
3
 """
4
-import weakref
5
 from collections.abc import Sequence
4
 from collections.abc import Sequence
6
-from datetime import datetime, timedelta
5
+from datetime import datetime
7
 from discord import AuditLogAction, AuditLogEntry, Emoji, Guild, GuildSticker, Invite, Member, Message, RawBulkMessageDeleteEvent, RawMessageDeleteEvent, RawMessageUpdateEvent, Role, Thread, User
6
 from discord import AuditLogAction, AuditLogEntry, Emoji, Guild, GuildSticker, Invite, Member, Message, RawBulkMessageDeleteEvent, RawMessageDeleteEvent, RawMessageUpdateEvent, Role, Thread, User
8
 from discord.abc import GuildChannel
7
 from discord.abc import GuildChannel
9
 from discord.ext import commands
8
 from discord.ext import commands
10
 from discord.utils import escape_markdown
9
 from discord.utils import escape_markdown
11
-from typing import List, Optional, Tuple, Union
10
+from typing import Optional, Tuple, Union
12
 import difflib
11
 import difflib
13
-import traceback
12
+import re
14
 
13
 
15
-from config import CONFIG
16
-from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
17
-from rocketbot.collections import AgeBoundList
18
-from rocketbot.storage import Storage
14
+from rocketbot.cogs.basecog import BaseCog, BotMessage, CogSetting
19
 
15
 
20
 class LoggingCog(BaseCog, name='Logging'):
16
 class LoggingCog(BaseCog, name='Logging'):
21
 	"""
17
 	"""
457
 			# Most likely an embed being asynchronously populated by server
453
 			# Most likely an embed being asynchronously populated by server
458
 			return
454
 			return
459
 		if content_changed:
455
 		if content_changed:
460
-			(before_markdown, after_markdown) = self.__diff(self.__quote_markdown(before.content), \
456
+			(before_markdown, after_markdown) = self.__diff(self.__quote_markdown(before.content),
461
 													  self.__quote_markdown(after.content))
457
 													  self.__quote_markdown(after.content))
462
 		else:
458
 		else:
463
 			before_markdown = self.__quote_markdown(before.content)
459
 			before_markdown = self.__quote_markdown(before.content)
722
 		return f'**{user.name}** ({user.display_name} {user.id})'
718
 		return f'**{user.name}** ({user.display_name} {user.id})'
723
 
719
 
724
 	def __diff(self, a: str, b: str) -> Tuple[str, str]:
720
 	def __diff(self, a: str, b: str) -> Tuple[str, str]:
721
+		# URLs don't work well in the diffs. Replace them with private use characters, one per unique URL.
722
+		preserved_sequences = []
723
+		def sub_token(match: re.Match) -> str:
724
+			seq = match.group(0)
725
+			sequence_index = len(preserved_sequences)
726
+			if seq in preserved_sequences:
727
+				sequence_index = preserved_sequences.index(seq)
728
+			else:
729
+				preserved_sequences.append(seq)
730
+			return chr(0xe000 + sequence_index)
731
+		url_regex = r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'
732
+		a = re.sub(url_regex, sub_token, a)
733
+		b = re.sub(url_regex, sub_token, b)
734
+
725
 		deletion_start = '~~'
735
 		deletion_start = '~~'
726
 		deletion_end = '~~'
736
 		deletion_end = '~~'
727
 		addition_start = '**'
737
 		addition_start = '**'
758
 			markdown_a += deletion_end
768
 			markdown_a += deletion_end
759
 		if b_open:
769
 		if b_open:
760
 			markdown_b += addition_end
770
 			markdown_b += addition_end
771
+
772
+		# Sub URLs back in
773
+		def unsub_token(match: re.Match) -> str:
774
+			char = match.group(0)
775
+			index = ord(char) - 0xe000
776
+			if 0 <= index < len(preserved_sequences):
777
+				return preserved_sequences[index]
778
+			return char
779
+		markdown_a = re.sub(r'[\ue000-\uefff]', unsub_token, markdown_a)
780
+		markdown_b = re.sub(r'[\ue000-\uefff]', unsub_token, markdown_b)
781
+
761
 		return (markdown_a, markdown_b)
782
 		return (markdown_a, markdown_b)

+ 7
- 5
rocketbot/cogs/patterncog.py Просмотреть файл

3
 automated actions on them.
3
 automated actions on them.
4
 """
4
 """
5
 from datetime import datetime
5
 from datetime import datetime
6
+from typing import Optional
7
+
6
 from discord import Guild, Member, Message, utils as discordutils
8
 from discord import Guild, Member, Message, utils as discordutils
7
 from discord.ext import commands
9
 from discord.ext import commands
8
 
10
 
60
 	def __save_patterns(cls,
62
 	def __save_patterns(cls,
61
 			guild: Guild,
63
 			guild: Guild,
62
 			patterns: dict[str, PatternStatement]) -> None:
64
 			patterns: dict[str, PatternStatement]) -> None:
63
-		to_save: list[dict] = list(map(PatternStatement.to_json, patterns.values()))
65
+		to_save: list[dict] = list(map(lambda ps: ps.to_json(), patterns.values()))
64
 		cls.set_guild_setting(guild, cls.SETTING_PATTERNS, to_save)
66
 		cls.set_guild_setting(guild, cls.SETTING_PATTERNS, to_save)
65
 
67
 
66
 	@classmethod
68
 	@classmethod
67
-	def __get_last_matched(cls, guild: Guild, name: str) -> datetime:
68
-		last_matched: dict[name, datetime] = Storage.get_state_value(guild, 'PatternCog.last_matched')
69
+	def __get_last_matched(cls, guild: Guild, name: str) -> Optional[datetime]:
70
+		last_matched: dict[str, datetime] = Storage.get_state_value(guild, 'PatternCog.last_matched')
69
 		if last_matched:
71
 		if last_matched:
70
 			return last_matched.get(name)
72
 			return last_matched.get(name)
71
 		return None
73
 		return None
72
 
74
 
73
 	@classmethod
75
 	@classmethod
74
 	def __set_last_matched(cls, guild: Guild, name: str, time: datetime) -> None:
76
 	def __set_last_matched(cls, guild: Guild, name: str, time: datetime) -> None:
75
-		last_matched: dict[name, datetime] = Storage.get_state_value(guild, 'PatternCog.last_matched')
77
+		last_matched: dict[str, datetime] = Storage.get_state_value(guild, 'PatternCog.last_matched')
76
 		if last_matched is None:
78
 		if last_matched is None:
77
 			last_matched = {}
79
 			last_matched = {}
78
 			Storage.set_state_value(guild, 'PatternCog.last_matched', last_matched)
80
 			Storage.set_state_value(guild, 'PatternCog.last_matched', last_matched)
156
 				type=message_type,
158
 				type=message_type,
157
 				context=context)
159
 				context=context)
158
 			self.record_warning(message.author)
160
 			self.record_warning(message.author)
159
-			bm.quote = discordutils.remove_markdown(message.clean_content)
161
+			bm.quote = discordutils.remove_markdown(message.clean_content())
160
 			await bm.set_reactions(BotMessageReaction.standard_set(
162
 			await bm.set_reactions(BotMessageReaction.standard_set(
161
 				did_delete=context.is_deleted,
163
 				did_delete=context.is_deleted,
162
 				did_kick=context.is_kicked,
164
 				did_kick=context.is_kicked,

+ 9
- 9
rocketbot/cogs/urlspamcog.py Просмотреть файл

33
 	SETTING_ACTION = CogSetting('action', str,
33
 	SETTING_ACTION = CogSetting('action', str,
34
 			brief='action to take on spam',
34
 			brief='action to take on spam',
35
 			description='The action to take on detected URL spam.',
35
 			description='The action to take on detected URL spam.',
36
-			enum_values=set(['nothing', 'modwarn', 'delete', 'kick', 'ban']))
36
+			enum_values={'nothing', 'modwarn', 'delete', 'kick', 'ban'})
37
 	SETTING_JOIN_AGE = CogSetting('joinage', float,
37
 	SETTING_JOIN_AGE = CogSetting('joinage', float,
38
 			brief='seconds since member joined',
38
 			brief='seconds since member joined',
39
 			description='The minimum seconds since the user joined the ' + \
39
 			description='The minimum seconds since the user joined the ' + \
48
 			brief='action to take on deceptive link markdown',
48
 			brief='action to take on deceptive link markdown',
49
 			description='The action to take on chat messages with links ' + \
49
 			description='The action to take on chat messages with links ' + \
50
 				'where the text looks like a different URL than the actual link.',
50
 				'where the text looks like a different URL than the actual link.',
51
-			enum_values=set(['nothing', 'modwarn', 'modwarndelete', \
52
-				'chatwarn', 'chatwarndelete', 'delete', 'kick', 'ban']))
51
+			enum_values={'nothing', 'modwarn', 'modwarndelete',
52
+				'chatwarn', 'chatwarndelete', 'delete', 'kick', 'ban'})
53
 
53
 
54
 	def __init__(self, bot):
54
 	def __init__(self, bot):
55
 		super().__init__(bot)
55
 		super().__init__(bot)
79
 			return
79
 			return
80
 		if not self.get_guild_setting(message.guild, self.SETTING_ENABLED):
80
 		if not self.get_guild_setting(message.guild, self.SETTING_ENABLED):
81
 			return
81
 			return
82
-		await self.check_message_recency(message);
83
-		await self.check_deceptive_links(message);
82
+		await self.check_message_recency(message)
83
+		await self.check_deceptive_links(message)
84
 
84
 
85
 	async def check_message_recency(self, message: Message):
85
 	async def check_message_recency(self, message: Message):
86
 		'Checks if the message was sent too recently by a new user'
86
 		'Checks if the message was sent too recently by a new user'
132
 					f'{join_age_str} after joining.',
132
 					f'{join_age_str} after joining.',
133
 					type = BotMessage.TYPE_MOD_WARNING if needs_attention else BotMessage.TYPE_INFO,
133
 					type = BotMessage.TYPE_MOD_WARNING if needs_attention else BotMessage.TYPE_INFO,
134
 					context = context)
134
 					context = context)
135
-			bm.quote = discordutils.remove_markdown(message.clean_content)
135
+			bm.quote = discordutils.remove_markdown(message.clean_content())
136
 			await bm.set_reactions(BotMessageReaction.standard_set(
136
 			await bm.set_reactions(BotMessageReaction.standard_set(
137
 				did_delete=context.is_deleted,
137
 				did_delete=context.is_deleted,
138
 				did_kick=context.is_kicked,
138
 				did_kick=context.is_kicked,
197
 		# Strip markdown that can safely contain URL sequences
197
 		# Strip markdown that can safely contain URL sequences
198
 		content = re.sub(r'`[^`]+`', '', content)  # `inline code`
198
 		content = re.sub(r'`[^`]+`', '', content)  # `inline code`
199
 		content = re.sub(r'```.+?```', '', content, re.DOTALL)  # ``` code block ```
199
 		content = re.sub(r'```.+?```', '', content, re.DOTALL)  # ``` code block ```
200
-		matches = re.findall(r'\[([^\]]+)\]\(([^\)]+)\)', content)
200
+		matches = re.findall(r'\[([^]]+)]\(([^)]+)\)', content)
201
 		for match in matches:
201
 		for match in matches:
202
 			original_label: str = match[0].strip()
202
 			original_label: str = match[0].strip()
203
 			original_link: str = match[1].strip()
203
 			original_link: str = match[1].strip()
246
 		port_pattern = '(?::[0-9]+)?'
246
 		port_pattern = '(?::[0-9]+)?'
247
 		path_pattern = r'(?:/[^ \]\)]*)?'
247
 		path_pattern = r'(?:/[^ \]\)]*)?'
248
 		pattern = r'^' + host_pattern + port_pattern + path_pattern + '$'
248
 		pattern = r'^' + host_pattern + port_pattern + path_pattern + '$'
249
-		return re.match(pattern, s, re.IGNORECASE) != None
249
+		return re.match(pattern, s, re.IGNORECASE) is not None
250
 
250
 
251
 	async def on_mod_react(self,
251
 	async def on_mod_react(self,
252
 			bot_message: BotMessage,
252
 			bot_message: BotMessage,
291
 
291
 
292
 	@classmethod
292
 	@classmethod
293
 	def __contains_url(cls, text: str) -> bool:
293
 	def __contains_url(cls, text: str) -> bool:
294
-		p = re.compile(r'http(?:s)?://[^\s]+')
294
+		p = re.compile(r'https?://\S+')
295
 		return p.search(text) is not None
295
 		return p.search(text) is not None

+ 5
- 3
rocketbot/cogs/usernamecog.py Просмотреть файл

1
 """
1
 """
2
 Cog for detecting username patterns.
2
 Cog for detecting username patterns.
3
 """
3
 """
4
+from typing import Optional
5
+
4
 from discord import Guild, Member
6
 from discord import Guild, Member
5
 from discord.ext import commands
7
 from discord.ext import commands
6
 
8
 
14
 	"""
16
 	"""
15
 	def __init__(self, member: Member) -> None:
17
 	def __init__(self, member: Member) -> None:
16
 		self.member: Member = member
18
 		self.member: Member = member
17
-		self.kicked_by: Member = None
18
-		self.banned_by: Member = None
19
-		self.ignored_by: Member = None
19
+		self.kicked_by: Optional[Member] = None
20
+		self.banned_by: Optional[Member] = None
21
+		self.ignored_by: Optional[Member] = None
20
 
22
 
21
 	def reactions(self) -> list[BotMessageReaction]:
23
 	def reactions(self) -> list[BotMessageReaction]:
22
 		"""
24
 		"""

+ 44
- 45
rocketbot/cogsetting.py Просмотреть файл

1
 """
1
 """
2
 A guild configuration setting available for editing via bot commands.
2
 A guild configuration setting available for editing via bot commands.
3
 """
3
 """
4
-from types import coroutine
5
-from typing import Any, Optional, Type
4
+from typing import Any, Callable, Coroutine, Optional, Type
6
 
5
 
7
 from discord.ext import commands
6
 from discord.ext import commands
8
-from discord.ext.commands import Bot, Cog, Command, Context, Group
7
+from discord.ext.commands import Bot, Command, Context, Group, Cog
9
 
8
 
10
 from config import CONFIG
9
 from config import CONFIG
11
 from rocketbot.storage import Storage
10
 from rocketbot.storage import Storage
12
 from rocketbot.utils import first_command_group
11
 from rocketbot.utils import first_command_group
13
 
12
 
13
+def _fix_command(command: Command) -> None:
14
+	"""
15
+	HACK: Fixes bug in discord.py 2.3.2 where it's requiring the user to
16
+	supply the context argument. This removes that argument from the list.
17
+	"""
18
+	params = command.params
19
+	del params['context']
20
+	command.params = params
21
+
14
 class CogSetting:
22
 class CogSetting:
15
 	"""
23
 	"""
16
 	Describes a configuration setting for a guild that can be edited by the
24
 	Describes a configuration setting for a guild that can be edited by the
20
 	"""
28
 	"""
21
 	def __init__(self,
29
 	def __init__(self,
22
 			name: str,
30
 			name: str,
23
-			datatype: Type,
31
+			datatype: Optional[Type],
24
 			brief: Optional[str] = None,
32
 			brief: Optional[str] = None,
25
 			description: Optional[str] = None,
33
 			description: Optional[str] = None,
26
 			usage: Optional[str] = None,
34
 			usage: Optional[str] = None,
90
 
98
 
91
 	def __make_getter_command(self, cog: Cog) -> Command:
99
 	def __make_getter_command(self, cog: Cog) -> Command:
92
 		setting: CogSetting = self
100
 		setting: CogSetting = self
93
-		async def getter(cog: Cog, context: Context) -> None:
101
+		async def getter(cog0: Cog, context: Context) -> None:
94
 			setting_name = setting.name
102
 			setting_name = setting.name
95
-			if context.command.parent:
103
+			if isinstance(context.command.parent, Group):
96
 				setting_name = f'{context.command.parent.name}.{setting_name}'
104
 				setting_name = f'{context.command.parent.name}.{setting_name}'
97
-			key = f'{cog.__class__.__name__}.{setting.name}'
105
+			key = f'{cog0.__class__.__name__}.{setting.name}'
98
 			value = Storage.get_config_value(context.guild, key)
106
 			value = Storage.get_config_value(context.guild, key)
99
 			if value is None:
107
 			if value is None:
100
-				value = cog.get_cog_default(setting.name)
108
+				value = cog0.get_cog_default(setting.name)
101
 				await context.message.reply(
109
 				await context.message.reply(
102
 					f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{value}`',
110
 					f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{value}`',
103
 					mention_author=False)
111
 					mention_author=False)
115
 				commands.guild_only(),
123
 				commands.guild_only(),
116
 			])
124
 			])
117
 		command.cog = cog
125
 		command.cog = cog
118
-		self.__fix_command(command)
126
+		_fix_command(command)
119
 		return command
127
 		return command
120
 
128
 
121
 	def __make_setter_command(self, cog: Cog) -> Command:
129
 	def __make_setter_command(self, cog: Cog) -> Command:
122
 		setting: CogSetting = self
130
 		setting: CogSetting = self
123
-		async def setter_common(cog: Cog, context: Context, new_value) -> None:
131
+		async def setter_common(cog0: Cog, context: Context, new_value) -> None:
124
 			try:
132
 			try:
125
 				setting.validate_value(new_value)
133
 				setting.validate_value(new_value)
126
 			except ValueError as ve:
134
 			except ValueError as ve:
129
 					mention_author=False)
137
 					mention_author=False)
130
 				return
138
 				return
131
 			setting_name = setting.name
139
 			setting_name = setting.name
132
-			if context.command.parent:
140
+			if isinstance(context.command.parent, Group):
133
 				setting_name = f'{context.command.parent.name}.{setting_name}'
141
 				setting_name = f'{context.command.parent.name}.{setting_name}'
134
-			key = f'{cog.__class__.__name__}.{setting.name}'
142
+			key = f'{cog0.__class__.__name__}.{setting.name}'
135
 			Storage.set_config_value(context.guild, key, new_value)
143
 			Storage.set_config_value(context.guild, key, new_value)
136
 			await context.message.reply(
144
 			await context.message.reply(
137
 				f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
145
 				f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
138
 				mention_author=False)
146
 				mention_author=False)
139
-			await cog.on_setting_updated(context.guild, setting)
140
-			cog.log(context.guild, f'{context.author.name} set {key} to {new_value}')
141
-
142
-		async def setter_int(cog, context, new_value: int):
143
-			await setter_common(cog, context, new_value)
144
-		async def setter_float(cog, context, new_value: float):
145
-			await setter_common(cog, context, new_value)
146
-		async def setter_str(cog, context, new_value: str):
147
-			await setter_common(cog, context, new_value)
148
-		async def setter_bool(cog, context, new_value: bool):
149
-			await setter_common(cog, context, new_value)
150
-
151
-		setter: coroutine = None
147
+			await cog0.on_setting_updated(context.guild, setting)
148
+			cog0.log(context.guild, f'{context.author.name} set {key} to {new_value}')
149
+
150
+		async def setter_int(cog1, context, new_value: int):
151
+			await setter_common(cog1, context, new_value)
152
+		async def setter_float(cog2, context, new_value: float):
153
+			await setter_common(cog2, context, new_value)
154
+		async def setter_str(cog3, context, new_value: str):
155
+			await setter_common(cog3, context, new_value)
156
+		async def setter_bool(cog4, context, new_value: bool):
157
+			await setter_common(cog4, context, new_value)
158
+
159
+		setter: Callable[[Cog, Context, Any], Coroutine]
152
 		if setting.datatype == int:
160
 		if setting.datatype == int:
153
 			setter = setter_int
161
 			setter = setter_int
154
 		elif setting.datatype == float:
162
 		elif setting.datatype == float:
173
 		# HACK: Passing `cog` in init gets ignored and set to `None` so set after.
181
 		# HACK: Passing `cog` in init gets ignored and set to `None` so set after.
174
 		# This ensures the callback is passed the cog as `self` argument.
182
 		# This ensures the callback is passed the cog as `self` argument.
175
 		command.cog = cog
183
 		command.cog = cog
176
-		self.__fix_command(command)
184
+		_fix_command(command)
177
 		return command
185
 		return command
178
 
186
 
179
 	def __make_enable_command(self, cog: Cog) -> Command:
187
 	def __make_enable_command(self, cog: Cog) -> Command:
180
 		setting: CogSetting = self
188
 		setting: CogSetting = self
181
-		async def enabler(cog: Cog, context: Context) -> None:
182
-			key = f'{cog.__class__.__name__}.{setting.name}'
189
+		async def enabler(cog0: Cog, context: Context) -> None:
190
+			key = f'{cog0.__class__.__name__}.{setting.name}'
183
 			Storage.set_config_value(context.guild, key, True)
191
 			Storage.set_config_value(context.guild, key, True)
184
 			await context.message.reply(
192
 			await context.message.reply(
185
 				f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} enabled.',
193
 				f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} enabled.',
186
 				mention_author=False)
194
 				mention_author=False)
187
-			await cog.on_setting_updated(context.guild, setting)
188
-			cog.log(context.guild, f'{context.author.name} enabled {cog.__class__.__name__}')
195
+			await cog0.on_setting_updated(context.guild, setting)
196
+			cog0.log(context.guild, f'{context.author.name} enabled {cog0.__class__.__name__}')
189
 
197
 
190
 		command = Command(
198
 		command = Command(
191
 			enabler,
199
 			enabler,
197
 				commands.guild_only(),
205
 				commands.guild_only(),
198
 			])
206
 			])
199
 		command.cog = cog
207
 		command.cog = cog
200
-		self.__fix_command(command)
208
+		_fix_command(command)
201
 		return command
209
 		return command
202
 
210
 
203
 	def __make_disable_command(self, cog: Cog) -> Command:
211
 	def __make_disable_command(self, cog: Cog) -> Command:
204
 		setting: CogSetting = self
212
 		setting: CogSetting = self
205
-		async def disabler(cog: Cog, context: Context) -> None:
206
-			key = f'{cog.__class__.__name__}.{setting.name}'
213
+		async def disabler(cog0: Cog, context: Context) -> None:
214
+			key = f'{cog0.__class__.__name__}.{setting.name}'
207
 			Storage.set_config_value(context.guild, key, False)
215
 			Storage.set_config_value(context.guild, key, False)
208
 			await context.message.reply(
216
 			await context.message.reply(
209
 				f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} disabled.',
217
 				f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} disabled.',
210
 				mention_author=False)
218
 				mention_author=False)
211
-			await cog.on_setting_updated(context.guild, setting)
212
-			cog.log(context.guild, f'{context.author.name} disabled {cog.__class__.__name__}')
219
+			await cog0.on_setting_updated(context.guild, setting)
220
+			cog0.log(context.guild, f'{context.author.name} disabled {cog0.__class__.__name__}')
213
 
221
 
214
 		command = Command(
222
 		command = Command(
215
 			disabler,
223
 			disabler,
221
 				commands.guild_only(),
229
 				commands.guild_only(),
222
 			])
230
 			])
223
 		command.cog = cog
231
 		command.cog = cog
224
-		self.__fix_command(command)
232
+		_fix_command(command)
225
 		return command
233
 		return command
226
 
234
 
227
-	def __fix_command(self, command: Command) -> None:
228
-		"""
229
-		HACK: Fixes bug in discord.py 2.3.2 where it's requiring the user to
230
-		supply the context argument. This removes that argument from the list.
231
-		"""
232
-		params = command.params
233
-		del params['context']
234
-		command.params = params
235
-
236
 	@classmethod
235
 	@classmethod
237
 	def set_up_all(cls, cog: Cog, bot: Bot, settings: list) -> None:
236
 	def set_up_all(cls, cog: Cog, bot: Bot, settings: list) -> None:
238
 		"""
237
 		"""

+ 1
- 1
rocketbot/pattern.py Просмотреть файл

4
 """
4
 """
5
 import re
5
 import re
6
 from abc import ABCMeta, abstractmethod
6
 from abc import ABCMeta, abstractmethod
7
-from datetime, timezone import datetime
7
+from datetime import datetime, timezone
8
 from typing import Any
8
 from typing import Any
9
 
9
 
10
 from discord import Message, utils as discordutils
10
 from discord import Message, utils as discordutils

+ 11
- 11
rocketbot/utils.py Просмотреть файл

78
 		components = components[0:max_components]
78
 		components = components[0:max_components]
79
 	return ' '.join(components)
79
 	return ' '.join(components)
80
 
80
 
81
-def first_command_group(cog: Cog) -> Group:
82
-	'Returns the first command Group found in a cog.'
81
+def first_command_group(cog: Cog) -> Optional[Group]:
82
+	"""Returns the first command Group found in a cog."""
83
 	for member_name in dir(cog):
83
 	for member_name in dir(cog):
84
 		member = getattr(cog, member_name)
84
 		member = getattr(cog, member_name)
85
 		if isinstance(member, Group):
85
 		if isinstance(member, Group):
87
 	return None
87
 	return None
88
 
88
 
89
 def bot_log(guild: Optional[Guild], cog_class: Optional[Type], message: Any) -> None:
89
 def bot_log(guild: Optional[Guild], cog_class: Optional[Type], message: Any) -> None:
90
-	'Logs a message to stdout with time, cog, and guild info.'
90
+	"""Logs a message to stdout with time, cog, and guild info."""
91
 	now: datetime = datetime.now() # local
91
 	now: datetime = datetime.now() # local
92
 	s = f'[{now.strftime("%Y-%m-%dT%H:%M:%S")}|'
92
 	s = f'[{now.strftime("%Y-%m-%dT%H:%M:%S")}|'
93
 	s += f'{cog_class.__name__}|' if cog_class else '-|'
93
 	s += f'{cog_class.__name__}|' if cog_class else '-|'
102
 __ROLE_MENTION_REGEX: re.Pattern = re.compile('^<@&([0-9]{17,20})>$')
102
 __ROLE_MENTION_REGEX: re.Pattern = re.compile('^<@&([0-9]{17,20})>$')
103
 
103
 
104
 def is_user_id(val: str) -> bool:
104
 def is_user_id(val: str) -> bool:
105
-	'Tests if a string is in user/role ID format.'
105
+	"""Tests if a string is in user/role ID format."""
106
 	return __ID_REGEX.match(val) is not None
106
 	return __ID_REGEX.match(val) is not None
107
 
107
 
108
 def is_mention(val: str) -> bool:
108
 def is_mention(val: str) -> bool:
109
-	'Tests if a string is a user or role mention.'
109
+	"""Tests if a string is a user or role mention."""
110
 	return __MENTION_REGEX.match(val) is not None
110
 	return __MENTION_REGEX.match(val) is not None
111
 
111
 
112
 def is_role_mention(val: str) -> bool:
112
 def is_role_mention(val: str) -> bool:
113
-	'Tests if a string is a role mention.'
113
+	"""Tests if a string is a role mention."""
114
 	return __ROLE_MENTION_REGEX.match(val) is not None
114
 	return __ROLE_MENTION_REGEX.match(val) is not None
115
 
115
 
116
 def is_user_mention(val: str) -> bool:
116
 def is_user_mention(val: str) -> bool:
117
-	'Tests if a string is a user mention.'
117
+	"""Tests if a string is a user mention."""
118
 	return __USER_MENTION_REGEX.match(val) is not None
118
 	return __USER_MENTION_REGEX.match(val) is not None
119
 
119
 
120
 def user_id_from_mention(mention: str) -> str:
120
 def user_id_from_mention(mention: str) -> str:
121
-	'Extracts the user ID from a mention. Raises a ValueError if malformed.'
121
+	"""Extracts the user ID from a mention. Raises a ValueError if malformed."""
122
 	m = __USER_MENTION_REGEX.match(mention)
122
 	m = __USER_MENTION_REGEX.match(mention)
123
 	if m:
123
 	if m:
124
 		return m.group(1)
124
 		return m.group(1)
125
 	raise ValueError(f'"{mention}" is not an @ user mention')
125
 	raise ValueError(f'"{mention}" is not an @ user mention')
126
 
126
 
127
 def mention_from_user_id(user_id: Union[str, int]) -> str:
127
 def mention_from_user_id(user_id: Union[str, int]) -> str:
128
-	'Returns a markdown user mention from a user id.'
128
+	"""Returns a markdown user mention from a user id."""
129
 	return f'<@!{user_id}>'
129
 	return f'<@!{user_id}>'
130
 
130
 
131
 def mention_from_role_id(role_id: Union[str, int]) -> str:
131
 def mention_from_role_id(role_id: Union[str, int]) -> str:
132
-	'Returns a markdown role mention from a role id.'
132
+	"""Returns a markdown role mention from a role id."""
133
 	return f'<@&{role_id}>'
133
 	return f'<@&{role_id}>'
134
 
134
 
135
 def str_from_quoted_str(val: str) -> str:
135
 def str_from_quoted_str(val: str) -> str:
136
-	'Removes the leading and trailing quotes from a string.'
136
+	"""Removes the leading and trailing quotes from a string."""
137
 	if len(val) < 2 or val[0:1] not in __QUOTE_CHARS or val[-1:] not in __QUOTE_CHARS:
137
 	if len(val) < 2 or val[0:1] not in __QUOTE_CHARS or val[-1:] not in __QUOTE_CHARS:
138
 		raise ValueError(f'Not a quoted string: {val}')
138
 		raise ValueError(f'Not a quoted string: {val}')
139
 	return val[1:-1]
139
 	return val[1:-1]

Загрузка…
Отмена
Сохранить