jack1142 febca8ccbb
Migration to discord.py 2.0 (#5600)
* Temporarily set d.py to use latest git revision

* Remove `bot` param to Client.start

* Switch to aware datetimes

A lot of this is removing `.replace(...)` which while not technically
needed, simplifies the code base. There's only a few changes that are
actually necessary here.

* Update to work with new Asset design

* [threads] Update core ModLog API to support threads

- Added proper support for passing `Thread` to `channel`
  when creating/editing case
- Added `parent_channel_id` attribute to Modlog API's Case
    - Added `parent_channel` property that tries to get parent channel
- Updated case's content to show both thread and parent information

* [threads] Disallow usage of threads in some of the commands

- announceset channel
- filter channel clear
- filter channel add
- filter channel remove
- GlobalUniqueObjectFinder converter
    - permissions addglobalrule
    - permissions removeglobalrule
    - permissions removeserverrule
    - Permissions cog does not perform any validation for IDs
      when setting through YAML so that has not been touched
- streamalert twitch/youtube/picarto
- embedset channel
- set ownernotifications adddestination

* [threads] Handle threads in Red's permissions system (Requires)

- Made permissions system apply rules of (only) parent in threads

* [threads] Update embed_requested to support threads

- Threads don't have their own embed settings and inherit from parent

* [threads] Update Red.message_eligible_as_command to support threads

* [threads] Properly handle invocation of [p](un)mutechannel in threads

Usage of a (un)mutechannel will mute/unmute user in the parent channel
if it's invoked in a thread.

* [threads] Update Filter cog to properly handle threads

- `[p]filter channel list` in a threads sends list for parent channel
- Checking for filter hits for a message in a thread checks its parent
  channel's word list. There's no separate word list for threads.

* [threads] Support threads in Audio cog

- Handle threads being notify channels
- Update type hint for `is_query_allowed()`

* [threads] Update type hints and documentation to reflect thread support

- Documented that `{channel}` in CCs might be a thread
- Allowed (documented) usage of threads with `Config.channel()`
    - Separate thread scope is still in the picture though
      if it were to be done, it's going to be in separate in PR
- GuildContext.channel might be Thread

* Use less costy channel check in customcom's on_message_without_command

This isn't needed for d.py 2.0 but whatever...

* Update for in-place edits

* Embed's bool changed behavior, I'm hoping it doesn't affect us

* Address User.permissions_in() removal

* Swap VerificationLevel.extreme with VerificationLevel.highest

* Change to keyword-only parameters

* Change of `Guild.vanity_invite()` return type

* avatar -> display_avatar

* Fix metaclass shenanigans with Converter

* Update Red.add_cog() to be inline with `dpy_commands.Bot.add_cog()`

This means adding `override` keyword-only parameter and causing
small breakage by swapping RuntimeError with discord.ClientException.

* Address all DEP-WARNs

* Remove Context.clean_prefix and use upstream implementation instead

* Remove commands.Literal and use upstream implementation instead

Honestly, this was a rather bad implementation anyway...

Breaking but actually not really - it was provisional.

* Update Command.callback's setter

Support for functools.partial is now built into d.py

* Add new perms in HUMANIZED_PERM mapping (some from d.py 1.7 it seems)

BTW, that should really be in core instead of what we have now...

* Remove the part of do_conversion that has not worked for a long while

* Stop wrapping BadArgument in ConversionFailure

This is breaking but it's best to resolve it like this.

The functionality of ConversionFailure can be replicated with
Context.current_parameter and Context.current_argument.

* Add custom errors for int and float converters

* Remove Command.__call__ as it's now implemented in d.py

* Get rid of _dpy_reimplements

These were reimplemented for the purpose of typing
so it is no longer needed now that d.py is type hinted.

* Add return to Red.remove_cog

* Ensure we don't delete messages that differ only by used sticker

* discord.InvalidArgument->ValueError

* Move from raw <t:...> syntax to discord.utils.format_dt()

* Address AsyncIter removal

* Swap to pos-only for params that are pos-only in upstream

* Update for changes to Command.params

* [threads] Support threads in ignore checks and allow ignoring them

- Updated `[p](un)ignore channel` to accept threads
- Updated `[p]ignore list` to list ignored threads
- Updated logic in `Red.ignored_channel_or_guild()`

Ignores for guild channels now work as follows (only changes for threads):
- if channel is not a thread:
    - check if user has manage channels perm in channel
      and allow command usage if so
    - check if channel is ignored and disallow command usage if so
    - allow command usage if none of the conditions above happened
- if channel is a thread:
    - check if user has manage channels perm in parent channel
      and allow command usage if so
    - check if parent channel is ignored and disallow command usage
      if so
    - check if user has manage thread perm in parent channel
      and allow command usage if so
    - check if thread is ignored and disallow command usage if so
    - allow command usage if none of the conditions above happened

* [partial] Raise TypeError when channel is of PartialMessageable type

- Red.embed_requested
- Red.ignored_channel_or_guild

* [partial] Discard command messages when channel is PartialMessageable

* [threads] Add utilities for checking appropriate perms in both channels & threads

* [threads] Update code to use can_react_in() and @bot_can_react()

* [threads] Update code to use can_send_messages_in

* [threads] Add send_messages_in_threads perm to mute role and overrides

* [threads] Update code to use (bot/user)_can_manage_channel

* [threads] Update [p]diagnoseissues to work with threads

* Type hint fix

* [threads] Patch vendored discord.ext.menus to check proper perms in threads

I guess we've reached time when we have to patch the lib we vendor...

* Make docs generation work with non-final d.py releases

* Update discord.utils.oauth_url() usage

* Swap usage of discord.Embed.Empty/discord.embeds.EmptyEmbed to None

* Update usage of Guild.member_count to work with `None`

* Switch from Guild.vanity_invite() to Guild.vanity_url

* Update startup process to work with d.py's new asynchronous startup

* Use setup_hook() for pre-connect actions

* Update core's add_cog, remove_cog, and load_extension methods

* Update all setup functions to async and add awaits to bot.add_cog calls

* Modernize cogs by using async cog_load and cog_unload

* Address StoreChannel removal

* [partial] Disallow passing PartialMessageable to Case.channel

* [partial] Update cogs and utils to work better with PartialMessageable

- Ignore messages with PartialMessageable channel in CustomCommands cog
- In Filter cog, don't pass channel to modlog.create_case()
  if it's PartialMessageable
- In Trivia cog, only compare channel IDs
- Make `.utils.menus.menu()` work for messages
  with PartialMessageable channel
- Make checks in `.utils.tunnel.Tunnel.communicate()` more rigid

* Add few missing DEP-WARNs
2022-04-03 03:21:20 +02:00

647 lines
25 KiB
Python

import asyncio
import contextlib
from datetime import timezone
from collections import namedtuple
from copy import copy
from typing import Union, Optional, Literal
import discord
from redbot.cogs.warnings.helpers import (
warning_points_add_check,
get_command_for_exceeded_points,
get_command_for_dropping_points,
warning_points_remove_check,
)
from redbot.core import Config, checks, commands, modlog
from redbot.core.bot import Red
from redbot.core.commands import UserInputOptional
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils import AsyncIter
from redbot.core.utils.chat_formatting import warning, pagify
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
_ = Translator("Warnings", __file__)
@cog_i18n(_)
class Warnings(commands.Cog):
"""Warn misbehaving users and take automated actions."""
default_guild = {
"actions": [],
"reasons": {},
"allow_custom_reasons": False,
"toggle_dm": True,
"show_mod": False,
"warn_channel": None,
"toggle_channel": False,
}
default_member = {"total_points": 0, "status": "", "warnings": {}}
def __init__(self, bot: Red):
super().__init__()
self.config = Config.get_conf(self, identifier=5757575755)
self.config.register_guild(**self.default_guild)
self.config.register_member(**self.default_member)
self.bot = bot
async def cog_load(self) -> None:
await self.register_warningtype()
async def red_delete_data_for_user(
self,
*,
requester: Literal["discord_deleted_user", "owner", "user", "user_strict"],
user_id: int,
):
if requester != "discord_deleted_user":
return
all_members = await self.config.all_members()
c = 0
for guild_id, guild_data in all_members.items():
c += 1
if not c % 100:
await asyncio.sleep(0)
if user_id in guild_data:
await self.config.member_from_ids(guild_id, user_id).clear()
for remaining_user, user_warns in guild_data.items():
c += 1
if not c % 100:
await asyncio.sleep(0)
for warn_id, warning in user_warns.get("warnings", {}).items():
c += 1
if not c % 100:
await asyncio.sleep(0)
if warning.get("mod", 0) == user_id:
grp = self.config.member_from_ids(guild_id, remaining_user)
await grp.set_raw("warnings", warn_id, "mod", value=0xDE1)
# We're not utilising modlog yet - no need to register a casetype
@staticmethod
async def register_warningtype():
casetypes_to_register = [
{
"name": "warning",
"default_setting": True,
"image": "\N{WARNING SIGN}\N{VARIATION SELECTOR-16}",
"case_str": "Warning",
},
{
"name": "unwarned",
"default_setting": True,
"image": "\N{WARNING SIGN}\N{VARIATION SELECTOR-16}",
"case_str": "Unwarned",
},
]
try:
await modlog.register_casetypes(casetypes_to_register)
except RuntimeError:
pass
@commands.group()
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
async def warningset(self, ctx: commands.Context):
"""Manage settings for Warnings."""
pass
@warningset.command()
@commands.guild_only()
async def allowcustomreasons(self, ctx: commands.Context, allowed: bool):
"""Enable or disable custom reasons for a warning."""
guild = ctx.guild
await self.config.guild(guild).allow_custom_reasons.set(allowed)
if allowed:
await ctx.send(_("Custom reasons have been enabled."))
else:
await ctx.send(_("Custom reasons have been disabled."))
@warningset.command()
@commands.guild_only()
async def senddm(self, ctx: commands.Context, true_or_false: bool):
"""Set whether warnings should be sent to users in DMs."""
await self.config.guild(ctx.guild).toggle_dm.set(true_or_false)
if true_or_false:
await ctx.send(_("I will now try to send warnings to users DMs."))
else:
await ctx.send(_("Warnings will no longer be sent to users DMs."))
@warningset.command()
@commands.guild_only()
async def showmoderator(self, ctx, true_or_false: bool):
"""Decide whether the name of the moderator warning a user should be included in the DM to that user."""
await self.config.guild(ctx.guild).show_mod.set(true_or_false)
if true_or_false:
await ctx.send(
_(
"I will include the name of the moderator who issued the warning when sending a DM to a user."
)
)
else:
await ctx.send(
_(
"I will not include the name of the moderator who issued the warning when sending a DM to a user."
)
)
@warningset.command()
@commands.guild_only()
async def warnchannel(self, ctx: commands.Context, channel: discord.TextChannel = None):
"""Set the channel where warnings should be sent to.
Leave empty to use the channel `[p]warn` command was called in.
"""
guild = ctx.guild
if channel:
await self.config.guild(guild).warn_channel.set(channel.id)
await ctx.send(
_("The warn channel has been set to {channel}.").format(channel=channel.mention)
)
else:
await self.config.guild(guild).warn_channel.set(channel)
await ctx.send(_("Warnings will now be sent in the channel command was used in."))
@warningset.command()
@commands.guild_only()
async def usewarnchannel(self, ctx: commands.Context, true_or_false: bool):
"""
Set if warnings should be sent to a channel set with `[p]warningset warnchannel`.
"""
await self.config.guild(ctx.guild).toggle_channel.set(true_or_false)
channel = self.bot.get_channel(await self.config.guild(ctx.guild).warn_channel())
if true_or_false:
if channel:
await ctx.send(
_("Warnings will now be sent to {channel}.").format(channel=channel.mention)
)
else:
await ctx.send(_("Warnings will now be sent in the channel command was used in."))
else:
await ctx.send(_("Toggle channel has been disabled."))
@commands.group()
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
async def warnaction(self, ctx: commands.Context):
"""Manage automated actions for Warnings.
Actions are essentially command macros. Any command can be run
when the action is initially triggered, and/or when the action
is lifted.
Actions must be given a name and a points threshold. When a
user is warned enough so that their points go over this
threshold, the action will be executed.
"""
pass
@warnaction.command(name="add")
@commands.guild_only()
async def action_add(self, ctx: commands.Context, name: str, points: int):
"""Create an automated action.
Duplicate action names are not allowed.
"""
guild = ctx.guild
exceed_command = await get_command_for_exceeded_points(ctx)
drop_command = await get_command_for_dropping_points(ctx)
to_add = {
"action_name": name,
"points": points,
"exceed_command": exceed_command,
"drop_command": drop_command,
}
# Have all details for the action, now save the action
guild_settings = self.config.guild(guild)
async with guild_settings.actions() as registered_actions:
for act in registered_actions:
if act["action_name"] == to_add["action_name"]:
await ctx.send(_("Duplicate action name found!"))
break
else:
registered_actions.append(to_add)
# Sort in descending order by point count for ease in
# finding the highest possible action to take
registered_actions.sort(key=lambda a: a["points"], reverse=True)
await ctx.send(_("Action {name} has been added.").format(name=name))
@warnaction.command(name="delete", aliases=["del", "remove"])
@commands.guild_only()
async def action_del(self, ctx: commands.Context, action_name: str):
"""Delete the action with the specified name."""
guild = ctx.guild
guild_settings = self.config.guild(guild)
async with guild_settings.actions() as registered_actions:
to_remove = None
for act in registered_actions:
if act["action_name"] == action_name:
to_remove = act
break
if to_remove:
registered_actions.remove(to_remove)
await ctx.tick()
else:
await ctx.send(_("No action named {name} exists!").format(name=action_name))
@commands.group()
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
async def warnreason(self, ctx: commands.Context):
"""Manage warning reasons.
Reasons must be given a name, description and points value. The
name of the reason must be given when a user is warned.
"""
pass
@warnreason.command(name="create", aliases=["add"])
@commands.guild_only()
async def reason_create(
self, ctx: commands.Context, name: str, points: int, *, description: str
):
"""Create a warning reason."""
guild = ctx.guild
if name.lower() == "custom":
await ctx.send(_("*Custom* cannot be used as a reason name!"))
return
to_add = {"points": points, "description": description}
completed = {name.lower(): to_add}
guild_settings = self.config.guild(guild)
async with guild_settings.reasons() as registered_reasons:
registered_reasons.update(completed)
await ctx.send(_("The new reason has been registered."))
@warnreason.command(name="delete", aliases=["remove", "del"])
@commands.guild_only()
async def reason_del(self, ctx: commands.Context, reason_name: str):
"""Delete a warning reason."""
guild = ctx.guild
guild_settings = self.config.guild(guild)
async with guild_settings.reasons() as registered_reasons:
if registered_reasons.pop(reason_name.lower(), None):
await ctx.tick()
else:
await ctx.send(_("That is not a registered reason name."))
@commands.command()
@commands.guild_only()
@checks.admin_or_permissions(ban_members=True)
async def reasonlist(self, ctx: commands.Context):
"""List all configured reasons for Warnings."""
guild = ctx.guild
guild_settings = self.config.guild(guild)
msg_list = []
async with guild_settings.reasons() as registered_reasons:
for r, v in registered_reasons.items():
if await ctx.embed_requested():
em = discord.Embed(
title=_("Reason: {name}").format(name=r),
description=v["description"],
color=await ctx.embed_colour(),
)
em.add_field(name=_("Points"), value=str(v["points"]))
msg_list.append(em)
else:
msg_list.append(
_(
"Name: {reason_name}\nPoints: {points}\nDescription: {description}"
).format(reason_name=r, **v)
)
if msg_list:
await menu(ctx, msg_list, DEFAULT_CONTROLS)
else:
await ctx.send(_("There are no reasons configured!"))
@commands.command()
@commands.guild_only()
@checks.admin_or_permissions(ban_members=True)
async def actionlist(self, ctx: commands.Context):
"""List all configured automated actions for Warnings."""
guild = ctx.guild
guild_settings = self.config.guild(guild)
msg_list = []
async with guild_settings.actions() as registered_actions:
for r in registered_actions:
if await ctx.embed_requested():
em = discord.Embed(
title=_("Action: {name}").format(name=r["action_name"]),
color=await ctx.embed_colour(),
)
em.add_field(name=_("Points"), value="{}".format(r["points"]), inline=False)
em.add_field(
name=_("Exceed command"),
value=r["exceed_command"],
inline=False,
)
em.add_field(name=_("Drop command"), value=r["drop_command"], inline=False)
msg_list.append(em)
else:
msg_list.append(
_(
"Name: {action_name}\nPoints: {points}\n"
"Exceed command: {exceed_command}\nDrop command: {drop_command}"
).format(**r)
)
if msg_list:
await menu(ctx, msg_list, DEFAULT_CONTROLS)
else:
await ctx.send(_("There are no actions configured!"))
@commands.command()
@commands.guild_only()
@checks.admin_or_permissions(ban_members=True)
async def warn(
self,
ctx: commands.Context,
member: discord.Member,
points: UserInputOptional[int] = 1,
*,
reason: str,
):
"""Warn the user for the specified reason.
`<points>` number of points the warning should be for. If no number is supplied
1 point will be given. Pre-set warnings disregard this.
`<reason>` is reason for the warning. This can be a registered reason,
or a custom reason if ``[p]warningset allowcustomreasons`` is set.
"""
guild = ctx.guild
if member == ctx.author:
return await ctx.send(_("You cannot warn yourself."))
if member.bot:
return await ctx.send(_("You cannot warn other bots."))
if member == ctx.guild.owner:
return await ctx.send(_("You cannot warn the server owner."))
if member.top_role >= ctx.author.top_role and ctx.author != ctx.guild.owner:
return await ctx.send(
_(
"The person you're trying to warn is equal or higher than you in the discord hierarchy, you cannot warn them."
)
)
guild_settings = await self.config.guild(ctx.guild).all()
custom_allowed = guild_settings["allow_custom_reasons"]
reason_type = None
async with self.config.guild(ctx.guild).reasons() as registered_reasons:
if (reason_type := registered_reasons.get(reason.lower())) is None:
msg = _("That is not a registered reason!")
if custom_allowed:
if points < 0:
return await ctx.send(_("You cannot apply negative points."))
reason_type = {"description": reason, "points": points}
else:
# logic taken from `[p]permissions canrun`
fake_message = copy(ctx.message)
fake_message.content = f"{ctx.prefix}warningset allowcustomreasons"
fake_context = await ctx.bot.get_context(fake_message)
try:
can = await self.allowcustomreasons.can_run(
fake_context, check_all_parents=True, change_permission_state=False
)
except commands.CommandError:
can = False
if can:
msg += " " + _(
"Do `{prefix}warningset allowcustomreasons true` to enable custom "
"reasons."
).format(prefix=ctx.clean_prefix)
return await ctx.send(msg)
if reason_type is None:
return
member_settings = self.config.member(member)
current_point_count = await member_settings.total_points()
warning_to_add = {
str(ctx.message.id): {
"points": reason_type["points"],
"description": reason_type["description"],
"mod": ctx.author.id,
}
}
dm = guild_settings["toggle_dm"]
showmod = guild_settings["show_mod"]
dm_failed = False
if dm:
if showmod:
title = _("Warning from {user}").format(user=ctx.author)
else:
title = _("Warning")
em = discord.Embed(
title=title, description=reason_type["description"], color=await ctx.embed_colour()
)
em.add_field(name=_("Points"), value=str(reason_type["points"]))
try:
await member.send(
_("You have received a warning in {guild_name}.").format(
guild_name=ctx.guild.name
),
embed=em,
)
except discord.HTTPException:
dm_failed = True
if dm_failed:
await ctx.send(
_(
"A warning for {user} has been issued,"
" but I wasn't able to send them a warn message."
).format(user=member.mention)
)
async with member_settings.warnings() as user_warnings:
user_warnings.update(warning_to_add)
current_point_count += reason_type["points"]
await member_settings.total_points.set(current_point_count)
await warning_points_add_check(self.config, ctx, member, current_point_count)
toggle_channel = guild_settings["toggle_channel"]
if toggle_channel:
if showmod:
title = _("Warning from {user}").format(user=ctx.author)
else:
title = _("Warning")
em = discord.Embed(
title=title, description=reason_type["description"], color=await ctx.embed_colour()
)
em.add_field(name=_("Points"), value=str(reason_type["points"]))
warn_channel = self.bot.get_channel(guild_settings["warn_channel"])
if warn_channel:
if warn_channel.permissions_for(guild.me).send_messages:
with contextlib.suppress(discord.HTTPException):
await warn_channel.send(
_("{user} has been warned.").format(user=member.mention),
embed=em,
)
if not dm_failed:
if warn_channel:
await ctx.tick()
else:
await ctx.send(
_("{user} has been warned.").format(user=member.mention), embed=em
)
else:
if not dm_failed:
await ctx.tick()
reason_msg = _(
"{reason}\n\nUse `{prefix}unwarn {user} {message}` to remove this warning."
).format(
reason=_("{description}\nPoints: {points}").format(
description=reason_type["description"], points=reason_type["points"]
),
prefix=ctx.clean_prefix,
user=member.id,
message=ctx.message.id,
)
await modlog.create_case(
self.bot,
ctx.guild,
ctx.message.created_at,
"warning",
member,
ctx.message.author,
reason_msg,
until=None,
channel=None,
)
@commands.command()
@commands.guild_only()
@checks.admin()
async def warnings(self, ctx: commands.Context, member: Union[discord.Member, int]):
"""List the warnings for the specified user."""
try:
userid: int = member.id
except AttributeError:
userid: int = member
member = ctx.guild.get_member(userid)
member = member or namedtuple("Member", "id guild")(userid, ctx.guild)
msg = ""
member_settings = self.config.member(member)
async with member_settings.warnings() as user_warnings:
if not user_warnings.keys(): # no warnings for the user
await ctx.send(_("That user has no warnings!"))
else:
for key in user_warnings.keys():
mod_id = user_warnings[key]["mod"]
if mod_id == 0xDE1:
mod = _("Deleted Moderator")
else:
bot = ctx.bot
mod = bot.get_user(mod_id) or _("Unknown Moderator ({})").format(mod_id)
msg += _(
"{num_points} point warning {reason_name} issued by {user} for "
"{description}\n"
).format(
num_points=user_warnings[key]["points"],
reason_name=key,
user=mod,
description=user_warnings[key]["description"],
)
await ctx.send_interactive(
pagify(msg, shorten_by=58),
box_lang=_("Warnings for {user}").format(
user=member if isinstance(member, discord.Member) else member.id
),
)
@commands.command()
@commands.guild_only()
async def mywarnings(self, ctx: commands.Context):
"""List warnings for yourself."""
user = ctx.author
msg = ""
member_settings = self.config.member(user)
async with member_settings.warnings() as user_warnings:
if not user_warnings.keys(): # no warnings for the user
await ctx.send(_("You have no warnings!"))
else:
for key in user_warnings.keys():
mod_id = user_warnings[key]["mod"]
if mod_id == 0xDE1:
mod = _("Deleted Moderator")
else:
bot = ctx.bot
mod = bot.get_user(mod_id) or _("Unknown Moderator ({})").format(mod_id)
msg += _(
"{num_points} point warning {reason_name} issued by {user} for "
"{description}\n"
).format(
num_points=user_warnings[key]["points"],
reason_name=key,
user=mod,
description=user_warnings[key]["description"],
)
await ctx.send_interactive(
pagify(msg, shorten_by=58),
box_lang=_("Warnings for {user}").format(user=user),
)
@commands.command()
@commands.guild_only()
@checks.admin_or_permissions(ban_members=True)
async def unwarn(
self,
ctx: commands.Context,
member: Union[discord.Member, int],
warn_id: str,
*,
reason: str = None,
):
"""Remove a warning from a user."""
guild = ctx.guild
try:
user_id = member.id
member = member
except AttributeError:
user_id = member
member = guild.get_member(user_id)
member = member or namedtuple("Member", "guild id")(guild, user_id)
if user_id == ctx.author.id:
return await ctx.send(_("You cannot remove warnings from yourself."))
member_settings = self.config.member(member)
current_point_count = await member_settings.total_points()
await warning_points_remove_check(self.config, ctx, member, current_point_count)
async with member_settings.warnings() as user_warnings:
if warn_id not in user_warnings.keys():
return await ctx.send(_("That warning doesn't exist!"))
else:
current_point_count -= user_warnings[warn_id]["points"]
await member_settings.total_points.set(current_point_count)
user_warnings.pop(warn_id)
await modlog.create_case(
self.bot,
ctx.guild,
ctx.message.created_at,
"unwarned",
member,
ctx.message.author,
reason,
until=None,
channel=None,
)
await ctx.tick()