Skip to content
This repository was archived by the owner on Feb 13, 2022. It is now read-only.

Commit 4a20b5e

Browse files
committed
Add ban appeals cog
This cog listens for embeds to be sent by the forms webhook for user's submitting ban appeals. When one is detected it fetches extra info from the forms API and starts a thread, ready for mods to deliberate. When a descision is made, it allows for the generation of a response to the ban appeal, using canned responses and custom input. I have made a skeleton for what will eventually become and automatic email to appealers. This is currently waiting for PyDis to move to a different email supplier, so that we can integrate with the new API.
1 parent f6bc7ba commit 4a20b5e

File tree

6 files changed

+313
-1
lines changed

6 files changed

+313
-1
lines changed

bot/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ class Channels(metaclass=YAMLGetter):
226226

227227
nomination_voting: int
228228
dev_log: int
229+
appeals: int
229230

230231

231232
class Roles(metaclass=YAMLGetter):
@@ -243,6 +244,7 @@ class URLs(metaclass=YAMLGetter):
243244
section = "urls"
244245

245246
github_bot_repo: str
247+
forms: str
246248

247249

248250
class Emojis(metaclass=YAMLGetter):
@@ -263,6 +265,12 @@ class Colours(metaclass=YAMLGetter):
263265
error: int
264266

265267

268+
class Secrets(metaclass=YAMLGetter):
269+
section = "secrets"
270+
271+
forms_token: str
272+
273+
266274
class ThreadArchiveTimes(Enum):
267275
HOUR = 60
268276
DAY = 1440

bot/exts/ban_appeals/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import json
2+
3+
from bot.bot import ThreadBot
4+
5+
with open("bot/exts/ban_appeals/responses.json") as f:
6+
APPEAL_RESPONSES = json.load(f)
7+
8+
BASE_RESPONSE = "Hi {name},{snippet}{extras}\n\nKind regards,\nPython Discord Appeals Team."
9+
10+
11+
def setup(bot: ThreadBot) -> None:
12+
"""Load the ban appeals cog."""
13+
# Defer import to reduce side effects from importing the ban_appeals package.
14+
from bot.exts.ban_appeals._cog import BanAppeals
15+
16+
bot.add_cog(BanAppeals(bot))
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from aiohttp import ClientSession
2+
3+
from bot import constants, logger
4+
from bot.exts.ban_appeals import _models
5+
6+
7+
async def fetch_form_appeal_data(response_uuid: str, cookies: dict, session: ClientSession) -> _models.AppealDetails:
8+
"""Fetches ban appeal data from the pydis forms API."""
9+
logger.info(f"Fetching appeal info for form response {response_uuid}")
10+
url = f"{constants.URLs.forms}/forms/ban-appeals/responses/{response_uuid}"
11+
async with session.get(url, cookies=cookies, raise_for_status=True) as resp:
12+
appeal_response_data = await resp.json()
13+
appealer = f"{appeal_response_data['user']['username']}#{appeal_response_data['user']['discriminator']}"
14+
return _models.AppealDetails(
15+
appealer=appealer,
16+
uuid=appeal_response_data["id"],
17+
email=appeal_response_data["user"]["email"],
18+
reason=appeal_response_data["response"]["reason"],
19+
justification=appeal_response_data["response"]["justification"]
20+
)
21+
22+
23+
async def post_appeal_respose_email(email_body: str, appealer_email: str) -> None:
24+
"""Sends the appeal response email to the appealer."""
25+
# Awaiting new mail provider before we implement this.
26+
raise NotImplementedError

