Files
payphone/payphone/cogs/mods/cog.py
T

468 lines
14 KiB
Python

from typing import Awaitable
from datetime import datetime, timezone, timedelta
import sys
import asyncio
import discord
from discord import errors
from discord.ext import commands
from tortoise import transactions, expressions
from payphone import bot
from payphone.db import utils, models
from payphone.cogs import base
from payphone.cogs.mods import embeds, views
from payphone.core import checks, converters, constants, formatting as fmt
from payphone.core.redis import redis
class Mods(base.Cog):
show_in_help = False
def __init__(self, bot_instance: bot.Payphone):
self.bot = bot_instance
@commands.command(extras={"reply_argument": "user"})
@checks.is_moderator()
@transactions.atomic()
async def ban(
self,
ctx: commands.Context,
user: converters.DiscordUserID,
duration: converters.Timedelta | None = None,
*,
reason: str | None = "",
) -> None:
"""
Ban a user by their ID, pinging them, or replying to their message.
Options:
- `<user>`: The user to ban or their ID.
- `[<duration>]`: The amount of time to ban the user for. For example, `1d 5h`.
- `[<reason>]`: The reason of their ban.
"""
user_id = user
profile = await models.Profile.filter(user_id=user_id).limit(1).first()
row = await self.bot.ban(
user_id,
duration,
reason,
ctx.author,
)
message = f"✅ {f"**{profile.name}**" if profile is not None else f"`ID: {user_id}`"} was banned"
if row.expires_at:
message += f"{f" for `{fmt.time_to_text(duration)}`" if duration is not None else ""} until <t:{int(row.expires_at.timestamp())}:F> (<t:{int(row.expires_at.timestamp())}:R>)"
else:
message += " permanently"
message += "."
await ctx.send(message)
try:
notification_message = await (
await self.bot.get_or_fetch_user(user_id)
).send(
embed=embeds.BanNotificationEmbed(self.bot, row),
)
row.notification_message_id = notification_message.id
await row.save()
except errors.DiscordException:
pass
@commands.command(extras={"reply_argument": "user"})
@checks.is_moderator()
@transactions.atomic()
async def unban(
self,
ctx: commands.Context,
user: converters.DiscordUserID,
) -> None:
"""
Unbans a user by their ID, pinging them, or replying to their message. The user will be
attempted to get notified of the unban.
Options:
- `<user>`: The user to unban or their ID.
"""
user_id = user
await self.bot.unban(user_id)
profile = await models.Profile.filter(user_id=user_id).limit(1).first()
await ctx.send(
f"✅ {f"**{profile.name}**" if profile is not None else f"`ID: {user_id}`"} was unbanned."
)
try:
await (await self.bot.get_or_fetch_user(user_id)).send(
"🔔 You have been unbanned from Payphone."
)
except errors.DiscordException:
pass
@commands.command(extras={"reply_argument": "user"})
@checks.is_moderator()
@transactions.atomic()
async def warn(
self,
ctx: commands.Context,
user: converters.DiscordUserID,
*,
reason: str,
) -> None:
"""
Warn a user by their ID or mention.
Options:
- `<user>`: The user's mention or their ID.
- `<reason>`: The reason of the warn.
"""
user_id = user
user = None
try:
user = await self.bot.get_or_fetch_user(user_id)
except errors.DiscordError:
await ctx.send(
"❌ Could not find this user. It may be because the user has left all servers with Payphone in them."
)
return
embed = discord.Embed(
color=constants.EMBED_COLOR,
title="You've been warned",
description=reason,
timestamp=datetime.now(timezone.utc),
author=self.bot.embed_author,
footer=discord.EmbedFooter(
text="Ignoring this warning will result in a ban."
),
thumbnail=self.bot.user.display_avatar.url,
)
profile = await models.Profile.filter(user_id=user_id).limit(1).first()
try:
await user.send(embed=embed)
except errors.DiscordException:
await ctx.send(
f"❌ Could not send the warning to {f"**{profile.name}**" if profile is not None else "the user"}, probably because they have their DMs closed."
)
return
await ctx.send(
f"✅ {f"**{profile.name}**" if profile is not None else "the user"} has been warned."
)
@commands.command()
@checks.is_moderator()
@transactions.atomic()
async def reports(self, ctx: commands.Context) -> None:
"""
Shows the next report to review.
"""
report = (
await models.Report.filter(status=models.ReportStatus.ON_REVIEW)
.order_by("-id")
.limit(1)
.first()
.select_related("call")
.prefetch_related("call__call_rss", "call__call_rss__profile")
)
total = await models.Report.filter(status=models.ReportStatus.ON_REVIEW).count()
if report is None:
await ctx.send("✅ No reports left, have this cookie 🍪 !!")
return
await ctx.send(
embed=embeds.ReportEmbed(self.bot, report),
view=views.ReportView(self.bot, report, total),
)
@commands.command(aliases=["mod"])
@commands.is_owner()
@transactions.atomic()
async def moderator(
self,
ctx: commands.Context,
user: discord.User | discord.Member,
toggle: bool | None = None,
) -> None:
"""
Toggles a user's moderator status. It can only be ran by Payphone's owner.
Options:
- `<user>`: The user to toggle the moderation status for.
- `[<toggle>]`: The moderator status to set to the user.
"""
user_row, profile_row = await utils.get_user(user)
if toggle is not None:
user_row.is_moderator = toggle
else:
user_row.is_moderator = not user_row.is_moderator
await user_row.save()
await ctx.send(
f"✅ **{profile_row.name}**'s moderator status was set to `{"on" if user_row.is_moderator else "off"}`."
)
@commands.command(aliases=["restart"])
@commands.is_owner()
@transactions.atomic()
async def shutdown(
self, ctx: commands.Context, seconds: int = 60, *, reason: str | None
) -> None:
"""
Notifies all the in-call users of the scheduled shutdown and shuts down Payphone after x seconds.
Options:
- `[<seconds>]`: The amount of seconds to wait before shutdown.
- `[<reason>]`: The reason of the shutdown.
"""
oncall_channel_ids = list(await redis.smembers(constants.CHANNELS_ONCALL_SET))
searching_channel_ids = list(
await redis.smembers(constants.CHANNELS_SEARCH_QUEUE)
)
channel_ids = [int(c) for c in oncall_channel_ids] + [
int(c) for c in searching_channel_ids
]
await ctx.send(f"⚡ Notifying `{len(channel_ids)}` channels of the shutdown.")
for channel_id in channel_ids:
try:
await self.bot.get_partial_messageable(channel_id).send(
f"⚡ Payphone will be shutdown <t:{int((datetime.now(timezone.utc) + timedelta(seconds=seconds)).timestamp())}:R> due to a maintenance restart."
+ (" Reason: " + reason if reason is not None else "")
)
except errors.DiscordException:
pass
await ctx.send(
f"⚡ All channels were notified of the shutdown. Payphone will shut down <t:{int((datetime.now(timezone.utc) + timedelta(seconds=seconds)).timestamp())}:R>."
)
await asyncio.sleep(seconds)
oncall_channel_ids = list(await redis.smembers(constants.CHANNELS_ONCALL_SET))
searching_channel_ids = list(
await redis.smembers(constants.CHANNELS_SEARCH_QUEUE)
)
channel_ids = [int(c) for c in oncall_channel_ids] + [
int(c) for c in searching_channel_ids
]
await ctx.send(f"⚡ Notifying `{len(channel_ids)}` channels of hang up.")
for channel_id in channel_ids:
try:
await self.bot.get_partial_messageable(channel_id).send(
"⚡ The call will be ended due to a scheduled shutdown."
)
except errors.DiscordException:
pass
await ctx.send(
"⚡ Notified all channels of calls hang up. Flushing messages to the DB..."
)
calls = await models.Call.filter(ended_at=None)
await redis.sadd(constants.CALL_IDS_TO_FLUSH, *(call.id for call in calls))
await self.bot.get_cog("Calls").flush_messages()
await ctx.send("⚡ Messages were flushed, payphone will shut down.")
sys.exit()
@commands.command()
@commands.is_owner()
async def stats(
self, ctx: commands.Context, iterations: int = 5, wait: int = 5
) -> None:
"""
Shows all Payphone's stats.
Options:
- `<iterations>`: The amount of iterations (to display progress). Defaults to 5.
- `<wait>`: The amount of seconds to wait between iterations. Defaults to 5.
"""
stats = [
(
"On-call Channels",
lambda: redis.scard(constants.CHANNELS_ONCALL_SET),
),
(
"Searching Channels",
lambda: redis.scard(constants.CHANNELS_SEARCH_QUEUE),
),
(
"Messages",
lambda: models.Message.all().count(),
),
(
"Message Contents",
lambda: models.MessageContent.all().count(),
),
("Calls", lambda: models.Call.all().count()),
(
"Ongoing Calls",
lambda: models.Call.filter(ended_at=None).count(),
),
("Bans", lambda: models.Ban.all().count()),
("Reports", lambda: models.Report.all().count()),
(
"On-review Reports",
lambda: models.Report.filter(
status=models.ReportStatus.ON_REVIEW
).count(),
),
("Users", lambda: models.User.all().count()),
(
"Profiles",
lambda: models.Profile.all().count(),
),
("Servers", lambda: models.Server.all().count()),
("Shards", lambda: self.bot.shard_count),
("Current Shards", lambda: ctx.guild.shard_id + 1),
]
rows = [[stat[0]] for stat in stats]
rows_with_diff = [[stat[0]] for stat in stats]
i = 0
message = None
while i < iterations:
i += 1
for stat_i, stat in enumerate(stats):
value = stat[1]()
if isinstance(value, Awaitable):
value = await value
rows[stat_i].append(value)
prev_value = rows[stat_i][-2]
if isinstance(prev_value, str):
rows_with_diff[stat_i].append(f"{value:,}")
else:
diff = value - prev_value
rows_with_diff[stat_i].append(
f"{value:,}"
if diff == 0
else (f"{value:,}", f"({"+" if diff > 0 else ""}{diff})")
)
table = fmt.monospace_table(
rows_with_diff,
[""] + [f"{wait * j}s" for j in range(i)],
padding=2,
)
if message is None:
message = await ctx.send(f"```\n{table}\n```")
else:
message = await message.edit(f"```\n{table}\n```")
if i < iterations:
await asyncio.sleep(wait)
@commands.command()
async def user(self, ctx: commands.Context, user: converters.DiscordUserID) -> None:
"""
Shows all the information about a user.
Options:
- `[<user>]`: The user, their ID, or `.` or `reply` replying to their message.
"""
user_id = user
user = await models.User.get_or_none(id=user_id).prefetch_related("profiles")
profile: models.Profile | None = user.profiles[0] if user is not None else None
bans = await models.Ban.filter(
expressions.Q(
expires_at=None,
expires_at__gt=datetime.now(timezone.utc),
join_type="OR",
),
user_id=user_id,
)
embed = discord.Embed(
color=(
(profile.color or constants.EMBED_COLOR)
if profile is not None
else constants.EMBED_COLOR
),
title="Profile Information",
timestamp=datetime.now(timezone.utc),
fields=[
discord.EmbedField(
name="Ongoing Bans",
value="\n".join(
[
f"- `ID: {ban.id}`{f" (by <@{ban.banned_by_id}>)" if ban.banned_by_id else None}: {ban.reason if ban.reason else "*No reason*"}. Banned {f"until <t:{int(ban.expires_at.timestamp())}:R>" if ban.expires_at is not None else "permanently"}."
for ban in bans
]
)
or "*No ongoing bans.*",
)
],
author=(
profile.embed_author if profile is not None else self.bot.embed_author
),
thumbnail=(
profile.avatar_url
if profile is not None
else self.bot.user.display_avatar.url
),
)
await ctx.send(embed=embed)
def setup(b: bot.Payphone) -> None:
"""Load the cog into the bot.
Args:
b (bot.Payphone): The bot instance to load the cog into.
"""
b.add_cog(Mods(b))