Files
Red-DiscordBot/redbot/core/_events.py
T
2026-05-13 14:14:43 -08:00

525 lines
23 KiB
Python

import asyncio
import contextlib
import platform
import shlex
import sys
import logging
import traceback
from datetime import datetime, timedelta, timezone
from typing import Tuple
import aiohttp
import discord
import redbot_update
from packaging.specifiers import SpecifierSet
from packaging.version import Version
from redbot.core import data_manager
from redbot.core.bot import ExitCodes
from redbot.core.commands import RedHelpFormatter, HelpSettings
from redbot.core.i18n import (
Translator,
set_contextual_locales_from_guild,
)
from .. import __version__ as red_version
from . import commands
from ._config import get_latest_confs
from .utils._internal_utils import (
fuzzy_command_search,
format_fuzzy_results,
fetch_latest_red_version,
send_to_owners_with_prefix_replaced,
)
from .utils.chat_formatting import inline, format_perms_list
import rich
from rich import box
from rich.table import Table
from rich.columns import Columns
from rich.panel import Panel
from rich.text import Text
log = logging.getLogger("red")
INTRO = r"""
______ _ ______ _ _ ______ _
| ___ \ | | | _ (_) | | | ___ \ | |
| |_/ /___ __| | ______ | | | |_ ___ ___ ___ _ __ __| | | |_/ / ___ | |_
| // _ \/ _` | |______| | | | | / __|/ __/ _ \| '__/ _` | | ___ \/ _ \| __|
| |\ \ __/ (_| | | |/ /| \__ \ (_| (_) | | | (_| | | |_/ / (_) | |_
\_| \_\___|\__,_| |___/ |_|___/\___\___/|_| \__,_| \____/ \___/ \__|
"""
_ = Translator(__name__, __file__)
def get_outdated_red_messages(pypi_version: str) -> Tuple[str, str]:
outdated_red_message = _(
"Your Red instance is out of date! {} is the current version, however you are using {}!"
).format(pypi_version, red_version)
rich_outdated_message = (
f"[red]Outdated version![/red]\n"
f"[red]!!![/red]Version [cyan]{pypi_version}[/] is available, "
f"but you're using [cyan]{red_version}[/][red]!!![/red]"
)
extra_update = _(
"\n\nWhile the following command should work in most scenarios as it is "
"based on your current OS, environment, and Python version, "
"**we highly recommend you to read the update docs at <{docs}> and "
"make sure there is nothing else that "
"needs to be done during the update.**"
).format(docs="https://docs.discord.red/en/stable/update_red.html")
redbot_update_bin = redbot_update.find_redbot_update_bin()
is_windows = platform.system() == "Windows"
update_command = f'"{redbot_update_bin}"' if is_windows else shlex.quote(redbot_update_bin)
extra_update += _(
"\n\nTo update your bot, first shutdown your bot"
" then open a window of {console} (Not as admin) and run the following: {command}"
).format(
console=_("Command Prompt") if is_windows else _("Terminal"),
command=f"```{update_command}```",
)
outdated_red_message += extra_update
return outdated_red_message, rich_outdated_message
def init_events(bot, cli_flags):
@bot.event
async def on_connect():
if bot._uptime is None:
log.info("Connected to Discord. Getting ready...")
@bot.event
async def on_ready():
try:
await _on_ready()
except Exception as exc:
log.critical("The bot failed to get ready!", exc_info=exc)
sys.exit(ExitCodes.CRITICAL)
async def _on_ready():
if bot._uptime is not None:
return
bot._uptime = datetime.utcnow()
guilds = len(bot.guilds)
users = len(set([m for m in bot.get_all_members()]))
invite_url = discord.utils.oauth_url(bot.application_id, scopes=("bot",))
prefixes = cli_flags.prefix or (await bot._config.prefix())
lang = await bot._config.locale()
dpy_version = discord.__version__
table_general_info = Table(show_edge=False, show_header=False, box=box.MINIMAL)
table_general_info.add_row("Prefixes", ", ".join(prefixes))
table_general_info.add_row("Language", lang)
table_general_info.add_row("Red version", red_version)
table_general_info.add_row("Discord.py version", dpy_version)
table_general_info.add_row("Storage type", data_manager.storage_type())
table_counts = Table(show_edge=False, show_header=False, box=box.MINIMAL)
# String conversion is needed as Rich doesn't deal with ints
table_counts.add_row("Shards", str(bot.shard_count))
table_counts.add_row("Servers", str(guilds))
if bot.intents.members: # Lets avoid 0 Unique Users
table_counts.add_row("Unique Users", str(users))
fetch_version_task = asyncio.create_task(fetch_latest_red_version())
log.info("Fetching information about latest Red version...")
try:
await asyncio.wait_for(asyncio.shield(fetch_version_task), timeout=5)
except asyncio.TimeoutError:
log.info("Version information will continue to be fetched in the background...")
except Exception:
# these will be logged later
pass
rich_console = rich.get_console()
rich_console.print(INTRO, style="red", markup=False, highlight=False)
if guilds:
rich_console.print(
Columns(
[Panel(table_general_info, title=bot.user.display_name), Panel(table_counts)],
equal=True,
align="center",
)
)
else:
rich_console.print(Columns([Panel(table_general_info, title=bot.user.display_name)]))
rich_console.print(
"Loaded {} cogs with {} commands".format(len(bot.cogs), len(bot.commands))
)
if invite_url:
rich_console.print(f"\nInvite URL: {Text(invite_url, style=f'link {invite_url}')}")
# We generally shouldn't care if the client supports it or not as Rich deals with it.
if not guilds:
rich_console.print(
f"Looking for a quick guide on setting up Red? Checkout {Text('https://start.discord.red', style='link https://start.discord.red}')}"
)
bot._red_ready.set()
try:
latest = await fetch_version_task
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
log.error("Failed to fetch latest version information from PyPI.", exc_info=exc)
except (KeyError, ValueError) as exc:
log.error("Failed to parse version metadata received from PyPI.", exc_info=exc)
else:
outdated = latest.version > Version(red_version)
if outdated:
outdated_red_message, rich_outdated_message = get_outdated_red_messages(
latest.version
)
rich_console.print(rich_outdated_message)
await send_to_owners_with_prefix_replaced(bot, outdated_red_message)
@bot.event
async def on_command_completion(ctx: commands.Context):
await bot._delete_delay(ctx)
@bot.event
async def on_command_error(ctx, error, unhandled_by_cog=False):
if not unhandled_by_cog:
if hasattr(ctx.command, "on_error"):
return
if ctx.cog:
if ctx.cog.has_error_handler():
return
if not isinstance(error, commands.CommandNotFound):
asyncio.create_task(bot._delete_delay(ctx))
converter = getattr(ctx.current_parameter, "converter", None)
argument = ctx.current_argument
if isinstance(error, commands.MissingRequiredArgument):
await ctx.send_help()
elif isinstance(error, commands.ArgParserFailure):
msg = _("`{user_input}` is not a valid value for `{command}`").format(
user_input=error.user_input, command=error.cmd
)
if error.custom_help_msg:
msg += f"\n{error.custom_help_msg}"
await ctx.send(msg)
if error.send_cmd_help:
await ctx.send_help()
elif isinstance(error, commands.RangeError):
if isinstance(error.value, int):
if error.minimum == 0 and error.maximum is None:
message = _("Argument `{parameter_name}` must be a positive integer.")
elif error.minimum is None and error.maximum is not None:
message = _(
"Argument `{parameter_name}` must be an integer no more than {maximum}."
)
elif error.minimum is not None and error.maximum is None:
message = _(
"Argument `{parameter_name}` must be an integer no less than {minimum}."
)
elif error.maximum is not None and error.minimum is not None:
message = _(
"Argument `{parameter_name}` must be an integer between {minimum} and {maximum}."
)
elif isinstance(error.value, float):
if error.minimum == 0 and error.maximum is None:
message = _("Argument `{parameter_name}` must be a positive number.")
elif error.minimum is None and error.maximum is not None:
message = _(
"Argument `{parameter_name}` must be a number no more than {maximum}."
)
elif error.minimum is not None and error.maximum is None:
message = _(
"Argument `{parameter_name}` must be a number no less than {maximum}."
)
elif error.maximum is not None and error.minimum is not None:
message = _(
"Argument `{parameter_name}` must be a number between {minimum} and {maximum}."
)
elif isinstance(error.value, str):
if error.minimum is None and error.maximum is not None:
message = _(
"Argument `{parameter_name}` must be a string with a length of no more than {maximum}."
)
elif error.minimum is not None and error.maximum is None:
message = _(
"Argument `{parameter_name}` must be a string with a length of no less than {minimum}."
)
elif error.maximum is not None and error.minimum is not None:
message = _(
"Argument `{parameter_name}` must be a string with a length of between {minimum} and {maximum}."
)
await ctx.send(
message.format(
maximum=error.maximum,
minimum=error.minimum,
parameter_name=ctx.current_parameter.name,
)
)
return
elif isinstance(error, commands.BadArgument):
if isinstance(converter, commands.Range):
if converter.annotation is int:
if converter.min == 0 and converter.max is None:
message = _("Argument `{parameter_name}` must be a positive integer.")
elif converter.min is None and converter.max is not None:
message = _(
"Argument `{parameter_name}` must be an integer no more than {maximum}."
)
elif converter.min is not None and converter.max is None:
message = _(
"Argument `{parameter_name}` must be an integer no less than {minimum}."
)
elif converter.max is not None and converter.min is not None:
message = _(
"Argument `{parameter_name}` must be an integer between {minimum} and {maximum}."
)
elif converter.annotation is float:
if converter.min == 0 and converter.max is None:
message = _("Argument `{parameter_name}` must be a positive number.")
elif converter.min is None and converter.max is not None:
message = _(
"Argument `{parameter_name}` must be a number no more than {maximum}."
)
elif converter.min is not None and converter.max is None:
message = _(
"Argument `{parameter_name}` must be a number no less than {minimum}."
)
elif converter.max is not None and converter.min is not None:
message = _(
"Argument `{parameter_name}` must be a number between {minimum} and {maximum}."
)
elif converter.annotation is str:
if error.minimum is None and error.maximum is not None:
message = _(
"Argument `{parameter_name}` must be a string with a length of no more than {maximum}."
)
elif error.minimum is not None and error.maximum is None:
message = _(
"Argument `{parameter_name}` must be a string with a length of no less than {minimum}."
)
elif error.maximum is not None and error.minimum is not None:
message = _(
"Argument `{parameter_name}` must be a string with a length of between {minimum} and {maximum}."
)
await ctx.send(
message.format(
maximum=converter.max,
minimum=converter.min,
parameter_name=ctx.current_parameter.name,
)
)
return
if isinstance(error.__cause__, ValueError):
if converter is int:
await ctx.send(_('"{argument}" is not an integer.').format(argument=argument))
return
if converter is float:
await ctx.send(_('"{argument}" is not a number.').format(argument=argument))
return
if error.args:
await ctx.send(error.args[0])
else:
await ctx.send_help()
elif isinstance(error, commands.UserInputError):
await ctx.send_help()
elif isinstance(error, commands.DisabledCommand):
disabled_message = await bot._config.disabled_command_msg()
if disabled_message:
await ctx.send(disabled_message.replace("{command}", ctx.invoked_with))
elif isinstance(error, commands.CommandInvokeError):
log.exception(
"Exception in command '{}'".format(ctx.command.qualified_name),
exc_info=error.original,
)
exception_log = "Exception in command '{}'\n" "".format(ctx.command.qualified_name)
exception_log += "".join(
traceback.format_exception(type(error), error, error.__traceback__)
)
bot._last_exception = exception_log
message = await bot._config.invoke_error_msg()
if not message:
if ctx.author.id in bot.owner_ids:
message = inline(
_("Error in command '{command}'. Check your console or logs for details.")
)
else:
message = inline(_("Error in command '{command}'."))
await ctx.send(message.replace("{command}", ctx.command.qualified_name))
elif isinstance(error, commands.CommandNotFound):
if not await bot.message_eligible_as_command(ctx.message):
return
help_settings = await HelpSettings.from_context(ctx)
fuzzy_commands = await fuzzy_command_search(
ctx,
commands=RedHelpFormatter.help_filter_func(
ctx, bot.walk_commands(), help_settings=help_settings
),
)
if not fuzzy_commands:
pass
elif await ctx.embed_requested():
await ctx.send(embed=await format_fuzzy_results(ctx, fuzzy_commands, embed=True))
else:
await ctx.send(await format_fuzzy_results(ctx, fuzzy_commands, embed=False))
elif isinstance(error, commands.BotMissingPermissions):
if bin(error.missing.value).count("1") == 1: # Only one perm missing
msg = _("I require the {permission} permission to execute that command.").format(
permission=format_perms_list(error.missing)
)
else:
msg = _("I require {permission_list} permissions to execute that command.").format(
permission_list=format_perms_list(error.missing)
)
await ctx.send(msg)
elif isinstance(error, commands.UserFeedbackCheckFailure):
if error.message:
await ctx.send(error.message)
elif isinstance(error, commands.NoPrivateMessage):
await ctx.send(_("That command is not available in DMs."))
elif isinstance(error, commands.PrivateMessageOnly):
await ctx.send(_("That command is only available in DMs."))
elif isinstance(error, commands.NSFWChannelRequired):
await ctx.send(_("That command is only available in NSFW channels."))
elif isinstance(error, commands.CheckFailure):
pass
elif isinstance(error, commands.CommandOnCooldown):
if bot._bypass_cooldowns and ctx.author.id in bot.owner_ids:
ctx.command.reset_cooldown(ctx)
new_ctx = await bot.get_context(ctx.message)
await bot.invoke(new_ctx)
return
relative_time = discord.utils.format_dt(
datetime.now(timezone.utc) + timedelta(seconds=error.retry_after), "R"
)
msg = _("This command is on cooldown. Try again {relative_time}.").format(
relative_time=relative_time
)
await ctx.send(msg, delete_after=error.retry_after)
elif isinstance(error, commands.MaxConcurrencyReached):
if error.per is commands.BucketType.default:
if error.number > 1:
msg = _(
"Too many people using this command."
" It can only be used {number} times concurrently."
).format(number=error.number)
else:
msg = _(
"Too many people using this command."
" It can only be used once concurrently."
)
elif error.per in (commands.BucketType.user, commands.BucketType.member):
if error.number > 1:
msg = _(
"That command is still completing,"
" it can only be used {number} times per {type} concurrently."
).format(number=error.number, type=error.per.name)
else:
msg = _(
"That command is still completing,"
" it can only be used once per {type} concurrently."
).format(type=error.per.name)
else:
if error.number > 1:
msg = _(
"Too many people using this command."
" It can only be used {number} times per {type} concurrently."
).format(number=error.number, type=error.per.name)
else:
msg = _(
"Too many people using this command."
" It can only be used once per {type} concurrently."
).format(type=error.per.name)
await ctx.send(msg)
else:
log.exception(type(error).__name__, exc_info=error)
@bot.event
async def on_message(message, /):
await set_contextual_locales_from_guild(bot, message.guild)
await bot.process_commands(message)
discord_now = message.created_at
if (
not bot._checked_time_accuracy
or (discord_now - timedelta(minutes=60)) > bot._checked_time_accuracy
):
system_now = datetime.now(timezone.utc)
diff = abs((discord_now - system_now).total_seconds())
if diff > 60:
log.warning(
"Detected significant difference (%d seconds) in system clock to discord's "
"clock. Any time sensitive code may fail.",
diff,
)
bot._checked_time_accuracy = discord_now
@bot.event
async def on_command_add(command: commands.Command):
if command.cog is not None:
return
await _disable_command_no_cog(command)
async def _guild_added(guild: discord.Guild):
disabled_commands = await bot._config.guild(guild).disabled_commands()
for command_name in disabled_commands:
command_obj = bot.get_command(command_name)
if command_obj is not None:
command_obj.disable_in(guild)
@bot.event
async def on_guild_join(guild: discord.Guild):
await _guild_added(guild)
@bot.event
async def on_guild_available(guild: discord.Guild):
# We need to check guild-disabled commands here since some cogs
# are loaded prior to `on_ready`.
await _guild_added(guild)
@bot.event
async def on_guild_remove(guild: discord.Guild):
# Clean up any unneeded checks
disabled_commands = await bot._config.guild(guild).disabled_commands()
for command_name in disabled_commands:
command_obj = bot.get_command(command_name)
if command_obj is not None:
command_obj.enable_in(guild)
@bot.event
async def on_cog_add(cog: commands.Cog):
confs = get_latest_confs()
for c in confs:
uuid = c.unique_identifier
group_data = c.custom_groups
await bot._config.custom("CUSTOM_GROUPS", c.cog_name, uuid).set(group_data)
await _disable_commands_cog(cog)
async def _disable_command(
command: commands.Command, global_disabled: list, guilds_data: dict
):
if command.qualified_name in global_disabled:
command.enabled = False
for guild_id, data in guilds_data.items():
guild_disabled_cmds = data.get("disabled_commands", [])
if command.qualified_name in guild_disabled_cmds:
command.disable_in(discord.Object(id=guild_id))
async def _disable_commands_cog(cog: commands.Cog):
global_disabled = await bot._config.disabled_commands()
guilds_data = await bot._config.all_guilds()
for command in cog.walk_commands():
await _disable_command(command, global_disabled, guilds_data)
async def _disable_command_no_cog(command: commands.Command):
global_disabled = await bot._config.disabled_commands()
guilds_data = await bot._config.all_guilds()
await _disable_command(command, global_disabled, guilds_data)