Skip to content

Commit 5488859

Browse files
authored
Automod improvements (#41)
* Add normalization to `message_content_contains` condition * Adjust `message_content_matches` condition * Adjust docstrings for some conditions * Implement ValueFormatter for custom event field formatting * Adjust docstring for AutomodLogOptions * Add extra fields to member join/leave events * Implement `log_message` action * Implement role-based (per-guild) permissions * Account for unresolved roles * Disable pings by default in ResponsiveException * Adjust AutomodEvent member fields * Implement custom AllowedMentions for actions * Clarify docstring for `log_message` action * Update changelog
1 parent 549a21d commit 5488859

25 files changed

+672
-81
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [Unreleased]
8+
9+
### Changed
10+
11+
- `automod` improvements (#41):
12+
- Implemented role-based (per-guild) permissions
13+
- Added a new `log_message` action that suppresses pings by default
14+
- Pings are now suppressed by default in error messages
15+
- Added normalization to the `message_content_contains` condition
16+
- Added more fields to some events for string formatting
17+
718
## [0.13.0] - 2021-08-22
819

920
### Added

commanderbot_ext/ext/automod/actions/abc/add_roles_to_target_base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,7 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]:
1818
async def apply(self, event: AutomodEvent):
1919
if member := self.get_target(event):
2020
guild: Guild = member.guild
21+
# TODO Warn about unresolved roles. #logging
2122
roles = [guild.get_role(role_id) for role_id in self.roles]
23+
roles = [role for role in roles if role]
2224
await member.add_roles(roles)

commanderbot_ext/ext/automod/actions/abc/remove_roles_from_target_base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,7 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]:
1818
async def apply(self, event: AutomodEvent):
1919
if member := self.get_target(event):
2020
guild: Guild = member.guild
21+
# TODO Warn about unresolved roles. #logging
2122
roles = [guild.get_role(role_id) for role_id in self.roles]
23+
roles = [role for role in roles if role]
2224
await member.remove_roles(roles)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from dataclasses import dataclass, field
2+
from typing import Dict, Optional, Type, TypeVar
3+
4+
from discord import Color
5+
from discord.abc import Messageable
6+
7+
from commanderbot_ext.ext.automod.automod_action import AutomodAction, AutomodActionBase
8+
from commanderbot_ext.ext.automod.automod_event import AutomodEvent
9+
from commanderbot_ext.lib import AllowedMentions, ChannelID, JsonObject, ValueFormatter
10+
from commanderbot_ext.lib.utils import color_from_field_optional
11+
12+
ST = TypeVar("ST")
13+
14+
15+
@dataclass
16+
class LogMessage(AutomodActionBase):
17+
"""
18+
Send a log message, with pings disabled by default.
19+
20+
Attributes
21+
----------
22+
content
23+
The content of the message to send.
24+
channel
25+
The channel to send the message in. Defaults to the channel in context.
26+
emoji
27+
The emoji used to represent the type of message.
28+
color
29+
The emoji used to represent the type of message.
30+
fields
31+
A custom set of fields to display as part of the message. The key should
32+
correspond to an event field, and the value is the title to use for it.
33+
allowed_mentions
34+
The types of mentions allowed in the message. Unless otherwise specified, all
35+
mentions will be suppressed.
36+
"""
37+
38+
content: Optional[str] = None
39+
channel: Optional[ChannelID] = None
40+
emoji: Optional[str] = None
41+
color: Optional[Color] = None
42+
fields: Optional[Dict[str, str]] = None
43+
allowed_mentions: Optional[AllowedMentions] = None
44+
45+
@classmethod
46+
def from_data(cls: Type[ST], data: JsonObject) -> ST:
47+
color = color_from_field_optional(data, "color")
48+
allowed_mentions = AllowedMentions.from_field_optional(data, "allowed_mentions")
49+
return cls(
50+
description=data.get("description"),
51+
content=data.get("content"),
52+
channel=data.get("channel"),
53+
emoji=data.get("emoji"),
54+
color=color,
55+
fields=data.get("fields"),
56+
allowed_mentions=allowed_mentions,
57+
)
58+
59+
async def resolve_channel(self, event: AutomodEvent) -> Optional[Messageable]:
60+
if self.channel is not None:
61+
return event.bot.get_channel(self.channel)
62+
return event.channel
63+
64+
async def apply(self, event: AutomodEvent):
65+
if channel := await self.resolve_channel(event):
66+
parts = []
67+
if self.emoji:
68+
parts.append(self.emoji)
69+
if self.content:
70+
formatted_content = event.format_content(self.content)
71+
parts.append(formatted_content)
72+
if self.fields:
73+
event_fields = event.get_fields()
74+
field_parts = []
75+
for field_key, field_title in self.fields.items():
76+
field_value = event_fields.get(field_key)
77+
if isinstance(field_value, ValueFormatter):
78+
field_value_str = str(field_value)
79+
else:
80+
field_value_str = f"`{field_value}`"
81+
field_parts.append(f"{field_title} {field_value_str}")
82+
fields_str = ", ".join(field_parts)
83+
parts.append(fields_str)
84+
content = " ".join(parts)
85+
allowed_mentions = self.allowed_mentions or AllowedMentions.none()
86+
await channel.send(content, allowed_mentions=allowed_mentions)
87+
88+
89+
def create_action(data: JsonObject) -> AutomodAction:
90+
return LogMessage.from_data(data)

