2024-01-10 09:39:46 +00:00
|
|
|
import asyncio
|
2024-01-02 22:29:25 +00:00
|
|
|
import datetime
|
2024-04-29 01:41:02 +01:00
|
|
|
import glob
|
2024-01-02 22:59:47 +00:00
|
|
|
import logging
|
2024-04-18 00:24:58 +01:00
|
|
|
import random
|
2024-04-14 18:02:22 +01:00
|
|
|
import shutil
|
2024-01-06 21:43:52 +00:00
|
|
|
import sys
|
2024-04-18 00:24:58 +01:00
|
|
|
import time
|
2024-01-02 22:29:25 +00:00
|
|
|
import traceback
|
2024-01-10 09:39:46 +00:00
|
|
|
import typing
|
2024-01-02 22:59:47 +00:00
|
|
|
from logging import FileHandler
|
2024-04-18 00:24:58 +01:00
|
|
|
from threading import Event, Thread
|
2024-01-02 22:29:25 +00:00
|
|
|
|
|
|
|
import discord
|
2024-04-18 00:24:58 +01:00
|
|
|
import httpx
|
2024-01-02 22:29:25 +00:00
|
|
|
from discord.ext import commands
|
2024-04-14 18:02:22 +01:00
|
|
|
from rich.console import Console
|
2024-04-18 00:24:58 +01:00
|
|
|
from rich.logging import RichHandler
|
|
|
|
|
2024-01-06 20:53:12 +00:00
|
|
|
from conf import CONFIG
|
2024-01-02 22:29:25 +00:00
|
|
|
|
2024-03-04 12:39:45 +00:00
|
|
|
|
|
|
|
class KillableThread(Thread):
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self.kill = Event()
|
|
|
|
|
|
|
|
|
|
|
|
class KumaThread(KillableThread):
|
|
|
|
def __init__(self, url: str, interval: float = 60.0):
|
|
|
|
super().__init__(target=self.run)
|
|
|
|
self.daemon = True
|
2024-05-04 20:01:37 +01:00
|
|
|
self.log = logging.getLogger("jimmy .status")
|
2024-03-04 12:39:45 +00:00
|
|
|
self.url = url
|
|
|
|
self.interval = interval
|
|
|
|
self.kill = Event()
|
|
|
|
self.retries = 0
|
2024-05-01 20:20:29 +01:00
|
|
|
self.previous = False
|
2024-05-04 20:01:37 +01:00
|
|
|
self.was_ready = False
|
2024-04-16 00:46:26 +01:00
|
|
|
|
2024-03-04 12:39:45 +00:00
|
|
|
def calculate_backoff(self) -> float:
|
|
|
|
rnd = random.uniform(0, 1)
|
|
|
|
retries = min(self.retries, 1000)
|
2024-04-16 00:46:26 +01:00
|
|
|
t = (2 * 2**retries) + rnd
|
2024-03-04 12:39:45 +00:00
|
|
|
self.log.debug("Backoff: 2 * (2 ** %d) + %f = %f", retries, rnd, t)
|
|
|
|
# T can never exceed self.interval
|
|
|
|
return max(0, min(self.interval, t))
|
2024-04-16 00:46:26 +01:00
|
|
|
|
2024-03-04 12:39:45 +00:00
|
|
|
def run(self) -> None:
|
|
|
|
with httpx.Client(http2=True) as client:
|
|
|
|
while not self.kill.is_set():
|
|
|
|
start_time = time.time()
|
|
|
|
try:
|
|
|
|
self.retries += 1
|
2024-05-01 20:18:38 +01:00
|
|
|
url = self.url
|
|
|
|
if url.endswith("ping="):
|
2024-05-01 20:22:47 +01:00
|
|
|
url += str(round(bot.latency * 1000, 2))
|
2024-05-01 20:18:38 +01:00
|
|
|
if bot.is_ready() is False:
|
2024-05-04 20:01:37 +01:00
|
|
|
if self.was_ready is False:
|
|
|
|
# Wait rather than attack
|
|
|
|
time.sleep(1)
|
|
|
|
continue
|
2024-05-01 20:18:38 +01:00
|
|
|
url = url.replace("status=up", "status=down")
|
|
|
|
url = url.replace("msg=OK", "msg=Bot%20not%20ready")
|
2024-05-04 20:01:37 +01:00
|
|
|
else:
|
|
|
|
self.was_ready = True
|
2024-05-01 20:18:38 +01:00
|
|
|
response = client.get(url)
|
2024-03-04 12:39:45 +00:00
|
|
|
response.raise_for_status()
|
2024-05-01 20:20:29 +01:00
|
|
|
self.previous = bot.is_ready()
|
2024-03-04 12:39:45 +00:00
|
|
|
except httpx.HTTPError as error:
|
2024-05-01 20:18:38 +01:00
|
|
|
self.log.error("Failed to connect to uptime-kuma: %r: %r", url, error, exc_info=error)
|
2024-03-04 12:39:45 +00:00
|
|
|
timeout = self.calculate_backoff()
|
|
|
|
self.log.warning("Waiting %d seconds before retrying ping.", timeout)
|
|
|
|
time.sleep(timeout)
|
|
|
|
continue
|
|
|
|
|
|
|
|
self.retries = 0
|
|
|
|
end_time = time.time()
|
|
|
|
timeout = self.interval - (end_time - start_time)
|
2024-05-01 20:20:29 +01:00
|
|
|
if self.previous is False:
|
2024-05-01 20:22:47 +01:00
|
|
|
timeout = 2.5
|
2024-03-04 12:39:45 +00:00
|
|
|
self.kill.wait(timeout)
|
|
|
|
|
|
|
|
|
2024-01-02 22:29:25 +00:00
|
|
|
log = logging.getLogger("jimmy")
|
2024-01-06 21:43:52 +00:00
|
|
|
CONFIG.setdefault("logging", {})
|
2024-04-14 18:02:22 +01:00
|
|
|
cols, lns = shutil.get_terminal_size((120, 48))
|
2024-01-02 22:29:25 +00:00
|
|
|
|
|
|
|
logging.basicConfig(
|
2024-01-06 22:32:43 +00:00
|
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
2024-01-02 22:29:25 +00:00
|
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
2024-01-06 21:43:52 +00:00
|
|
|
level=CONFIG["logging"].get("level", "INFO"),
|
2024-01-02 22:29:25 +00:00
|
|
|
handlers=[
|
|
|
|
RichHandler(
|
2024-01-06 21:43:52 +00:00
|
|
|
level=CONFIG["logging"].get("level", "INFO"),
|
2024-01-02 22:29:25 +00:00
|
|
|
show_time=False,
|
|
|
|
show_path=False,
|
2024-04-14 18:02:22 +01:00
|
|
|
markup=True,
|
2024-04-16 00:46:26 +01:00
|
|
|
console=Console(width=cols, height=lns),
|
2024-01-02 22:29:25 +00:00
|
|
|
),
|
2024-05-05 13:30:39 +01:00
|
|
|
FileHandler(filename=CONFIG["logging"].get("file", "jimmy.log"), encoding="utf-8", errors="replace"),
|
2024-04-16 00:46:26 +01:00
|
|
|
],
|
2024-01-02 22:29:25 +00:00
|
|
|
)
|
2024-01-06 21:43:52 +00:00
|
|
|
for logger in CONFIG["logging"].get("suppress", []):
|
|
|
|
logging.getLogger(logger).setLevel(logging.WARNING)
|
|
|
|
log.info(f"Suppressed logging for {logger}")
|
2024-01-02 22:29:25 +00:00
|
|
|
|
2024-01-10 09:39:46 +00:00
|
|
|
|
|
|
|
class Client(commands.Bot):
|
|
|
|
def __init_(self, *args, **kwargs):
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self.web: typing.Optional[asyncio.Task] = None
|
2024-03-04 12:39:45 +00:00
|
|
|
self.uptime_thread = None
|
2024-05-27 17:34:26 +01:00
|
|
|
|
|
|
|
async def on_connect(self):
|
|
|
|
log.debug("Walking the following application commands: %r", list(self.walk_application_commands()))
|
|
|
|
for command in self.walk_application_commands():
|
|
|
|
log.debug("Checking %r", command)
|
|
|
|
try:
|
|
|
|
assert command.input_type is not None, "Input type was None!"
|
|
|
|
_ = command.to_dict()
|
|
|
|
except Exception as e:
|
|
|
|
log.error("Failed to convert %r to dict", command, exc_info=e)
|
|
|
|
self.remove_application_command(command)
|
|
|
|
else:
|
|
|
|
log.debug("Loaded command %r.", command)
|
|
|
|
log.info("Connected to Discord.")
|
|
|
|
super().on_connect()
|
2024-01-10 09:39:46 +00:00
|
|
|
|
|
|
|
async def start(self, token: str, *, reconnect: bool = True) -> None:
|
2024-03-04 12:39:45 +00:00
|
|
|
if CONFIG["jimmy"].get("uptime_kuma_url"):
|
|
|
|
self.uptime_thread = KumaThread(
|
2024-05-05 13:19:16 +01:00
|
|
|
CONFIG["jimmy"]["uptime_kuma_url"], CONFIG["jimmy"].get("uptime_kuma_interval", 58.0)
|
2024-03-04 12:39:45 +00:00
|
|
|
)
|
|
|
|
self.uptime_thread.start()
|
2024-01-10 09:39:46 +00:00
|
|
|
await super().start(token, reconnect=reconnect)
|
|
|
|
|
|
|
|
async def close(self) -> None:
|
2024-03-31 18:57:04 +01:00
|
|
|
if self.uptime_thread:
|
|
|
|
self.uptime_thread.kill.set()
|
|
|
|
await asyncio.get_event_loop().run_in_executor(None, self.uptime_thread.join)
|
2024-01-10 09:39:46 +00:00
|
|
|
await super().close()
|
|
|
|
|
2024-02-06 23:03:49 +00:00
|
|
|
|
2024-01-10 09:47:37 +00:00
|
|
|
bot = Client(
|
2024-01-02 22:29:25 +00:00
|
|
|
command_prefix=commands.when_mentioned_or("h!", "H!"),
|
|
|
|
case_insensitive=True,
|
|
|
|
strip_after_prefix=True,
|
2024-04-14 23:15:17 +01:00
|
|
|
debug_guilds=CONFIG["jimmy"].get("debug_guilds"),
|
2024-04-16 00:46:26 +01:00
|
|
|
intents=discord.Intents.all(),
|
2024-01-02 22:29:25 +00:00
|
|
|
)
|
|
|
|
|
2024-04-29 01:41:02 +01:00
|
|
|
for module in CONFIG["jimmy"].get("modules", ["cogs/*.py"]):
|
2024-02-06 23:03:49 +00:00
|
|
|
try:
|
2024-04-29 01:41:02 +01:00
|
|
|
bot.load_extension(module)
|
2024-04-29 01:42:02 +01:00
|
|
|
except (discord.ExtensionNotFound, ModuleNotFoundError):
|
2024-04-29 01:41:02 +01:00
|
|
|
log.debug("%r does not exist - trying as glob pattern", module)
|
|
|
|
for ext in glob.glob(module, recursive=True):
|
2024-05-01 01:18:06 +01:00
|
|
|
if ext.endswith(("__init__.py", ".pyc")):
|
|
|
|
continue
|
2024-04-29 01:41:02 +01:00
|
|
|
ext = ext.replace("/", ".")[:-3] # foo/bar.py -> foo.bar
|
2024-04-29 01:43:05 +01:00
|
|
|
if ext.split(".")[-1].startswith("__"):
|
|
|
|
log.info("Skipping loading %s - starts with double underscore." % ext)
|
2024-04-29 01:41:02 +01:00
|
|
|
try:
|
|
|
|
bot.load_extension(ext)
|
2024-04-29 01:42:16 +01:00
|
|
|
except (discord.ExtensionError, ModuleNotFoundError) as e:
|
2024-04-29 01:41:02 +01:00
|
|
|
log.error(f"Failed to load extension {ext}", exc_info=e)
|
|
|
|
else:
|
|
|
|
log.info(f"Loaded extension {ext}")
|
2024-02-06 23:03:49 +00:00
|
|
|
except discord.ExtensionError as e:
|
2024-04-29 01:41:02 +01:00
|
|
|
log.error(f"Failed to load extension {module}", exc_info=e)
|
2024-02-06 23:03:49 +00:00
|
|
|
else:
|
2024-04-29 01:41:02 +01:00
|
|
|
log.info(f"Loaded extension {module}")
|
2024-01-02 22:29:25 +00:00
|
|
|
|
2024-05-01 20:18:38 +01:00
|
|
|
|
2024-01-02 22:29:25 +00:00
|
|
|
@bot.event
|
|
|
|
async def on_ready():
|
|
|
|
log.info(f"Logged in as {bot.user} ({bot.user.id})")
|
|
|
|
|
|
|
|
|
|
|
|
@bot.listen()
|
|
|
|
async def on_application_command(ctx: discord.ApplicationContext):
|
|
|
|
log.info(f"Received command [b]{ctx.command}[/] from {ctx.author} in {ctx.guild}")
|
|
|
|
ctx.start_time = discord.utils.utcnow()
|
|
|
|
|
|
|
|
|
|
|
|
@bot.listen()
|
|
|
|
async def on_application_command_error(ctx: discord.ApplicationContext, exc: Exception):
|
|
|
|
log.error(f"Error in {ctx.command} from {ctx.author} in {ctx.guild}", exc_info=exc)
|
|
|
|
if isinstance(exc, commands.CommandOnCooldown):
|
|
|
|
expires = discord.utils.utcnow() + datetime.timedelta(seconds=exc.retry_after)
|
|
|
|
await ctx.respond(f"Command on cooldown. Try again {discord.utils.format_dt(expires, style='R')}.")
|
|
|
|
elif isinstance(exc, commands.MaxConcurrencyReached):
|
|
|
|
await ctx.respond("You've reached the maximum number of concurrent uses for this command.")
|
|
|
|
else:
|
|
|
|
if await bot.is_owner(ctx.author):
|
|
|
|
paginator = commands.Paginator(prefix="```py")
|
|
|
|
for line in traceback.format_exception(type(exc), exc, exc.__traceback__):
|
|
|
|
paginator.add_line(line[:1990])
|
|
|
|
for page in paginator.pages:
|
|
|
|
await ctx.respond(page)
|
|
|
|
else:
|
2024-04-16 00:46:26 +01:00
|
|
|
await ctx.respond(f"An error occurred while processing your command. Please try again later.\n" f"{exc}")
|
2024-01-02 22:29:25 +00:00
|
|
|
|
|
|
|
|
|
|
|
@bot.listen()
|
|
|
|
async def on_application_command_completion(ctx: discord.ApplicationContext):
|
|
|
|
time_taken = discord.utils.utcnow() - ctx.start_time
|
|
|
|
log.info(
|
|
|
|
f"Completed command [b]{ctx.command}[/] from {ctx.author} in "
|
|
|
|
f"{ctx.guild} in {time_taken.total_seconds():.2f} seconds."
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2024-01-10 09:59:45 +00:00
|
|
|
@bot.message_command(name="Delete Message")
|
2024-01-10 10:14:45 +00:00
|
|
|
async def delete_message(ctx: discord.ApplicationContext, message: discord.Message):
|
2024-01-10 09:59:45 +00:00
|
|
|
await ctx.defer()
|
|
|
|
if not ctx.channel.permissions_for(ctx.me).manage_messages:
|
2024-01-10 10:14:45 +00:00
|
|
|
if message.author != bot.user:
|
2024-01-10 09:59:45 +00:00
|
|
|
return await ctx.respond("I don't have permission to delete messages in this channel.", delete_after=30)
|
|
|
|
|
2024-04-16 00:46:26 +01:00
|
|
|
log.info("%s deleted message %s>%s: %r", ctx.author, ctx.channel.name, message.id, message.content)
|
2024-01-10 10:14:45 +00:00
|
|
|
await message.delete(delay=3)
|
|
|
|
await ctx.respond(f"\N{white heavy check mark} Deleted message by {message.author.display_name}.")
|
2024-01-10 09:59:45 +00:00
|
|
|
await ctx.delete(delay=15)
|
|
|
|
|
|
|
|
|
2024-05-14 18:02:18 +01:00
|
|
|
@bot.slash_command(name="about")
|
2024-05-14 18:01:18 +01:00
|
|
|
async def about_me(self, ctx: discord.ApplicationContext):
|
|
|
|
embed = discord.Embed(
|
|
|
|
title="Jimmy v3",
|
|
|
|
description="A bot specifically for the LCC discord server(s).",
|
|
|
|
colour=discord.Colour.green()
|
|
|
|
)
|
|
|
|
embed.add_field(
|
|
|
|
name="Source",
|
|
|
|
value="[Source code is available under AGPLv3.](https://git.i-am.nexus/nex/college-bot-v2)"
|
|
|
|
)
|
|
|
|
user = await self.bot.fetch_user(421698654189912064)
|
|
|
|
appinfo = await self.bot.application_info()
|
|
|
|
author = appinfo.owner
|
|
|
|
embed.add_field(name="Author", value=f"{user.mention} created me. {author.mention} is running this instance.")
|
|
|
|
return await ctx.respond(embed=embed)
|
|
|
|
|
|
|
|
|
2024-04-29 01:29:48 +01:00
|
|
|
@bot.check_once
|
|
|
|
async def check_is_enabled(ctx: commands.Context | discord.ApplicationContext) -> bool:
|
|
|
|
disabled = CONFIG["jimmy"].get("disabled_commands", [])
|
|
|
|
if ctx.command.qualified_name in disabled:
|
2024-05-01 01:18:06 +01:00
|
|
|
raise commands.DisabledCommand(
|
|
|
|
"%s is disabled via this instance's configuration file." % (
|
|
|
|
ctx.command.qualified_name
|
|
|
|
)
|
|
|
|
)
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
2024-05-01 01:26:03 +01:00
|
|
|
@bot.check_once
|
2024-05-01 01:18:06 +01:00
|
|
|
async def add_delays(ctx: commands.Context | discord.ApplicationContext) -> bool:
|
|
|
|
blocked = CONFIG["jimmy"].get("delay", {})
|
2024-05-01 01:27:18 +01:00
|
|
|
if str(ctx.author.id) in blocked:
|
|
|
|
range_start, range_end = blocked[str(ctx.author.id)]
|
2024-05-01 01:18:06 +01:00
|
|
|
range_start = max(0.01, range_start)
|
|
|
|
range_end = min(2.89, range_end)
|
|
|
|
n = random.randint(round(range_start * 100), round(range_end * 100)) / 100
|
|
|
|
log.warning("Delaying user %s by %.2f seconds.", ctx.author, n)
|
|
|
|
await asyncio.sleep(n)
|
|
|
|
log.info("Artificial delay for %s lifted", ctx.author)
|
2024-05-01 01:26:03 +01:00
|
|
|
else:
|
|
|
|
log.debug("%s is not in %s", ctx.author.id, blocked.keys())
|
2024-04-29 01:29:48 +01:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
2024-01-06 21:43:52 +00:00
|
|
|
if not CONFIG["jimmy"].get("token"):
|
|
|
|
log.critical("No token specified in config.toml. Exiting. (hint: set jimmy.token in config.toml)")
|
|
|
|
sys.exit(1)
|
2024-01-10 09:39:46 +00:00
|
|
|
|
2024-05-01 01:18:06 +01:00
|
|
|
__disabled = CONFIG["jimmy"].get("disabled_commands", [])
|
|
|
|
__disabled_mode = CONFIG["jimmy"].get("disabled_mode", "disabled").lower()
|
|
|
|
if __disabled:
|
|
|
|
if __disabled_mode == "delete":
|
|
|
|
for __command__name in __disabled:
|
|
|
|
_cmd = bot.get_application_command(__command__name)
|
|
|
|
if not _cmd:
|
|
|
|
log.warning("Unknown command %r - cannot remove.", __command__name)
|
|
|
|
else:
|
|
|
|
bot.remove_application_command(_cmd)
|
2024-05-01 01:29:42 +01:00
|
|
|
log.info("Removed command %s", __command__name)
|
|
|
|
else:
|
|
|
|
log.info("Disabled commands are specified, however they're soft-blocked.")
|
|
|
|
else:
|
|
|
|
log.info("No commands to disable. Full steam ahead!")
|
2024-05-01 01:18:06 +01:00
|
|
|
|
2024-01-02 22:29:25 +00:00
|
|
|
bot.run(CONFIG["jimmy"]["token"])
|