Skip to content

Commit 1a9720c

Browse files
Implement Moderation logging and replication with the discord.py server (#38)
* add moderation logging capability from discord.py server * apply feedback
1 parent c96f9ca commit 1a9720c

File tree

7 files changed

+188
-5
lines changed

7 files changed

+188
-5
lines changed

constants/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class Channels(CONSTANTS):
5959
MYSTBIN_DEV = 698366338774728714
6060

6161
FORUM_LOGS = 1114743569786347550
62+
DPY_MOD_LOGS = 955843398001127434
6263

6364

6465
class Colours(CONSTANTS):

core/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@
2727
from .context import *
2828
from .converters import *
2929
from .core import *
30+
from .enums import *
3031
from .errors import *

core/bot.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,38 @@ async def on_command_error(self, ctx: Context, error: commands.CommandError) ->
135135

136136
self.log_handler.error("Command Error", extra={"embed": embed})
137137

138+
async def get_or_fetch_user(
139+
self,
140+
target_id: int,
141+
/,
142+
*,
143+
guild: discord.Guild | None = None,
144+
cache: dict[int, discord.User | discord.Member] | None = None,
145+
) -> discord.User | discord.Member | None:
146+
if guild:
147+
user = guild.get_member(target_id)
148+
if not user:
149+
try:
150+
user = await guild.fetch_member(target_id)
151+
except discord.HTTPException:
152+
return
153+
154+
if cache:
155+
cache[target_id] = user
156+
return user
157+
158+
user = self.get_user(target_id)
159+
if not user:
160+
try:
161+
user = await self.fetch_user(target_id)
162+
except discord.HTTPException:
163+
return
164+
165+
if cache:
166+
cache[target_id] = user
167+
168+
return user
169+
138170
async def start(self, token: str, *, reconnect: bool = True) -> None:
139171
try:
140172
await super().start(token=token, reconnect=reconnect)

core/enums.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from discord.enums import Enum
2+
3+
4+
__all__ = ("DiscordPyModerationEvent",)
5+
6+
7+
class DiscordPyModerationEvent(Enum):
8+
ban = 1
9+
kick = 2
10+
mute = 3
11+
unban = 4
12+
helpblock = 5

core/utils/formatters.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,16 @@
2020
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121
SOFTWARE.
2222
"""
23+
import random
24+
25+
from discord import Colour
2326
from discord.utils import escape_markdown
2427

2528

26-
__all__ = ("to_codeblock",)
29+
__all__ = (
30+
"to_codeblock",
31+
"random_pastel_colour",
32+
)
2733

2834

2935
def to_codeblock(
@@ -41,3 +47,7 @@ def to_codeblock(
4147
if escape_md:
4248
content = escape_markdown(content)
4349
return f"```{language}\n{content}\n```"
50+
51+
52+
def random_pastel_colour() -> Colour:
53+
return Colour.from_hsv(random.random(), 0.28, 0.97)

modules/moderation.py

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,34 @@
2525
import asyncio
2626
import base64
2727
import binascii
28+
import datetime
2829
import re
29-
from typing import Any
30+
from textwrap import shorten
31+
from typing import TYPE_CHECKING, Any, Self, TypeAlias
3032

3133
import discord
3234
import yarl
3335
from discord.ext import commands
3436

3537
import core
38+
from constants import Channels
39+
from core.context import Interaction
40+
from core.utils import random_pastel_colour
3641

3742

43+
if TYPE_CHECKING:
44+
from types_.papi import ModLogPayload, PythonistaAPIWebsocketPayload
45+
46+
ModLogType: TypeAlias = PythonistaAPIWebsocketPayload[ModLogPayload]
47+
3848
TOKEN_RE = re.compile(r"[a-zA-Z0-9_-]{23,28}\.[a-zA-Z0-9_-]{6,7}\.[a-zA-Z0-9_-]{27}")
49+
PROSE_LOOKUP = {
50+
1: "banned",
51+
2: "kicked",
52+
3: "muted",
53+
4: "unbanned",
54+
5: "helpblocked",
55+
}
3956

4057

4158
def validate_token(token: str) -> bool:
@@ -45,17 +62,47 @@ def validate_token(token: str) -> bool:
4562
user_id = int(base64.b64decode(user_id + "==", validate=True))
4663
except (ValueError, binascii.Error):
4764
return False
48-
else:
49-
return True
65+
return True
5066

5167

5268
class GithubError(commands.CommandError):
5369
pass
5470

5571

72+
class ModerationRespostView(discord.ui.View):
73+
message: discord.Message | discord.WebhookMessage
74+
75+
def __init__(self, *, timeout: float | None = 180, target_id: int, target_reason: str) -> None:
76+
super().__init__(timeout=timeout)
77+
self.target: discord.Object = discord.Object(id=target_id, type=discord.Member)
78+
self.target_reason: str = target_reason
79+
80+
def _disable_all_buttons(self) -> None:
81+
for item in self.children:
82+
if isinstance(item, (discord.ui.Button, discord.ui.Select)):
83+
item.disabled = True
84+
85+
async def on_timeout(self) -> None:
86+
self._disable_all_buttons()
87+
await self.message.edit(view=self)
88+
89+
@discord.ui.button(label="Ban", emoji="\U0001f528")
90+
async def ban_button(self, interaction: Interaction, button: discord.ui.Button[Self]) -> None:
91+
assert interaction.guild
92+
await interaction.response.defer(ephemeral=False)
93+
94+
reason = f"Banned due to grievances in discord.py: {self.target_reason!r}"
95+
await interaction.guild.ban(
96+
self.target,
97+
reason=shorten(reason, width=128, placeholder="..."),
98+
)
99+
await interaction.followup.send("Banned.")
100+
101+
56102
class Moderation(commands.Cog):
57103
def __init__(self, bot: core.Bot, /) -> None:
58104
self.bot = bot
105+
self.dpy_mod_cache: dict[int, discord.User | discord.Member] = {}
59106
self._req_lock = asyncio.Lock()
60107

61108
async def github_request(
@@ -73,7 +120,7 @@ async def github_request(
73120

74121
hdrs = {
75122
"Accept": "application/vnd.github.inertia-preview+json",
76-
"User-Agent": "RoboDanny DPYExclusive Cog",
123+
"User-Agent": "PythonistaBot Moderation Cog",
77124
"Authorization": f"token {api_key}",
78125
}
79126

@@ -148,6 +195,56 @@ async def find_discord_tokens(self, message: discord.Message) -> None:
148195
)
149196
await message.reply(msg)
150197

198+
@commands.Cog.listener()
199+
async def on_papi_dpy_modlog(self, payload: ModLogType, /) -> None:
200+
moderation_payload = payload["payload"]
201+
moderation_event = core.DiscordPyModerationEvent(moderation_payload["moderation_event_type"])
202+
203+
embed = discord.Embed(
204+
title=f"Discord.py Moderation Event: {moderation_event.name.title()}",
205+
colour=random_pastel_colour(),
206+
)
207+
208+
target_id = moderation_payload["target_id"]
209+
target = await self.bot.get_or_fetch_user(target_id)
210+
211+
moderation_reason = moderation_payload["reason"]
212+
213+
moderator_id = moderation_payload["author_id"]
214+
moderator = self.dpy_mod_cache.get(moderator_id) or await self.bot.get_or_fetch_user(
215+
moderator_id, cache=self.dpy_mod_cache
216+
)
217+
218+
if moderator:
219+
self.dpy_mod_cache[moderator.id] = moderator
220+
moderator_format = f"{moderator.name} {PROSE_LOOKUP[moderation_event.value]} "
221+
embed.set_author(name=moderator.name, icon_url=moderator.display_avatar.url)
222+
else:
223+
moderator_format = f"Unknown Moderator with ID: {moderator_id} {PROSE_LOOKUP[moderation_event.value]} "
224+
embed.set_author(name=f"Unknown Moderator.")
225+
226+
if target:
227+
target_format = target.name
228+
embed.set_footer(text=f"{target.name} | {target_id}", icon_url=target.display_avatar.url)
229+
else:
230+
target_format = f"An unknown user with ID {target_id}"
231+
embed.set_footer(text=f"Not Found | {target_id}")
232+
embed.add_field(name="Reason", value=moderation_reason or "No reason given.")
233+
234+
embed.description = moderator_format + target_format
235+
236+
when = datetime.datetime.fromisoformat(moderation_payload["event_time"])
237+
embed.timestamp = when
238+
239+
guild = self.bot.get_guild(490948346773635102)
240+
assert guild
241+
242+
channel = guild.get_channel(Channels.DPY_MOD_LOGS)
243+
assert isinstance(channel, discord.TextChannel) # This is static
244+
245+
view = ModerationRespostView(timeout=900, target_id=target_id, target_reason=moderation_reason)
246+
view.message = await channel.send(embed=embed, view=view)
247+
151248

152249
async def setup(bot: core.Bot) -> None:
153250
await bot.add_cog(Moderation(bot))

types_/papi.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from typing import Generic, Literal, TypedDict, TypeVar
2+
3+
4+
PayloadT = TypeVar("PayloadT", bound=TypedDict)
5+
6+
7+
class ModLogPayload(TypedDict):
8+
# event types:
9+
"""
10+
1. Ban
11+
2. Kick
12+
3. Mute
13+
4. Unban
14+
5. Helpblock?
15+
"""
16+
moderation_event_type: Literal[1, 2, 3, 4, 5]
17+
guild_id: int
18+
target_id: int
19+
author_id: int
20+
reason: str
21+
event_time: str # isoformatted datetime
22+
23+
24+
class PythonistaAPIWebsocketPayload(TypedDict, Generic[PayloadT]):
25+
op: int
26+
subscription: str
27+
application: int
28+
application_name: str
29+
payload: PayloadT
30+
user_id: int

0 commit comments

Comments
 (0)