bot/exts/ban_appeals/_cog.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import typing as t
2+
from datetime import datetime
3+
4+
import discord
5+
from aiohttp.client_exceptions import ClientResponseError
6+
from discord.ext import commands
7+
8+
from bot import constants, logger
9+
from bot.bot import ThreadBot
10+
from bot.exts.ban_appeals import BASE_RESPONSE, _api_handlers, _models
11+
12+
13+
class BanAppeals(commands.Cog):
14+
"""Cog for creating and actioning ban appeals threads when a user appeals via forms."""
15+
16+
def __init__(self, bot: ThreadBot) -> None:
17+
self.bot = bot
18+
self.appeal_channel: t.Optional[discord.TextChannel] = None
19+
self.cookies = {"token": constants.Secrets.forms_token}
20+
21+
if constants.DEBUG_MODE:
22+
self.archive_time = constants.ThreadArchiveTimes.DAY.value
23+
else:
24+
self.archive_time = constants.ThreadArchiveTimes.WEEK.value
25+
26+
self.init_task = self.bot.loop.create_task(self.init_cog())
27+
28+
async def init_cog(self) -> None:
29+
"""Initialise the ban appeals system."""
30+
logger.info("Waiting for the guild to be available before initialisation.")
31+
await self.bot.wait_until_guild_available()
32+
33+
self.appeal_channel = self.bot.get_channel(constants.Channels.appeals)
34+
35+
async def appeal_thread_check(self, ctx: commands.Context, messages: list[discord.Message]) -> bool:
36+
"""Return True if channel is a Thread, in the appeal channel, with a first message sent from this bot."""
37+
await self.init_task
38+
if not isinstance(ctx.channel, discord.Thread):
39+
# Channel isn't a discord.Thread
40+
return False
41+
if not ctx.channel.parent == self.appeal_channel:
42+
# Thread parent channel isn't the appeal channel.
43+
return False
44+
if not messages or not messages[1].author == ctx.guild.me:
45+
# This aren't messages in the channel, or the first message isn't from this bot.
46+
# Ignore messages[0], as it refers to the parent message.
47+
return False
48+
if messages[1].content.startswith("Actioned"):
49+
# Ignore appeals that have already been actioned.
50+
return False
51+
return True
52+
53+
@commands.has_any_role(*constants.MODERATION_ROLES, constants.Roles.core_developers)
54+
@commands.group(name="appeal", invoke_without_command=True)
55+
async def ban_appeal(self, ctx: commands.Context) -> None:
56+
"""Ban group for the ban appeal commands."""
57+
await ctx.send_help(ctx.command)
58+
59+
@commands.has_any_role(*constants.MODERATION_ROLES, constants.Roles.core_developers)
60+
@ban_appeal.command(name="test")
61+
async def embed_test(self, ctx: commands.Context) -> None:
62+
"""Send a embed that mocks the ban appeal webhook."""
63+
await self.init_task
64+
65+
embed = discord.Embed(
66+
title="New Form Response",
67+
description=f"{ctx.author.mention} submitted a response to `​Ban Appeals​`.",
68+
colour=constants.Colours.info,
69+
url="https://forms-api.pythondiscord.com/forms/ban-appeals/responses/7fbb1a2e-d910-44bb-bcc7-e9fcfd04f758"
70+
)
71+
embed.timestamp = datetime(year=2021, month=8, day=22, hour=12, minute=56, second=14)
72+
embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar.url)
73+
await self.appeal_channel.send("New ban appeal!", embed=embed)
74+
await ctx.message.add_reaction("✅")
75+
76+
@commands.has_any_role(constants.Roles.admins)
77+
@ban_appeal.command(name="respond", aliases=("response",))
78+
async def appeal_respond(
79+
self,
80+
ctx: commands.Context,
81+
response: _models.AppealResponse,
82+
*,
83+
extras: t.Optional[str]
84+
) -> None:
85+
"""
86+
Respond to the appeal with the given response.
87+
88+
`extras` can be given to add extra content to the email.
89+
You will be asked to confirm the full email before it is sent.
90+
"""
91+
# Don't use a discord.py check to avoid fetching first message multiple times.
92+
messages = await ctx.channel.history(limit=2, oldest_first=True).flatten()
93+
if not await self.appeal_thread_check(ctx, messages):
94+
await ctx.message.add_reaction("❌")
95+
return
96+
thread_data_message = messages[1]
97+
response_uuid = thread_data_message.content.split(" ")[0]
98+
appeal_details: _models.AppealDetails = await _api_handlers.fetch_form_appeal_data(
99+
response_uuid,
100+
self.cookies,
101+
self.bot.http_session
102+
)
103+
104+
email_response_content = BASE_RESPONSE.format(
105+
name=appeal_details.appealer,
106+
snippet=response,
107+
extras=f"\n\n{extras}" if extras else ""
108+
)
109+
await ctx.send(
110+
email_response_content,
111+
# This is commented out awaiting PyDis to get a new mail provider.
112+
# view=_models.ConfirmAppealResponse(thread_data_message, appeal_details.email)
113+
)
114+
115+
@commands.Cog.listener(name="on_message")
116+
async def ban_appeal_listener(self, message: discord.Message) -> None:
117+
"""Listens for ban appeal embeds to trigger the appeal process."""
118+
await self.init_task
119+
120+
if not message.channel == self.appeal_channel:
121+
# Ignore messages not in the appeal channel
122+
return
123+
124+
if not message.author.bot:
125+
# Ignore messages not from bots
126+
return
127+
128+
if not message.embeds or len(message.embeds) != 1:
129+
# Ignore messages without extact 1 embed
130+
return
131+
132+
appeal_details = await self.get_appeal_details_from_embed(message.embeds[0])
133+
134+
thread: discord.Thread = await message.create_thread(
135+
name=appeal_details.thread_name,
136+
auto_archive_duration=self.archive_time
137+
)
138+
await thread.send(appeal_details)
139+
140+
async def get_appeal_details_from_embed(self, embed: discord.Embed) -> _models.AppealDetails:
141+
"""Extract a form response uuid from a ban appeal webhook message."""
142+
response_uuid = embed.url.split("/")[-1]
143+
try:
144+
return await _api_handlers.fetch_form_appeal_data(
145+
response_uuid,
146+
self.cookies,
147+
self.bot.http_session
148+
)
149+
except ClientResponseError as e:
150+
if e.status == 403:
151+
await self.appeal_channel.send(":x: Forms credentials are invalid, could not initiate appeal flow.")
152+
raise

