Skip to content

Commit 91f2b7b

Browse files
Add ban appeal server kicks and thread notices (#3561)
1 parent ab21eef commit 91f2b7b

2 files changed

Lines changed: 187 additions & 0 deletions

File tree

‎bot/constants.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,16 @@ class _Channels(EnvConfig, env_prefix="channels_"):
128128

129129
rules: int = 693837295685730335
130130

131+
ban_appeal_logs: int = 891680793351553068
132+
131133

132134
Channels = _Channels()
133135

134136

135137
class _Roles(EnvConfig, env_prefix="roles_"):
136138

139+
ban_appeal_server_staff: int = 890273013671526481
140+
137141
# Self-assignable roles, see the Subscribe cog
138142
advent_of_code: int = 518565788744024082
139143
announcements: int = 463658397560995840
@@ -198,6 +202,7 @@ class _Guild(EnvConfig, env_prefix="guild_"):
198202

199203
id: int = 267624335836053506
200204
invite: str = "https://discord.gg/python"
205+
ban_appeal_id: int = 890261951979061298
201206

202207
moderation_categories: tuple[int, ...] = (
203208
Categories.moderators,
@@ -452,6 +457,9 @@ class _BaseURLs(EnvConfig, env_prefix="urls_"):
452457
site_api: str = "http://site.web.svc.cluster.local/api"
453458
paste_url: str = "https://paste.pythondiscord.com"
454459

460+
# Polonium (modmail)
461+
polonium_api: str = "http://polonium-api.modmail.svc.cluster.local/api"
462+
455463

456464
BaseURLs = _BaseURLs()
457465

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import asyncio
2+
import weakref
3+
from pathlib import Path
4+
5+
import aiohttp
6+
import discord
7+
from discord.ext import commands
8+
from pydis_core.utils import members, scheduling
9+
from pydis_core.utils.channel import get_or_fetch_channel
10+
11+
from bot.bot import Bot
12+
from bot.constants import Channels, Colours, Guild, Roles, URLs
13+
from bot.log import get_logger
14+
15+
log = get_logger(__name__)
16+
17+
18+
PYDIS_NO_KICK_ROLE_IDS = (Roles.admins, Roles.devops)
19+
# A Kubernetes service account token for the Polonium API, mounted by the bot's deployment.
20+
POLONIUM_TOKEN_FILE = Path("/var/run/secrets/polonium-api/token")
21+
22+
23+
class BanAppeals(commands.Cog):
24+
"""Keep the ban appeal server to banned users only, and post their joins and leaves in their modmail thread."""
25+
26+
def __init__(self, bot: Bot):
27+
self.bot = bot
28+
self.user_locks: weakref.WeakValueDictionary[int, asyncio.Lock] = weakref.WeakValueDictionary()
29+
# Users we kicked ourselves, so the leave that follows isn't also posted as them leaving.
30+
self.ignore_next_remove_event: set[int] = set()
31+
32+
async def cog_load(self) -> None:
33+
"""Look up the guilds and log channel, then kick anyone who shouldn't be in the appeals server."""
34+
self.appeals_guild = self.bot.get_guild(Guild.ban_appeal_id)
35+
self.pydis_guild = self.bot.get_guild(Guild.id)
36+
self.logs_channel = self.bot.get_channel(Channels.ban_appeal_logs)
37+
scheduling.create_task(self._sync_kicks())
38+
39+
async def _sync_kicks(self) -> None:
40+
"""Kick every member of the appeals server who shouldn't be there."""
41+
await self.bot.wait_until_ready()
42+
log.info("Starting kick job for ban appeals server")
43+
for member in self.appeals_guild.members:
44+
await self._maybe_kick_user(member)
45+
log.info("Kick job for ban appeals server completed")
46+
47+
async def _is_banned_pydis(self, member: discord.Member) -> bool:
48+
"""See if the given member is banned in PyDis."""
49+
try:
50+
await self.pydis_guild.fetch_ban(member)
51+
except discord.NotFound:
52+
return False
53+
return True
54+
55+
async def _can_bypass_kick(self, member: discord.Member) -> bool:
56+
"""See if the given appeals server member is staff who may stay without being banned."""
57+
pydis_member = await members.get_or_fetch_member(self.pydis_guild, member.id)
58+
if not pydis_member:
59+
return False
60+
return (
61+
any(role.id in PYDIS_NO_KICK_ROLE_IDS for role in pydis_member.roles)
62+
or any(role.id == Roles.ban_appeal_server_staff for role in member.roles)
63+
)
64+
65+
async def _get_thread(self, user: discord.abc.Snowflake) -> discord.Thread | None:
66+
"""Return the user's currently open modmail thread, if they have one."""
67+
try:
68+
# Read on every call, as Kubernetes rotates this token every few minutes.
69+
token = POLONIUM_TOKEN_FILE.read_text().strip()
70+
async with self.bot.http_session.get(
71+
f"{URLs.polonium_api}/users/{user.id}/current-post",
72+
headers={"Authorization": f"Bearer {token}"},
73+
) as resp:
74+
if resp.status == 404:
75+
return None
76+
resp.raise_for_status()
77+
post = await resp.json()
78+
if post["guild_id"] != Guild.id:
79+
return None
80+
return await get_or_fetch_channel(self.bot, post["post_id"])
81+
except (OSError, aiohttp.ClientError, discord.HTTPException):
82+
# Thread notices are best-effort, they shouldn't stop the kicks.
83+
log.exception("Failed to get the modmail thread for %d.", user.id)
84+
return None
85+
86+
async def _notify_thread(self, user: discord.abc.Snowflake, description: str, colour: int) -> bool:
87+
"""Post a notice in the user's modmail thread. Return whether they had a thread to post in."""
88+
thread = await self._get_thread(user)
89+
if not thread:
90+
return False
91+
await thread.send(embed=discord.Embed(description=description, colour=colour))
92+
return True
93+
94+
async def _maybe_kick_user(self, member: discord.Member) -> bool:
95+
"""
96+
Kick the member from the appeals server unless they are banned in PyDis or are staff.
97+
98+
Return whether the member was kicked.
99+
"""
100+
if member.bot or await self._is_banned_pydis(member):
101+
return False
102+
103+
if await self._can_bypass_kick(member):
104+
log.info("Not kicking %s (%d) as they have a bypass role", member, member.id)
105+
return False
106+
107+
try:
108+
await member.kick(reason="Not banned in main server")
109+
except discord.Forbidden:
110+
log.error("Failed to kick %s (%d) due to insufficient permissions.", member, member.id)
111+
return False
112+
113+
await self.logs_channel.send(f"Kicked {member} ({member.id}) on join as they're not banned in main server.")
114+
log.info("Kicked %s (%d).", member, member.id)
115+
116+
if await self._notify_thread(
117+
member,
118+
"The recipient joined the appeals server and has been automatically kicked.",
119+
Colours.soft_red,
120+
):
121+
self.ignore_next_remove_event.add(member.id)
122+
return True
123+
124+
async def _handle_pydis_join(self, member: discord.Member) -> None:
125+
"""Kick the member from the appeals server now that they're back in PyDis."""
126+
appeals_member = await members.get_or_fetch_member(self.appeals_guild, member.id)
127+
if not appeals_member:
128+
return
129+
130+
await appeals_member.kick(reason="Rejoined PyDis")
131+
await self.logs_channel.send(f"Kicked {member} ({member.id}) as they rejoined PyDis.")
132+
log.info("Kicked %s (%d) as they rejoined PyDis.", member, member.id)
133+
134+
if await self._notify_thread(
135+
member,
136+
"The recipient has been kicked from the appeals server.",
137+
Colours.soft_red,
138+
):
139+
self.ignore_next_remove_event.add(member.id)
140+
141+
async def _handle_appeals_join(self, member: discord.Member) -> None:
142+
"""Kick the member if they shouldn't be in the appeals server, otherwise post that they joined."""
143+
if not await self._maybe_kick_user(member):
144+
await self._notify_thread(member, "The recipient has joined the appeals server.", Colours.soft_green)
145+
146+
async def _handle_remove(self, member: discord.Member) -> None:
147+
"""Post that the member left the appeals server, unless we were the ones who kicked them."""
148+
if member.guild != self.appeals_guild:
149+
return
150+
151+
if member.id in self.ignore_next_remove_event:
152+
self.ignore_next_remove_event.discard(member.id)
153+
return
154+
155+
await self._notify_thread(member, "The recipient has left the appeals server.", Colours.soft_red)
156+
157+
def _user_lock(self, user_id: int) -> asyncio.Lock:
158+
"""Return the user's lock, so their join and leave events are handled one at a time, in order."""
159+
return self.user_locks.setdefault(user_id, asyncio.Lock())
160+
161+
@commands.Cog.listener()
162+
async def on_member_join(self, member: discord.Member) -> None:
163+
"""Handle a member joining either PyDis or the appeals server."""
164+
async with self._user_lock(member.id):
165+
if member.guild == self.pydis_guild:
166+
await self._handle_pydis_join(member)
167+
elif member.guild == self.appeals_guild:
168+
await self._handle_appeals_join(member)
169+
170+
@commands.Cog.listener()
171+
async def on_member_remove(self, member: discord.Member) -> None:
172+
"""Handle a member leaving the appeals server."""
173+
async with self._user_lock(member.id):
174+
await self._handle_remove(member)
175+
176+
177+
async def setup(bot: Bot) -> None:
178+
"""Load the BanAppeals cog."""
179+
await bot.add_cog(BanAppeals(bot))

0 commit comments

Comments
 (0)