commanderbot_ext/ext/automod/actions/send_message.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
from dataclasses import dataclass
2-
from typing import Optional
1+
from dataclasses import dataclass, field
2+
from typing import Optional, Type, TypeVar
33

44
from discord.abc import Messageable
55

66
from commanderbot_ext.ext.automod.automod_action import AutomodAction, AutomodActionBase
77
from commanderbot_ext.ext.automod.automod_event import AutomodEvent
8-
from commanderbot_ext.lib import ChannelID, JsonObject
8+
from commanderbot_ext.lib import AllowedMentions, ChannelID, JsonObject
9+
10+
ST = TypeVar("ST")
911

1012

1113
@dataclass
@@ -19,11 +21,24 @@ class SendMessage(AutomodActionBase):
1921
The content of the message to send.
2022
channel
2123
The channel to send the message in. Defaults to the channel in context.
24+
allowed_mentions
25+
The types of mentions allowed in the message. Unless otherwise specified, only
26+
"everyone" mentions will be suppressed.
2227
"""
2328

2429
content: str
25-
2630
channel: Optional[ChannelID] = None
31+
allowed_mentions: Optional[AllowedMentions] = None
32+
33+
@classmethod
34+
def from_data(cls: Type[ST], data: JsonObject) -> ST:
35+
allowed_mentions = AllowedMentions.from_field_optional(data, "allowed_mentions")
36+
return cls(
37+
description=data.get("description"),
38+
content=data.get("content"),
39+
channel=data.get("channel"),
40+
allowed_mentions=allowed_mentions,
41+
)
2742

2843
async def resolve_channel(self, event: AutomodEvent) -> Optional[Messageable]:
2944
if self.channel is not None:
@@ -33,7 +48,11 @@ async def resolve_channel(self, event: AutomodEvent) -> Optional[Messageable]:
3348
async def apply(self, event: AutomodEvent):
3449
if channel := await self.resolve_channel(event):
3550
content = event.format_content(self.content)
36-
await channel.send(content)
51+
allowed_mentions = self.allowed_mentions or AllowedMentions.not_everyone()
52+
await channel.send(
53+
content,
54+
allowed_mentions=allowed_mentions,
55+
)
3756

3857

3958
def create_action(data: JsonObject) -> AutomodAction:

commanderbot_ext/ext/automod/automod_cog.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
RawMessageUpdateEvent,
1111
RawReactionActionEvent,
1212
Reaction,
13+
Role,
1314
TextChannel,
1415
User,
1516
)
1617
from discord.abc import Messageable
18+
from discord.ext import commands
1719
from discord.ext.commands import Bot, Cog, group
1820

1921
from commanderbot_ext.ext.automod.automod_data import AutomodData
@@ -53,6 +55,17 @@ def make_automod_store(bot: Bot, cog: Cog, options: AutomodOptions) -> AutomodSt
5355
raise UnsupportedDatabaseOptions(db_options)
5456

5557

58+
def check_can_run_automod():
59+
async def predicate(ctx: GuildContext):
60+
cog = ctx.cog
61+
actor = ctx.author
62+
if isinstance(cog, AutomodCog) and isinstance(actor, Member):
63+
return await cog.state[ctx.guild].member_has_permission(actor)
64+
return False
65+
66+
return commands.check(predicate)
67+
68+
5669
class AutomodCog(Cog, name="commanderbot_ext.ext.automod"):
5770
"""
5871
Automate a variety of moderation tasks.
@@ -254,7 +267,11 @@ async def on_raw_reaction_remove(self, payload: RawReactionActionEvent):
254267
aliases=["am"],
255268
)
256269
@checks.guild_only()
257-
@checks.is_administrator()
270+
@commands.check_any(
271+
checks.is_administrator(),
272+
check_can_run_automod(),
273+
commands.is_owner(),
274+
)
258275
async def cmd_automod(self, ctx: GuildContext):
259276
if not ctx.invoked_subcommand:
260277
await ctx.send_help(self.cmd_automod)
@@ -269,17 +286,26 @@ async def cmd_automod_options(self, ctx: GuildContext):
269286
if not ctx.invoked_subcommand:
270287
await ctx.send_help(self.cmd_automod_options)
271288