bot/exts/ban_appeals/_models.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import asyncio
2+
from dataclasses import dataclass
3+
4+
from discord import ButtonStyle, Interaction, Message, ui
5+
from discord.ext import commands
6+
7+
from bot import constants
8+
from bot.exts.ban_appeals import APPEAL_RESPONSES, _api_handlers
9+
10+
11+
@dataclass(frozen=True)
12+
class AppealDetails:
13+
"""A data class to hold all details about a given appeal."""
14+
15+
appealer: str
16+
uuid: str
17+
email: str
18+
reason: str
19+
justification: str
20+
21+
@property
22+
def thread_name(self) -> str:
23+
"""The name of the thread to create, based on the appealer."""
24+
return f"Ban appeal - {self.appealer}"
25+
26+
def __str__(self) -> str:
27+
return (
28+
f"{self.uuid} - {self.appealer}\n\n"
29+
f"**Their understanding of the ban reason:**\n> {self.reason}\n\n"
30+
f"**Why they think they should be unbanned**:\n> {self.justification}"
31+
)
32+
33+
34+
class AppealResponse(commands.Converter):
35+
"""Ensure that the given appeal response exists."""
36+
37+
async def convert(self, ctx: commands.Context, response: str) -> str:
38+
"""Ensure that the given appeal response exists."""
39+
response = response.lower()
40+
if response in APPEAL_RESPONSES:
41+
return APPEAL_RESPONSES[response]
42+
43+
raise commands.BadArgument(f":x: Could not find the response `{response}`.")
44+
45+
46+
class ConfirmAppealResponse(ui.View):
47+
"""A confirmation view for responding to ban appeals."""
48+
49+
def __init__(self, thread_data_message: Message, appealer_email: str) -> None:
50+
super().__init__()
51+
self.lock = asyncio.Lock() # Only process 1 interaction is at a time, to avoid multiple emails being sent.
52+
53+
# Message storing data about the appeal. Used to mark the appeal as actioned after sending the email.
54+
self.thread_data_message = thread_data_message
55+
self.appealer_email = appealer_email
56+
57+
async def interaction_check(self, interaction: Interaction) -> bool:
58+
"""Check that the interactor is authorised and another interaction isn't being processed."""
59+
if self.lock.locked():
60+
await interaction.response.send_message(
61+
":x: Processing another user's button press, try again later.",
62+
ephemeral=True,
63+
)
64+
return False
65+
66+
if constants.Roles.admins in (role.id for role in interaction.user.roles):
67+
return True
68+
69+
await interaction.response.send_message(
70+
":x: You are not authorized to perform this action.",
71+
ephemeral=True,
72+
)
73+
74+
return False
75+
76+
async def on_error(self, error: Exception, item: ui.Item, interaction: Interaction) -> None:
77+
"""Release the lock in case of error."""
78+
if self.lock.locked():
79+
await self.lock.release()
80+
81+
async def stop(self, interaction: Interaction, *, actioned: bool = True) -> None:
82+
"""Remove buttons and mark thread as actioned on stop."""
83+
await interaction.message.edit(view=None)
84+
if actioned:
85+
await self.thread_data_message.edit(content=f"Actioned {self.thread_data_message.content}")
86+
87+
@ui.button(label="Confirm & send", style=ButtonStyle.green, row=0)
88+
async def confirm(self, _button: ui.Button, interaction: Interaction) -> None:
89+
"""Confirm body and send email to ban appealer."""
90+
await self.lock.acquire()
91+
92+
await _api_handlers.post_appeal_respose_email(interaction.message.content, self.appealer_email)
93+
94+
await interaction.response.send_message(
95+
f":+1: {interaction.user.mention} Email sent. "
96+
"Please archive this thread when ready."
97+
)
98+
await self.stop(interaction)
99+
100+
@ui.button(label="Cancel", style=ButtonStyle.gray, row=0)
101+
async def cancel(self, _button: ui.Button, interaction: Interaction) -> None:
102+
"""Cancel the response email."""
103+
await self.lock.acquire()
104+
await interaction.response.send_message(":x: Email aborted.")
105+
await self.stop(interaction, actioned=False)

config-default.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ guild:
1414
channels:
1515
dev_log: 622895325144940554
1616
nomination_voting: 822853512709931008
17+
appeals: 808790025688711198
1718

1819
roles:
1920
admins: 267628507062992896
@@ -23,7 +24,8 @@ guild:
2324
core_developers: 587606783669829632
2425

2526
urls:
26-
github_bot_repo: "https://github.com/python-discord/thread-bot"
27+
github_bot_repo: "https://github.com/python-discord/thread-bot"
28+
forms: "http://forms-backend.default.svc.cluster.local"
2729

2830
style:
2931
emojis:
@@ -35,5 +37,8 @@ style:
3537
warning: 0xf9cb54
3638
error: 0xcd6d6d
3739

40+
secrets:
41+
forms_token: !ENV "FORMS_TOKEN"
42+
3843
config:
3944
required_keys: ['bot.token']

0 commit comments

Comments
 (0)