289+
# @@ automod options log
290+
272291
@cmd_automod_options.group(
273292
name="log",
274293
brief="Configure the default logging behaviour.",
275294
)
276295
async def cmd_automod_options_log(self, ctx: GuildContext):
277296
if not ctx.invoked_subcommand:
278297
if ctx.subcommand_passed:
279-
await ctx.send_help(self.cmd_automod_options)
298+
await ctx.send_help(self.cmd_automod_options_log)
280299
else:
281300
await self.state[ctx.guild].show_default_log_options(ctx)
282301

302+
@cmd_automod_options_log.command(
303+
name="show",
304+
brief="Show the default logging behaviour.",
305+
)
306+
async def cmd_automod_options_log_show(self, ctx: GuildContext):
307+
await self.state[ctx.guild].show_default_log_options(ctx)
308+
283309
@cmd_automod_options_log.command(
284310
name="set",
285311
brief="Set the default logging behaviour.",
@@ -307,6 +333,43 @@ async def cmd_automod_options_log_set(
307333
async def cmd_automod_options_log_remove(self, ctx: GuildContext):
308334
await self.state[ctx.guild].remove_default_log_options(ctx)
309335

336+
# @@ automod options permit
337+
338+
# NOTE Only guild admins and bot owners can manage permitted roles.
339+
340+
@cmd_automod_options.group(
341+
name="permit",
342+
brief="Configure the set of roles permitted to manage automod.",
343+
)
344+
@checks.is_guild_admin_or_bot_owner()
345+
async def cmd_automod_options_permit(self, ctx: GuildContext):
346+
if not ctx.invoked_subcommand:
347+
if ctx.subcommand_passed:
348+
await ctx.send_help(self.cmd_automod_options_permit)
349+
else:
350+
await self.state[ctx.guild].show_permitted_roles(ctx)
351+
352+
@cmd_automod_options_permit.command(
353+
name="show",
354+
brief="Show the roles permitted to manage automod.",
355+
)
356+
async def cmd_automod_options_permit_show(self, ctx: GuildContext):
357+
await self.state[ctx.guild].show_permitted_roles(ctx)
358+
359+
@cmd_automod_options_permit.command(
360+
name="set",
361+
brief="Set the roles permitted to manage automod.",
362+
)
363+
async def cmd_automod_options_permit_set(self, ctx: GuildContext, *roles: Role):
364+
await self.state[ctx.guild].set_permitted_roles(ctx, *roles)
365+
366+
@cmd_automod_options_permit.command(
367+
name="clear",
368+
brief="Clear all roles permitted to manage automod.",
369+
)
370+
async def cmd_automod_options_permit_clear(self, ctx: GuildContext):
371+
await self.state[ctx.guild].clear_permitted_roles(ctx)
372+
310373
# @@ automod rules
311374

312375
@cmd_automod.group(

commanderbot_ext/ext/automod/automod_data.py

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
from datetime import datetime
66
from typing import AsyncIterable, DefaultDict, Dict, Iterable, Optional, Set, Type
77

8-
from discord import Guild
8+
from discord import Guild, Member
99

1010
from commanderbot_ext.ext.automod.automod_event import AutomodEvent
1111
from commanderbot_ext.ext.automod.automod_log_options import AutomodLogOptions
1212
from commanderbot_ext.ext.automod.automod_rule import AutomodRule
13-
from commanderbot_ext.lib import GuildID, JsonObject, ResponsiveException
13+
from commanderbot_ext.lib import GuildID, JsonObject, ResponsiveException, RoleSet
1414
from commanderbot_ext.lib.json import to_data
1515
from commanderbot_ext.lib.utils import dict_without_ellipsis
1616

@@ -51,20 +51,27 @@ def __init__(self, names: Set[str]):
5151

5252
@dataclass
5353
class AutomodGuildData:
54+
# Default logging configuration for this guild.
5455
default_log_options: Optional[AutomodLogOptions] = None
5556

56-
# Index rules by name for fast look-up in commands.
57+
# Roles that are permitted to manage the extension within this guild.
58+
permitted_roles: Optional[RoleSet] = None
59+
60+
# Index rules by name for faster look-up in commands.
5761
rules: Dict[str, AutomodRule] = field(init=False, default_factory=dict)
5862

59-
# Index rules by event type for faster initial access.
63+
# Group rules by event type for faster look-up during event dispatch.
6064
rules_by_event_type: RulesByEventType = field(
6165
init=False, default_factory=lambda: defaultdict(lambda: set())
6266
)
6367

6468
@staticmethod
6569
def from_data(data: JsonObject) -> AutomodGuildData:
70+
default_log_options = AutomodLogOptions.from_field_optional(data, "log")
71+
permitted_roles = RoleSet.from_field_optional(data, "permitted_roles")
6672
guild_data = AutomodGuildData(
67-
default_log_options=AutomodLogOptions.from_field_optional(data, "log"),
73+
default_log_options=default_log_options,
74+
permitted_roles=permitted_roles,
6875
)
6976
for rule_data in data.get("rules", []):
7077
rule = AutomodRule.from_data(rule_data)
@@ -74,15 +81,29 @@ def from_data(data: JsonObject) -> AutomodGuildData:
7481
def to_data(self) -> JsonObject:
7582
return dict_without_ellipsis(
7683
log=self.default_log_options or ...,
84+
permitted_roles=self.permitted_roles or ...,
7785
rules=list(self.rules.values()) or ...,
7886
)
7987

8088
def set_default_log_options(
8189
self, log_options: Optional[AutomodLogOptions]
8290
) -> Optional[AutomodLogOptions]:
83-
old_log_options = self.default_log_options
91+
old_value = self.default_log_options
8492
self.default_log_options = log_options
85-
return old_log_options
93+
return old_value
94+
95+
def set_permitted_roles(
96+
self, permitted_roles: Optional[RoleSet]
97+
) -> Optional[RoleSet]:
98+
old_value = self.permitted_roles
99+
self.permitted_roles = permitted_roles
100+
return old_value
101+
102+
def member_has_permission(self, member: Member) -> bool:
103+
if self.permitted_roles is None:
104+
return False
105+
has_permission = self.permitted_roles.member_has_some(member)
106+
return has_permission
86107

87108
def all_rules(self) -> Iterable[AutomodRule]:
88109
yield from self.rules.values()
@@ -224,6 +245,20 @@ async def set_default_log_options(
224245
) -> Optional[AutomodLogOptions]:
225246
return self.guilds[guild.id].set_default_log_options(log_options)
226247

248+
# @implements AutomodStore
249+
async def get_permitted_roles(self, guild: Guild) -> Optional[RoleSet]:
250+
return self.guilds[guild.id].permitted_roles
251+
252+
# @implements AutomodStore
253+
async def set_permitted_roles(
254+
self, guild: Guild, permitted_roles: Optional[RoleSet]
255+
) -> Optional[RoleSet]:
256+
return self.guilds[guild.id].set_permitted_roles(permitted_roles)
257+
258+
# @implements AutomodStore
259+
async def member_has_permission(self, guild: Guild, member: Member) -> bool:
260+
return self.guilds[guild.id].member_has_permission(member)
261+
227262
# @implements AutomodStore
228263
async def all_rules(self, guild: Guild) -> AsyncIterable[AutomodRule]:
229264
for rule in self.guilds[guild.id].all_rules():

0 commit comments

Comments
 (0)