diff --git a/cogs/assignments.py b/cogs/.assignments.py.old similarity index 100% rename from cogs/assignments.py rename to cogs/.assignments.py.old diff --git a/cogs/events.py b/cogs/events.py index a767b30..3771854 100644 --- a/cogs/events.py +++ b/cogs/events.py @@ -20,9 +20,9 @@ import discord import httpx import pydantic from bs4 import BeautifulSoup -from config import guilds from discord.ext import commands, pages, tasks +from config import guilds from utils import Student, console, get_or_none try: @@ -90,25 +90,11 @@ class Events(commands.Cog): self.bot.bridge_queue = asyncio.Queue() self.fetch_discord_atom_feed.start() self.bridge_health = False + self.log = logging.getLogger("jimmy.cogs.events") def cog_unload(self): self.fetch_discord_atom_feed.cancel() - # noinspection DuplicatedCode - async def analyse_text(self, text: str) -> Optional[Tuple[float, float, float, float]]: - """Analyse text for positivity, negativity and neutrality.""" - - def inner(): - try: - from utils.sentiment_analysis import intensity_analyser - except ImportError: - return None - scores = intensity_analyser.polarity_scores(text) - return scores["pos"], scores["neu"], scores["neg"], scores["compound"] - - async with self.bot.training_lock: - return await self.bot.loop.run_in_executor(None, inner) - @commands.Cog.listener("on_raw_reaction_add") async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): channel: Optional[discord.TextChannel] = self.bot.get_channel(payload.channel_id) @@ -126,16 +112,17 @@ class Events(commands.Cog): if member.guild is None or member.guild.id not in guilds: return - student: Optional[Student] = await get_or_none(Student, user_id=member.id) - if student and student.id: - role = discord.utils.find(lambda r: r.name.lower() == "verified", member.guild.roles) - if role and role < member.guild.me.top_role: - await member.add_roles(role, reason="Verified") + # student: Optional[Student] = await get_or_none(Student, user_id=member.id) + # if student and student.id: + # role = discord.utils.find(lambda r: r.name.lower() == "verified", member.guild.roles) + # if role and role < member.guild.me.top_role: + # await member.add_roles(role, reason="Verified") channel: discord.TextChannel = discord.utils.get(member.guild.text_channels, name="general") if channel and channel.can_send(): await channel.send( - f"{LTR} {member.mention} (`{member}`, {f'{student.id}' if student else 'pending verification'})" + # f"{LTR} {member.mention} (`{member}`, {f'{student.id}' if student else 'pending verification'})" + f"{LTR} {member.mention}" ) @commands.Cog.listener() @@ -143,11 +130,12 @@ class Events(commands.Cog): if member.guild is None or member.guild.id not in guilds: return - student: Optional[Student] = await get_or_none(Student, user_id=member.id) + # student: Optional[Student] = await get_or_none(Student, user_id=member.id) channel: discord.TextChannel = discord.utils.get(member.guild.text_channels, name="general") if channel and channel.can_send(): await channel.send( - f"{RTL} {member.mention} (`{member}`, {f'{student.id}' if student else 'pending verification'})" + # f"{RTL} {member.mention} (`{member}`, {f'{student.id}' if student else 'pending verification'})" + f"{RTL} {member.mention}" ) async def process_message_for_github_links(self, message: discord.Message): @@ -248,7 +236,7 @@ class Events(commands.Cog): ) region = message.author.voice.channel.rtc_region # noinspection PyUnresolvedReferences - console.log( + self.log.warning( "Timed out connecting to voice channel: {0.name} in {0.guild.name} " "(region {1})".format( message.author.voice.channel, region.name if region else "auto (unknown)" @@ -269,7 +257,7 @@ class Events(commands.Cog): _dc(voice), ) if err is not None: - console.log(f"Error playing audio: {err}") + self.log.error(f"Error playing audio: {err}", exc_info=err) self.bot.loop.create_task(message.add_reaction("\N{speaker with cancellation stroke}")) else: self.bot.loop.create_task( @@ -331,15 +319,16 @@ class Events(commands.Cog): return if message.channel.name == "femboy-hole": + def generate_payload(_message: discord.Message) -> MessagePayload: _payload = MessagePayload( message_id=_message.id, author=_message.author.name, is_automated=_message.author.bot or _message.author.system, avatar=_message.author.display_avatar.with_static_format("webp").with_size(512).url, - content=_message.content or '', - clean_content=str(_message.clean_content or ''), - at=_message.created_at.timestamp() + content=_message.content or "", + clean_content=str(_message.clean_content or ""), + at=_message.created_at.timestamp(), ) for attachment in _message.attachments: _payload.attachments.append( @@ -350,7 +339,7 @@ class Events(commands.Cog): size=attachment.size, width=attachment.width, height=attachment.height, - content_type=attachment.content_type + content_type=attachment.content_type, ) ) if _message.reference is not None and _message.reference.cached_message: @@ -358,11 +347,7 @@ class Events(commands.Cog): _payload.reply_to = generate_payload(_message.reference.cached_message) except RecursionError: _payload.reply_to = None - logging.warning( - "Failed to generate reply payload for message %s", - _message.id, - exc_info=True - ) + logging.warning("Failed to generate reply payload for message %s", _message.id, exc_info=True) return _payload payload = generate_payload(message) @@ -448,7 +433,7 @@ class Events(commands.Cog): r"china": {"file": discord.File(assets / "china.m4a")}, r"drones": {"file": discord.File(assets / "drones.m4a")}, r"pork($|\W+)|markets": {"file": discord.File(assets / "pork.m4a")}, - r"common\ssense\W*$|(wo)?man\W*$|(trans(\s)?)?gender\W*$": { + r"(wo)?man\sis\sa\s(wo)?man|(trans(\s)?)?gender\W*$": { "file": discord.File(assets / "common-sense.m4a") }, r"scrapped(\sit)?|((7\s|seven\s)?different\s)?bins|(meat\s|flying\s)?tax": { @@ -458,10 +443,12 @@ class Events(commands.Cog): r"brush|hair": {"file": discord.File(assets / "hair.m4a")}, r"((cup\s)?of\s)?tea\W*$": {"file": discord.File(assets / "tea.m4a")}, r"wheat|fields": {"file": discord.File(assets / "wheat.m4a")}, - r"bus((s)?es)?\W*$": {"file": discord.File(assets / "bus.m4a")}, + r"(\W|^)bus((s)?es)?\W*$": {"file": discord.File(assets / "bus.m4a")}, r"^DoH$": {"content": "DoH: Domain Name Service over Hyper Text Transfer Protocol Secure"}, r"^DoT$": {"content": "DoT: Domain Name Service over Transport Layer Security"}, - r"^DoQ$": {"content": "DoQ: Domain Name Service over Quick User Datagram Protocol Internet Connections"}, + r"^DoQ$": { + "content": "DoQ: Domain Name Service over Quick User Datagram Protocol Internet Connections" + }, r"^(Do)?DTLS$": {"content": "DoDTLS: Domain Name Service over Datagram Transport Layer Security"}, } # Stop responding to any bots @@ -590,14 +577,14 @@ class Events(commands.Cog): try: response = await self.http.get("https://discordstatus.com/history.atom", headers=headers) except httpx.HTTPError as e: - console.log("Failed to fetch discord atom feed:", e) + self.log.error("Failed to fetch discord atom feed: %r", e, exc_info=e) return if response.status_code == 304: return if response.status_code != 200: - console.log("Failed to fetch discord atom feed:", response.status_code) + self.log.error("Failed to fetch discord atom feed: HTTP/%s", response.status_code) return with file.open("wb") as f: diff --git a/cogs/extremism.py b/cogs/extremism.py index ba08d2b..30723f9 100644 --- a/cogs/extremism.py +++ b/cogs/extremism.py @@ -138,11 +138,11 @@ class Extremism(commands.Cog): @commands.slash_command(name="decorate") async def decorate( - self, - ctx: discord.ApplicationContext, - decoration_url: str, - user: discord.User = None, - # animated: bool = True + self, + ctx: discord.ApplicationContext, + decoration_url: str, + user: discord.User = None, + # animated: bool = True ): """Decorates an avatar with a decoration.""" if user is None: @@ -162,9 +162,7 @@ class Extremism(commands.Cog): size = 640 size = min(640, max(160, size)) - decoration_url = urlparse(decoration_url)._replace( - query="?size={!s}&passthrough=true".format(size) - ).geturl() + decoration_url = urlparse(decoration_url)._replace(query="?size={!s}&passthrough=true".format(size)).geturl() # Download the decoration try: @@ -199,9 +197,7 @@ class Extremism(commands.Cog): # discord.File(decoration_bio, "decoration.png"), discord.File(img_bytes, filename="decorated." + ext) ] - await ctx.respond( - files=files - ) + await ctx.respond(files=files) def setup(bot): diff --git a/cogs/info.py b/cogs/info.py index 7c20d2d..5f2eed0 100644 --- a/cogs/info.py +++ b/cogs/info.py @@ -1,3 +1,6 @@ +import logging +import typing + import discord import httpx from discord.ext import commands @@ -124,9 +127,27 @@ class InfoCog(commands.Cog): await ctx.respond(embed=embed) + @commands.command(name="set-log-level") + @commands.is_owner() + async def set_log_level(self, ctx: commands.Context, logger_name: str, logger_level: typing.Union[str, int]): + """Sets a module's log level.""" + logger_level = logger_level.upper() + if getattr(logging, logger_level, None) is None or logging.getLevelNamesMapping().get(logger_level) is None: + return await ctx.reply(":x: Invalid log level.") + logger = logging.getLogger(logger_name) + old_level = logger.getEffectiveLevel() + + def name(level): + return logging.getLevelName(level) + + logger.setLevel(logger_level) + return await ctx.reply( + f"\N{white heavy check mark} {logger_name}: {name(old_level)} -> {name(logger.getEffectiveLevel())}" + ) + def setup(bot): if OAUTH_REDIRECT_URI and OAUTH_ID: bot.add_cog(InfoCog(bot)) else: - print("OAUTH_REDIRECT_URI not set, not loading info cog") + logging.getLogger("jimmy.cogs.info").warning("OAUTH_REDIRECT_URI not set, not loading info cog") diff --git a/cogs/mcdonalds.py b/cogs/mcdonalds.py new file mode 100644 index 0000000..40caeff --- /dev/null +++ b/cogs/mcdonalds.py @@ -0,0 +1,207 @@ +import asyncio +import logging +import pathlib +import re +import typing + +import aiosqlite +import discord +from discord.ext import commands + + +class McDataBase: + def __init__(self): + self.db = pathlib.Path.home() / ".cache" / "lcc-bot" / "McDataBase.db" + self._conn: typing.Optional[aiosqlite.Connection] = None + + async def init_db(self): + if self._conn: + conn = self._conn + now = round(discord.utils.utcnow().timestamp(), 2) + await conn.execute( + f""" + CREATE TABLE IF NOT EXISTS breaks ( + user_id INTEGER PRIMARY KEY, + since FLOAT NOT NULL, + started FLOAT NOT NULL DEFAULT {now} + ); + """ + ) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS cooldowns ( + user_id INTEGER PRIMARY KEY, + expires FLOAT NOT NULL + ); + """ + ) + + async def get_break(self, user_id: int) -> typing.Optional[tuple[float, float]]: + async with self._conn.execute( + """ + SELECT since, started FROM breaks WHERE user_id = ?; + """, + (user_id,), + ) as cursor: + return await cursor.fetchone() + + async def set_break(self, user_id: int, since: float, started: float = None) -> None: + if not started: + started = discord.Object(discord.utils.generate_snowflake()).created_at.timestamp() + await self._conn.execute( + """ + INSERT INTO breaks (user_id, since, started) VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET since = excluded.since, started = excluded.started + """, + (user_id, since, started), + ) + + async def remove_break(self, user_id: int) -> None: + now = discord.utils.utcnow().timestamp() + await self._conn.execute( + """ + DELETE FROM breaks WHERE user_id = ?; + """, + (user_id,), + ) + await self.set_cooldown(user_id, now) + + async def get_cooldown(self, user_id: int) -> typing.Optional[tuple[float]]: + async with self._conn.execute( + """ + SELECT expires FROM cooldowns WHERE user_id = ?; + """, + (user_id,), + ) as cursor: + return await cursor.fetchone() + + async def set_cooldown(self, user_id: int, expires: float) -> None: + await self._conn.execute( + """ + INSERT INTO cooldowns (user_id, expires) VALUES (?, ?) + ON CONFLICT(user_id) DO UPDATE SET expires = excluded.expires; + """, + (user_id, expires), + ) + + async def remove_cooldown(self, user_id: int) -> None: + await self._conn.execute( + """ + DELETE FROM cooldowns WHERE user_id = ?; + """, + (user_id,), + ) + + async def __aenter__(self) -> "McDataBase": + self._conn = await aiosqlite.connect(self.db) + await self.init_db() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self._conn.commit() + await self._conn.close() + self._conn = None + + +class McDonaldsCog(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.lock = asyncio.Lock() + self.log = logging.getLogger("jimmy.cogs.mcdonalds") + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + author = message.author + me = message.guild.me if message.guild else self.bot.user + if not message.channel.permissions_for(me).manage_messages: + self.log.debug("McDonalds disabled in %s, no manage messages permissions.", message.channel) + return + + async with self.lock: + async with McDataBase() as db: + if (last_info := await db.get_break(author.id)) is not None: + if message.content.upper() != "MCDONALDS!": + if (message.created_at.timestamp() - last_info[1]) > 300: + self.log.debug("Ad break expired for %s (%s).", author.name, author.id) + await db.remove_break(author.id) + await message.reply( + f"Thank you for your patience during this commercial break. You may now resume your" + f" activity.", + delete_after=120, + ) + + elif (message.created_at.timestamp() - last_info[0]) > 10: + self.log.info( + "Deleting message %r by %r as they need to skip the ad first.", message, author + ) + await message.delete(delay=0) + await message.channel.send( + f"{message.author.mention} Please say `MCDONALDS!` to end commercial.", delete_after=30 + ) + await db.set_break(author.id, message.created_at.timestamp()) + elif message.author.bot is False: + self.log.info("%r skipped the ad break.", author) + await db.remove_break(author.id) + await message.reply("Thank you. You may now resume your activity.", delete_after=120) + + @commands.user_command(name="Commercial Break") + @commands.cooldown(2, 60, commands.BucketType.member) + async def commercial_break(self, ctx: discord.ApplicationContext, member: discord.Member): + await ctx.defer(ephemeral=True) + + if not ctx.channel.permissions_for(ctx.me).manage_messages: + return await ctx.respond("I don't have permission to manage messages in this channel.", ephemeral=True) + + if member.bot or member == ctx.user: + return await ctx.respond("No.", ephemeral=True) + + async with McDataBase() as db: + if await db.get_break(member.id) is not None: + await ctx.respond(f"{member.mention} is already in a commercial break.") + return + elif (cooldown := await db.get_cooldown(member.id)) is not None: + expires = cooldown[0] + 300 + if expires > discord.utils.utcnow().timestamp(): + await ctx.respond( + f"{member.mention} is not due another ad break yet. Their next commercial break will start " + f" at the earliest." + ) + return + else: + await db.remove_cooldown(member.id) + + await db.set_break(member.id, discord.utils.utcnow().timestamp()) + await ctx.send( + f"{member.mention} Commercial break! Please say `MCDONALDS!` to end commercial.\n" + f"*This commercial break is sponsored by {ctx.user.mention}.*", + delete_after=300, + allowed_mentions=discord.AllowedMentions(users=True, roles=False, everyone=False), + ) + await ctx.respond("Commercial break started.", ephemeral=True) + await ctx.delete(delay=120) + + @commands.command(name="commercial-break") + @commands.is_owner() + async def _force_com_break(self, ctx: commands.Context, *, member: discord.Member): + """Forces a member to go on commercial break.""" + async with McDataBase() as db: + await db.set_break(member.id, discord.utils.utcnow().timestamp()) + await ctx.reply( + f"{member.mention} Commercial break! Please say `MCDONALDS!` to end commercial.\n" + f"*This commercial break is sponsored by {ctx.author.mention}.*", + delete_after=300, + allowed_mentions=discord.AllowedMentions(users=True, roles=False, everyone=False), + ) + await ctx.message.delete(delay=120) + + @commands.command(name="end-break") + @commands.is_owner() + async def _unforce_com_break(self, ctx: commands.Context, *, member: discord.Member): + """Forces a member to finish their commercial break.""" + async with McDataBase() as db: + await db.remove_break(member.id) + await ctx.reply(f"{member.mention} Commercial break ended.", delete_after=10) + + +def setup(bot): + bot.add_cog(McDonaldsCog(bot)) diff --git a/cogs/other.py b/cogs/other.py index c0a9532..df5e891 100644 --- a/cogs/other.py +++ b/cogs/other.py @@ -2,18 +2,16 @@ import asyncio import fnmatch import functools import glob +import hashlib import io -import pathlib - -import openai -import pydub import json +import logging import math import os +import pathlib import random import re import shutil -import hashlib import subprocess import sys import tempfile @@ -32,7 +30,9 @@ import aiohttp import discord import dns.resolver import httpx +import openai import psutil +import pydub import pytesseract import pyttsx3 from discord import Interaction @@ -69,41 +69,19 @@ try: VOICES = [x.id for x in _engine.getProperty("voices")] del _engine except Exception as _pyttsx3_err: - print("Failed to load pyttsx3: %s" % _pyttsx3_err, file=sys.stderr) + logging.error("Failed to load pyttsx3: %r", _pyttsx3_err, exc_info=True) pyttsx3 = None VOICES = [] -# class OllamaStreamReader: -# def __init__(self, response: httpx.Response): -# self.response = response -# self.stream = response.aiter_bytes(1) -# self._buffer = b"" -# -# async def __aiter__(self): -# return self -# -# async def __anext__(self) -> dict[str, str | int | bool]: -# if self.response.is_stream_consumed: -# raise StopAsyncIteration -# self._buffer = b"" -# while not self._buffer.endswith(b"}\n"): -# async for char in self.stream: -# self._buffer += char -# -# return json.loads(self._buffer.decode("utf-8", "replace")) - - -async def ollama_stream_reader(response: httpx.Response) -> typing.AsyncGenerator[ - dict[str, str | int | bool], None -]: +async def ollama_stream_reader(response: httpx.Response) -> typing.AsyncGenerator[dict[str, str | int | bool], None]: async for chunk in response.aiter_lines(): # Each line is a JSON string try: loaded = json.loads(chunk) yield loaded except json.JSONDecodeError as e: - print("Failed to decode chunk %r: %r" % (chunk, e), file=sys.stderr) + logging.warning("Failed to decode chunk %r: %r", chunk, e) pass @@ -134,6 +112,7 @@ class OtherCog(commands.Cog): def __init__(self, bot): self.bot = bot self.lock = asyncio.Lock() + self.transcribe_lock = asyncio.Lock() self.http = httpx.AsyncClient() self._fmt_cache = {} self._fmt_queue = asyncio.Queue() @@ -141,6 +120,7 @@ class OtherCog(commands.Cog): self.ollama_locks: dict[discord.Message, asyncio.Event] = {} self.context_cache: dict[str, list[int]] = {} + self.log = logging.getLogger("jimmy.cogs.other") def cog_unload(self): self._worker_task.cancel() @@ -257,7 +237,7 @@ class OtherCog(commands.Cog): return driver, driver_path driver, driver_path = find_driver() - console.log( + self.log.info( "Using driver '{}' with binary '{}' to screenshot '{}', as requested by {}.".format( driver, driver_path, website, ctx.user ) @@ -295,7 +275,7 @@ class OtherCog(commands.Cog): start_init = time() driver, friendly_url = await asyncio.to_thread(_setup) end_init = time() - console.log("Driver '{}' initialised in {} seconds.".format(driver_name, round(end_init - start_init, 2))) + self.log.info("Driver '{}' initialised in {} seconds.".format(driver_name, round(end_init - start_init, 2))) def _edit(content: str): self.bot.loop.create_task(ctx.interaction.edit_original_response(content=content)) @@ -349,20 +329,6 @@ class OtherCog(commands.Cog): ) return result - async def analyse_text(self, text: str) -> Optional[Tuple[float, float, float, float]]: - """Analyse text for positivity, negativity and neutrality.""" - - def inner(): - try: - from utils.sentiment_analysis import intensity_analyser - except ImportError: - return None - scores = intensity_analyser.polarity_scores(text) - return scores["pos"], scores["neu"], scores["neg"], scores["compound"] - - async with self.bot.training_lock: - return await self.bot.loop.run_in_executor(None, inner) - @staticmethod async def get_xkcd(session: aiohttp.ClientSession, n: int) -> dict | None: async with session.get("https://xkcd.com/{!s}/info.0.json".format(n)) as response: @@ -445,40 +411,6 @@ class OtherCog(commands.Cog): view = self.XKCDGalleryView(number) return await ctx.respond(embed=embed, view=view) - @commands.slash_command() - async def sentiment(self, ctx: discord.ApplicationContext, *, text: str): - """Attempts to detect a text's tone""" - await ctx.defer() - if not text: - return await ctx.respond("You need to provide some text to analyse.") - result = await self.analyse_text(text) - if result is None: - return await ctx.edit(content="Failed to load sentiment analysis module.") - embed = discord.Embed(title="Sentiment Analysis", color=discord.Colour.embed_background()) - embed.add_field(name="Positive", value="{:.2%}".format(result[0])) - embed.add_field(name="Neutral", value="{:.2%}".format(result[2])) - embed.add_field(name="Negative", value="{:.2%}".format(result[1])) - embed.add_field(name="Compound", value="{:.2%}".format(result[3])) - return await ctx.edit(content=None, embed=embed) - - @commands.message_command(name="Detect Sentiment") - async def message_sentiment(self, ctx: discord.ApplicationContext, message: discord.Message): - await ctx.defer() - text = str(message.clean_content) - if not text: - return await ctx.respond("You need to provide some text to analyse.") - await ctx.respond("Analyzing (this may take some time)...") - result = await self.analyse_text(text) - if result is None: - return await ctx.edit(content="Failed to load sentiment analysis module.") - embed = discord.Embed(title="Sentiment Analysis", color=discord.Colour.embed_background()) - embed.add_field(name="Positive", value="{:.2%}".format(result[0])) - embed.add_field(name="Neutral", value="{:.2%}".format(result[2])) - embed.add_field(name="Negative", value="{:.2%}".format(result[1])) - embed.add_field(name="Compound", value="{:.2%}".format(result[3])) - embed.url = message.jump_url - return await ctx.edit(content=None, embed=embed) - corrupt_file = discord.SlashCommandGroup( name="corrupt-file", description="Corrupts files.", @@ -997,15 +929,12 @@ class OtherCog(commands.Cog): "merge_output_format": "webm/mp4/mov/flv/avi/ogg/m4a/wav/mp3/opus/mka/mkv", "source_address": "0.0.0.0", "cookiefile": str(real_cookies_txt.resolve().absolute()), - "concurrent_fragment_downloads": 4 + "concurrent_fragment_downloads": 4, } description = "" proxy_url = "socks5://localhost:1090" try: - proxy_down = await asyncio.wait_for( - self.check_proxy("socks5://localhost:1090"), - timeout=10 - ) + proxy_down = await asyncio.wait_for(self.check_proxy("socks5://localhost:1090"), timeout=10) if proxy_down > 0: if proxy_down == 1: description += ":warning: (SHRoNK) Proxy check leaked IP - trying backup proxy.\n" @@ -1014,10 +943,7 @@ class OtherCog(commands.Cog): else: description += ":warning: (SHRoNK) Unknown proxy error - trying backup proxy.\n" - proxy_down = await asyncio.wait_for( - self.check_proxy("socks5://localhost:1080"), - timeout=10 - ) + proxy_down = await asyncio.wait_for(self.check_proxy("socks5://localhost:1080"), timeout=10) if proxy_down > 0: if proxy_down == 1: description += ":warning: (NexBox) Proxy check leaked IP..\n" @@ -1072,20 +998,14 @@ class OtherCog(commands.Cog): "* Chosen format: `%s` (`%s`)" % (chosen_format, chosen_format_id), ) if format_note: - lines.append( - "* Format note: %r" % format_note - ) + lines.append("* Format note: %r" % format_note) if final_extension: - lines.append( - "* File extension: " + final_extension - ) + lines.append("* File extension: " + final_extension) if resolution: _s = resolution if fps: _s += " @ %s FPS" % fps - lines.append( - "* Resolution: " + _s - ) + lines.append("* Resolution: " + _s) if vcodec or acodec: lines.append("%s+%s" % (vcodec or "N/A", acodec or "N/A")) @@ -1822,17 +1742,18 @@ class OtherCog(commands.Cog): @commands.slash_command() @commands.max_concurrency(3, commands.BucketType.user, wait=False) async def ollama( - self, - ctx: discord.ApplicationContext, - model: str = "orca-mini", - query: str = None, - context: str = None, - server: str = "auto" + self, + ctx: discord.ApplicationContext, + model: str = "orca-mini", + query: str = None, + context: str = None, + server: str = "auto", ): """:3""" with open("./assets/ollama-prompt.txt") as file: system_prompt = file.read().replace("\n", " ").strip() if query is None: + class InputPrompt(discord.ui.Modal): def __init__(self, is_owner: bool): super().__init__( @@ -1844,7 +1765,7 @@ class OtherCog(commands.Cog): style=discord.InputTextStyle.long, ), title="Enter prompt", - timeout=120 + timeout=120, ) if is_owner: self.add_item( @@ -1887,10 +1808,10 @@ class OtherCog(commands.Cog): try: model, tag = model.split(":", 1) model = model + ":" + tag - print("Model %r already has a tag") + self.log.debug("Model %r already has a tag") except ValueError: model = model + ":latest" - print("Resolved model to %r" % model) + self.log.debug("Resolved model to %r" % model) servers: dict[str, dict[str, str, list[str] | int]] = { "100.106.34.86:11434": { @@ -1907,7 +1828,7 @@ class OtherCog(commands.Cog): "codellama:python", "codellama:instruct", ], - "owner": 421698654189912064 + "owner": 421698654189912064, }, "ollama.shronk.net:11434": { "name": "Alibaba Cloud", @@ -1922,26 +1843,19 @@ class OtherCog(commands.Cog): "orca-mini:3b", "orca-mini:7b", ], - "owner": 421698654189912064 + "owner": 421698654189912064, }, } - H_DEFAULT = { - "name": "Other", - "allow": ["*"], - "owner": 1019217990111199243 - } + H_DEFAULT = {"name": "Other", "allow": ["*"], "owner": 1019217990111199243} def model_is_allowed(model_name: str, _srv: dict[str, str | list[str] | int]) -> bool: if _srv["owner"] == ctx.user.id: return True - for pat in _srv.get("allow", ['*']): + for pat in _srv.get("allow", ["*"]): if not fnmatch.fnmatch(model_name.lower(), pat.lower()): - print( - "Server %r does not support %r (only %r.)" % ( - _srv['name'], - model_name, - ', '.join(_srv['allow']) - ) + self.log.debug( + "Server %r does not support %r (only %r.)" + % (_srv["name"], model_name, ", ".join(_srv["allow"])) ) else: break @@ -1951,9 +1865,7 @@ class OtherCog(commands.Cog): class ServerSelector(discord.ui.View): def __init__(self): - super().__init__( - disable_on_timeout=True - ) + super().__init__(disable_on_timeout=True) self.chosen_server = None async def interaction_check(self, interaction: Interaction) -> bool: @@ -1963,21 +1875,15 @@ class OtherCog(commands.Cog): placeholder="Choose a server.", custom_id="select", options=[ - discord.SelectOption( - label="%s (%s)" % (y['name'], x), - value=x - ) + discord.SelectOption(label="%s (%s)" % (y["name"], x), value=x) for x, y in servers.items() if model_is_allowed(model, y) - ] + [ - discord.SelectOption( - label="Custom", - value="custom" - ) ] + + [discord.SelectOption(label="Custom", value="custom")], ) async def select_callback(self, item: discord.ui.Select, interaction: discord.Interaction): if item.values[0] == "custom": + class ServerSelectionModal(discord.ui.Modal): def __init__(self): super().__init__( @@ -1994,10 +1900,10 @@ class OtherCog(commands.Cog): min_length=2, max_length=5, style=discord.InputTextStyle.short, - value="11434" + value="11434", ), title="Enter server details", - timeout=120 + timeout=120, ) self.hostname = None self.port = None @@ -2016,8 +1922,7 @@ class OtherCog(commands.Cog): self.chosen_server = item.values[0] await interaction.response.defer(ephemeral=True) await interaction.followup.send( - f"\N{white heavy check mark} Selected server {self.chosen_server}/", - ephemeral=True + f"\N{white heavy check mark} Selected server {self.chosen_server}/", ephemeral=True ) self.stop() @@ -2035,29 +1940,18 @@ class OtherCog(commands.Cog): if not model_is_allowed(model, srv): return await ctx.respond( ":x: <@{!s}> does not allow you to run that model on the server {!r}. You can, however, use" - " any of the following: {}".format( - srv["owner"], - srv["name"], - ", ".join(srv.get("allow", ["*"])) - ) + " any of the following: {}".format(srv["owner"], srv["name"], ", ".join(srv.get("allow", ["*"]))) ) content = None - embed = discord.Embed( - colour=discord.Colour.greyple() - ) + embed = discord.Embed(colour=discord.Colour.greyple()) embed.set_author( name=f"Loading {model}", url=f"http://{host}", - icon_url="https://cdn.discordapp.com/emojis/1101463077586735174.gif" - ) - FOOTER_TEXT = "Powered by Ollama • Using server {} ({})".format( - host, - servers.get(host, H_DEFAULT)['name'] - ) - embed.set_footer( - text=FOOTER_TEXT + icon_url="https://cdn.discordapp.com/emojis/1101463077586735174.gif", ) + FOOTER_TEXT = "Powered by Ollama • Using server {} ({})".format(host, servers.get(host, H_DEFAULT)["name"]) + embed.set_footer(text=FOOTER_TEXT) msg = await ctx.respond(embed=embed, ephemeral=False) async with httpx.AsyncClient(follow_redirects=True) as client: @@ -2074,19 +1968,15 @@ class OtherCog(commands.Cog): error = "GET {0.url} HTTP {0.status_code}: {0.text}".format(e.response) return await msg.edit( embed=discord.Embed( - title="Failed to GET /tags. Offline?", - description=error, - colour=discord.Colour.red() + title="Failed to GET /tags. Offline?", description=error, colour=discord.Colour.red() ).set_footer(text=FOOTER_TEXT) ) except httpx.TransportError as e: return await msg.edit( embed=discord.Embed( title=f"Failed to connect to {host!r}", - description="Transport error sending request to {}: {}".format( - host, str(e) - ), - colour=discord.Colour.red() + description="Transport error sending request to {}: {}".format(host, str(e)), + colour=discord.Colour.red(), ).set_footer(text=FOOTER_TEXT) ) # get models @@ -2094,9 +1984,7 @@ class OtherCog(commands.Cog): response = await client.post("/show", json={"name": model}) except httpx.TransportError as e: embed = discord.Embed( - title="Failed to connect to Ollama.", - description=str(e), - colour=discord.Colour.red() + title="Failed to connect to Ollama.", description=str(e), colour=discord.Colour.red() ) embed.set_footer(text=FOOTER_TEXT) return await msg.edit(embed=embed) @@ -2105,10 +1993,7 @@ class OtherCog(commands.Cog): await msg.edit(embed=embed) async with ctx.channel.typing(): async with client.stream( - "POST", - "/pull", - json={"name": model, "stream": True}, - timeout=None + "POST", "/pull", json={"name": model, "stream": True}, timeout=None ) as response: if response.status_code != 200: error = await response.aread() @@ -2116,7 +2001,7 @@ class OtherCog(commands.Cog): title=f"Failed to download model {model}:", description=f"HTTP {response.status_code}:\n```{error or ''}\n```", colour=discord.Colour.red(), - url=str(response.url) + url=str(response.url), ) embed.set_footer(text=FOOTER_TEXT) return await msg.edit(embed=embed) @@ -2131,12 +2016,13 @@ class OtherCog(commands.Cog): percent = round(completed / total * 100, 2) total_gigabytes = total / 1024 / 1024 / 1024 completed_gigabytes = completed / 1024 / 1024 / 1024 - lines[chunk["status"]] = (f"{percent}% " - f"({completed_gigabytes:.2f}GB/{total_gigabytes:.2f}GB)") + lines[chunk["status"]] = ( + f"{percent}% " f"({completed_gigabytes:.2f}GB/{total_gigabytes:.2f}GB)" + ) else: status = chunk.get("status", chunk.get("error", os.urandom(3).hex())) lines[status] = status - + embed.description = "\n".join(f"`{k}`: {v}" for k, v in lines.items()) if (time() - last_edit) >= 5: await msg.edit(embed=embed) @@ -2151,7 +2037,7 @@ class OtherCog(commands.Cog): title=f"Failed to download model {model}:", description=f"HTTP {response.status_code}:\n```{error or ''}\n```", colour=discord.Colour.red(), - url=str(response.url) + url=str(response.url), ) embed.set_footer(text=FOOTER_TEXT) return await msg.edit(embed=embed) @@ -2160,32 +2046,21 @@ class OtherCog(commands.Cog): title=f"{model} says:", description="", colour=discord.Colour.blurple(), - timestamp=discord.utils.utcnow() + timestamp=discord.utils.utcnow(), ) embed.set_footer(text=FOOTER_TEXT) await msg.edit(embed=embed) async with ctx.channel.typing(): - payload = { - "model": model, - "prompt": query, - "format": "json", - "system": system_prompt, - "stream": True - } + payload = {"model": model, "prompt": query, "format": "json", "system": system_prompt, "stream": True} if context: payload["context"] = context - async with client.stream( - "POST", - "/generate", - json=payload, - timeout=None - ) as response: + async with client.stream("POST", "/generate", json=payload, timeout=None) as response: if response.status_code != 200: error = await response.aread() embed = discord.Embed( title=f"Failed to generate response from {model}:", description=f"HTTP {response.status_code}:\n```{error or ''}\n```", - colour=discord.Colour.red() + colour=discord.Colour.red(), ) embed.set_footer(text=FOOTER_TEXT) return await msg.edit(embed=embed) @@ -2204,7 +2079,7 @@ class OtherCog(commands.Cog): embed.set_author( name=f"Generating response with {model}", url=f"http://{host}", - icon_url="https://cdn.discordapp.com/emojis/1101463077586735174.gif" + icon_url="https://cdn.discordapp.com/emojis/1101463077586735174.gif", ) embed.description += chunk["response"] if (time() - last_edit) >= 5 or chunk["done"] is True: @@ -2215,10 +2090,7 @@ class OtherCog(commands.Cog): embed.colour = discord.Colour.red() return await msg.edit(embed=embed, view=None) if len(embed.description) >= 4000: - embed.add_field( - name="Aborting early", - value="Output exceeded 4000 characters." - ) + embed.add_field(name="Aborting early", value="Output exceeded 4000 characters.") embed.title = embed.title[:-1] + " (Aborted)" embed.colour = discord.Colour.red() embed.description = embed.description[:4096] @@ -2268,28 +2140,22 @@ class OtherCog(commands.Cog): self.context_cache[key] = context else: context = key = None - value = ("* Total: {}\n" - "* Model load: {}\n" - "* Sample generation: {}\n" - "* Prompt eval: {}\n" - "* Response generation: {}\n").format( + value = ( + "* Total: {}\n" + "* Model load: {}\n" + "* Sample generation: {}\n" + "* Prompt eval: {}\n" + "* Response generation: {}\n" + ).format( total_time_spent, load_time_spent, sample_time_sent, prompt_eval_time_spent, eval_time_spent, ) - embed.add_field( - name="Timings", - value=value, - inline=False - ) + embed.add_field(name="Timings", value=value, inline=False) if context: - embed.add_field( - name="Context Key", - value=key, - inline=True - ) + embed.add_field(name="Context Key", value=key, inline=True) embed.set_footer(text=FOOTER_TEXT) await msg.edit(content=None, embed=embed, view=None) self.ollama_locks.pop(msg, None) @@ -2297,44 +2163,31 @@ class OtherCog(commands.Cog): @commands.slash_command(name="test-proxies") @commands.max_concurrency(1, commands.BucketType.user, wait=False) async def test_proxy( - self, - ctx: discord.ApplicationContext, - run_speed_test: bool = False, - proxy_name: discord.Option( - str, - choices=[ - "SHRoNK", - "NexBox", - "first-working" - ] - ) = "first-working", - test_time: float = 30 + self, + ctx: discord.ApplicationContext, + run_speed_test: bool = False, + proxy_name: discord.Option(str, choices=["SHRoNK", "NexBox", "first-working"]) = "first-working", + test_time: float = 30, ): """Tests proxies.""" test_time = max(5.0, test_time) await ctx.defer() - SPEED_REGIONS = [ - "fsn1", - "nbg1", - "hel1", - "ash", - "hil" - ] + SPEED_REGIONS = ["fsn1", "nbg1", "hel1", "ash", "hil"] results = { "localhost:1090": { "name": "SHRoNK", "failure": None, "download_speed": 0.0, "tested": False, - "speedtest": "https://{hetzner_region}-speed.hetzner.com/100M.bin" + "speedtest": "https://{hetzner_region}-speed.hetzner.com/100M.bin", }, "localhost:1080": { "name": "NexBox", "failure": None, "download_speed": 0.0, "tested": False, - "speedtest": "http://192.168.0.90:82/100M.bin" - } + "speedtest": "http://192.168.0.90:82/100M.bin", + }, } if proxy_name != "first-working": for key, value in results.copy().items(): @@ -2342,18 +2195,13 @@ class OtherCog(commands.Cog): continue else: results.pop(key) - embed = discord.Embed( - title="\N{white heavy check mark} Proxy available." - ) + embed = discord.Embed(title="\N{white heavy check mark} Proxy available.") FAILED = False proxy_uri = None for proxy_uri in results.keys(): name = results[proxy_uri]["name"] try: - proxy_down = await asyncio.wait_for( - self.check_proxy("socks5://" + proxy_uri), - timeout=10 - ) + proxy_down = await asyncio.wait_for(self.check_proxy("socks5://" + proxy_uri), timeout=10) results[proxy_uri]["tested"] = True if proxy_down > 0: embed.colour = discord.Colour.red() @@ -2375,19 +2223,12 @@ class OtherCog(commands.Cog): results[proxy_uri]["failure"] = f"Failed to check {name} proxy (`{e}`)." results[proxy_uri]["tested"] = True else: - embed = discord.Embed( - title="\N{cross mark} All proxies failed.", - colour=discord.Colour.red() - ) + embed = discord.Embed(title="\N{cross mark} All proxies failed.", colour=discord.Colour.red()) FAILED = True for uri, value in results.items(): if value["tested"]: - embed.add_field( - name=value["name"], - value=value["failure"] or "Proxy is working.", - inline=True - ) + embed.add_field(name=value["name"], value=value["failure"] or "Proxy is working.", inline=True) embed.set_footer(text="No speed test will be run.") await ctx.respond(embed=embed) if run_speed_test and FAILED is False: @@ -2396,11 +2237,9 @@ class OtherCog(commands.Cog): await ctx.edit(embed=embed) chosen_proxy = ("socks5://" + proxy_uri) if proxy_uri else None async with httpx.AsyncClient( - http2=True, - proxies=chosen_proxy, - headers={ - "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0" - } + http2=True, + proxies=chosen_proxy, + headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0"}, ) as client: bytes_received = 0 for region in SPEED_REGIONS: @@ -2418,9 +2257,7 @@ class OtherCog(commands.Cog): response.raise_for_status() end = time() now = discord.utils.utcnow() - embed.set_footer( - text=embed.footer.text + " | Finished at: " + now.strftime("%X") - ) + embed.set_footer(text=embed.footer.text + " | Finished at: " + now.strftime("%X")) break except Exception as e: results[proxy_uri]["failure"] = f"Failed to test {region} speed (`{e}`)." @@ -2433,8 +2270,8 @@ class OtherCog(commands.Cog): embed2 = discord.Embed( title=f"\U000023f2\U0000fe0f Speed test results (for {proxy_uri})", description=f"Downloaded {megabytes:,.1f}MB in {elapsed:,.0f} seconds " - f"({megabits_per_second:,.0f}Mbps).\n`{latency:,.0f}ms` latency.", - colour=discord.Colour.green() if megabits_per_second >= 50 else discord.Colour.red() + f"({megabits_per_second:,.0f}Mbps).\n`{latency:,.0f}ms` latency.", + colour=discord.Colour.green() if megabits_per_second >= 50 else discord.Colour.red(), ) embed2.add_field(name="Source", value=used) await ctx.edit(embeds=[embed, embed2]) @@ -2442,61 +2279,97 @@ class OtherCog(commands.Cog): @commands.message_command(name="Transcribe") async def transcribe_message(self, ctx: discord.ApplicationContext, message: discord.Message): await ctx.defer() - if not message.attachments: - return await ctx.respond("No attachments found.") + async with self.transcribe_lock: + if not message.attachments: + return await ctx.respond("No attachments found.") - _ft = "wav" - for attachment in message.attachments: - if attachment.content_type.startswith("audio/"): - _ft = attachment.filename.split(".")[-1] - break - else: - return await ctx.respond("No voice messages.") - if getattr(config, "OPENAI_KEY", None) is None: - return await ctx.respond("Service unavailable.") - file_hash = hashlib.sha1(usedforsecurity=False) - file_hash.update(await attachment.read()) - file_hash = file_hash.hexdigest() + _ft = "wav" + for attachment in message.attachments: + if attachment.content_type.startswith("audio/"): + _ft = attachment.filename.split(".")[-1] + break + else: + return await ctx.respond("No voice messages.") + if getattr(config, "OPENAI_KEY", None) is None: + return await ctx.respond("Service unavailable.") + file_hash = hashlib.sha1(usedforsecurity=False) + file_hash.update(await attachment.read()) + file_hash = file_hash.hexdigest() - cache = Path.home() / ".cache" / "lcc-bot" / ("%s-transcript.txt" % file_hash) - cached = False - if not cache.exists(): - client = openai.OpenAI(api_key=config.OPENAI_KEY) - with tempfile.NamedTemporaryFile("wb+", suffix=".mp4") as f: - with tempfile.NamedTemporaryFile("wb+", suffix="-" + attachment.filename) as f2: - await attachment.save(f2.name) - f2.seek(0) - seg: pydub.AudioSegment = await asyncio.to_thread(pydub.AudioSegment.from_file, file=f2, format=_ft) - seg = seg.set_channels(1) - await asyncio.to_thread( - seg.export, f.name, format="mp4" + cache = Path.home() / ".cache" / "lcc-bot" / ("%s-transcript.txt" % file_hash) + cached = False + if not cache.exists(): + client = openai.OpenAI(api_key=config.OPENAI_KEY) + with tempfile.NamedTemporaryFile("wb+", suffix=".mp4") as f: + with tempfile.NamedTemporaryFile("wb+", suffix="-" + attachment.filename) as f2: + await attachment.save(f2.name) + f2.seek(0) + seg: pydub.AudioSegment = await asyncio.to_thread(pydub.AudioSegment.from_file, file=f2, format=_ft) + seg = seg.set_channels(1) + await asyncio.to_thread(seg.export, f.name, format="mp4") + f.seek(0) + + transcript = await asyncio.to_thread( + client.audio.transcriptions.create, file=pathlib.Path(f.name), model="whisper-1" ) - f.seek(0) + text = transcript.text + cache.write_text(text) + else: + text = cache.read_text() + cached = True - transcript = await asyncio.to_thread( - client.audio.transcriptions.create, - file=pathlib.Path(f.name), - model="whisper-1" + paginator = commands.Paginator("", "", 4096) + for line in text.splitlines(): + paginator.add_line(textwrap.shorten(line, 4096)) + embeds = list(map(lambda p: discord.Embed(description=p), paginator.pages)) + await ctx.respond(embeds=embeds or [discord.Embed(description="No text found.")]) + + if await self.bot.is_owner(ctx.user): + await ctx.respond( + ("Cached response ({})" if cached else "Uncached response ({})").format(file_hash), ephemeral=True ) - text = transcript.text - cache.write_text(text) - else: - text = cache.read_text() - cached = True - paginator = commands.Paginator("", "", 4096) - for line in text.splitlines(): - paginator.add_line(textwrap.shorten(line, 4096)) - embeds = list(map(lambda p: discord.Embed(description=p), paginator.pages)) - await ctx.respond(embeds=embeds or [discord.Embed(description="No text found.")]) - - if await self.bot.is_owner(ctx.user): - await ctx.respond( - ("Cached response ({})" if cached else "Uncached response ({})").format( - file_hash - ), - ephemeral=True - ) + @commands.slash_command() + async def whois(self, ctx: discord.ApplicationContext, domain: str): + """Runs a WHOIS.""" + await ctx.defer() + url = urlparse("http://" + domain) + if not url.hostname: + return await ctx.respond("Invalid domain.") + + process = await asyncio.create_subprocess_exec( + "whois", "-H", url.hostname, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + await process.wait() + stdout = stdout.decode("utf-8", "replace") + stderr = stderr.decode("utf-8", "replace") + if process.returncode != 0: + return await ctx.respond(f"Error:\n```{stderr[:3900]}```") + + paginator = commands.Paginator() + redacted = io.BytesIO() + for line in stdout.splitlines(): + if line.startswith(">>> Last update"): + break + if "REDACTED" in line or "Please query the WHOIS server of the owning registrar" in line: + redacted.write(line.encode() + b"\n") + else: + paginator.add_line(line) + + if redacted.tell() > 0: + redacted.seek(0) + file = discord.File(redacted, "redacted-fields.txt", description="Any discovered redacted fields.") + else: + file = None + if len(paginator.pages) > 1: + for page in paginator.pages: + await ctx.respond(page) + if file: + await ctx.respond(file=file) + else: + kwargs = {"file": file} if file else {} + await ctx.respond(paginator.pages[0], **kwargs) def setup(bot): diff --git a/cogs/timetable.py b/cogs/timetable.py index db5df85..2fb4f99 100644 --- a/cogs/timetable.py +++ b/cogs/timetable.py @@ -1,13 +1,14 @@ import json +import logging import random from datetime import datetime, time, timedelta, timezone from pathlib import Path from typing import Dict, Optional, Union -import config import discord from discord.ext import commands, tasks +import config from utils import TimeTableDaySwitcherView, console @@ -22,6 +23,7 @@ def schedule_times(): class TimeTableCog(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot + self.log = logging.getLogger("jimmy.cogs.timetable") with (Path.cwd() / "utils" / "timetable.json").open() as file: self.timetable = json.load(file) self.update_status.start() @@ -159,7 +161,7 @@ class TimeTableCog(commands.Cog): try: next_lesson = self.absolute_next_lesson(date + timedelta(days=1)) except RuntimeError: - print("Failed to fetch absolute next lesson. Is this the end?") + self.log.critical("Failed to fetch absolute next lesson. Is this the end?") return next_lesson.setdefault("name", "unknown") next_lesson.setdefault("tutor", "unknown") diff --git a/cogs/uptime.py b/cogs/uptime.py index bca448f..b92b78d 100644 --- a/cogs/uptime.py +++ b/cogs/uptime.py @@ -2,6 +2,7 @@ import asyncio import datetime import hashlib import json +import logging import random import time from datetime import timedelta @@ -66,15 +67,17 @@ class UptimeCompetition(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.http = AsyncClient(verify=False) + self.log = logging.getLogger("jimmy.cogs.uptime") + self.http = AsyncClient(verify=False, http2=True) self._warning_posted = False self.test_uptimes.add_exception_type(Exception) self.test_uptimes.start() self.last_result: list[UptimeEntry] = [] self.task_lock = asyncio.Lock() self.task_event = asyncio.Event() - if not (pth := Path("targets.json")).exists(): - pth.write_text(BASE_JSON) + self.path = Path.home() / ".cache" / "lcc-bot" / "targets.json" + if not self.path.exists(): + self.path.write_text(BASE_JSON) self._cached_targets = self.read_targets() @property @@ -103,7 +106,7 @@ class UptimeCompetition(commands.Cog): return response def read_targets(self) -> List[Dict[str, str]]: - with open("targets.json") as f: + with self.path.open() as f: data: list = json.load(f) data.sort(key=lambda x: x["name"]) self._cached_targets = data.copy() @@ -111,7 +114,7 @@ class UptimeCompetition(commands.Cog): def write_targets(self, data: List[Dict[str, str]]): self._cached_targets = data - with open("targets.json", "w") as f: + with self.path.open("w") as f: json.dump(data, f, indent=4, default=str) def cog_unload(self): @@ -214,7 +217,7 @@ class UptimeCompetition(commands.Cog): okay_statuses = list(filter(None, okay_statuses)) guild: discord.Guild = self.bot.get_guild(guild_id) if guild is None: - console.log( + self.log.warning( f"[yellow]:warning: Unable to locate the guild for {target['name']!r}! Can't uptime check." ) else: @@ -224,7 +227,7 @@ class UptimeCompetition(commands.Cog): try: user = await guild.fetch_member(user_id) except discord.HTTPException: - console.log(f"[yellow]:warning: Unable to locate {target['name']!r}! Can't uptime check.") + self.log.warning(f"[yellow]Unable to locate {target['name']!r}! Can't uptime check.") user = None if user: create_tasks.append( @@ -240,7 +243,7 @@ class UptimeCompetition(commands.Cog): ) else: if self._warning_posted is False: - console.log( + self.log.warning( "[yellow]:warning: Jimmy does not have the presences intent enabled. Uptime monitoring of the" " shronk bot is disabled." ) diff --git a/main.py b/main.py index b463e05..e61efe7 100644 --- a/main.py +++ b/main.py @@ -1,28 +1,57 @@ -import asyncio -import sys import logging +import os +import signal +import sys import textwrap from datetime import datetime, timedelta, timezone -import config import discord from discord.ext import commands +from rich.logging import RichHandler +import config from utils import JimmyBanException, JimmyBans, console, get_or_none from utils.client import bot logging.basicConfig( - filename="jimmy.log", - filemode="a", - format="%(asctime)s:%(level)s:%(name)s: %(message)s", + format="%(asctime)s:%(levelname)s:%(name)s: %(message)s", datefmt="%Y-%m-%d:%H:%M", - level=logging.INFO + level=logging.DEBUG, + force=True, + handlers=[ + RichHandler( + getattr(config, "LOG_LEVEL", logging.INFO), + console=console, + markup=True, + rich_tracebacks=True, + show_path=False, + show_time=False, + ), + logging.FileHandler("jimmy.log", "a"), + ], ) +logging.getLogger("discord.gateway").setLevel(logging.WARNING) +for _ln in [ + "discord.client", + "httpcore.connection", + "httpcore.http2", + "hpack.hpack", + "discord.http", + "discord.bot", + "httpcore.http11", + "aiosqlite", + "httpx", +]: + logging.getLogger(_ln).setLevel(logging.INFO) + + +if os.name != "nt": + signal.signal(signal.SIGTERM, lambda: bot.loop.run_until_complete(bot.close())) @bot.listen() async def on_connect(): - console.log("[green]Connected to discord!") + bot.log.info("[green]Connected to discord!") @bot.listen("on_application_command_error") @@ -60,13 +89,13 @@ async def on_command_error(ctx: commands.Context, error: Exception): return elif isinstance(error, JimmyBanException): return await ctx.reply(str(error)) - await ctx.reply("Command Error: `%r`" % error) + await ctx.reply(textwrap.shorten("Command Error: `%r`" % error, 2000)) raise error @bot.listen("on_application_command") async def on_application_command(ctx: discord.ApplicationContext): - console.log( + bot.log.info( "{0.author} ({0.author.id}) used application command /{0.command.qualified_name} in " "[blue]#{0.channel}[/], {0.guild}".format(ctx) ) @@ -74,9 +103,9 @@ async def on_application_command(ctx: discord.ApplicationContext): @bot.event async def on_ready(): - console.log("Logged in as", bot.user) + bot.log.info("(READY) Logged in as %r", bot.user) if getattr(config, "CONNECT_MODE", None) == 1: - console.log("Bot is now ready and exit target 1 is set, shutting down.") + bot.log.critical("Bot is now ready and exit target 1 is set, shutting down.") await bot.close() sys.exit(0) @@ -105,10 +134,11 @@ async def check_not_banned(ctx: discord.ApplicationContext | commands.Context): if __name__ == "__main__": - console.log("Starting...") + bot.log.info("Starting...") bot.started_at = discord.utils.utcnow() if getattr(config, "WEB_SERVER", True): + bot.log.info("Web server is enabled (WEB_SERVER=True in config.py), initialising.") import uvicorn from web.server import app @@ -119,11 +149,11 @@ if __name__ == "__main__": app, host=getattr(config, "HTTP_HOST", "127.0.0.1"), port=getattr(config, "HTTP_PORT", 3762), - loop="asyncio", **getattr(config, "UVICORN_CONFIG", {}), ) + bot.log.info("Web server will listen on %s:%s", http_config.host, http_config.port) server = uvicorn.Server(http_config) - console.log("Starting web server...") + bot.log.info("Starting web server...") loop = bot.loop http_server_task = loop.create_task(server.serve()) bot.web = { @@ -131,5 +161,5 @@ if __name__ == "__main__": "config": http_config, "task": http_server_task, } - + bot.log.info("Beginning main loop.") bot.run(config.token) diff --git a/utils/__init__.py b/utils/__init__.py index 76b0421..3ade98e 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -4,7 +4,6 @@ from urllib.parse import urlparse from discord.ext import commands -from ._email import * from .console import * from .db import * from .views import * diff --git a/utils/_email.py b/utils/_email.py deleted file mode 100644 index 258e69d..0000000 --- a/utils/_email.py +++ /dev/null @@ -1,52 +0,0 @@ -import secrets -from email.message import EmailMessage - -import aiosmtplib as smtp -import config -import discord - -gmail_cfg = {"addr": "smtp.gmail.com", "username": config.email, "password": config.email_password, "port": 465} -TOKEN_LENGTH = 16 - - -class _FakeUser: - def __init__(self): - with open("/etc/dictionaries-common/words") as file: - names = file.readlines() - names = [x.strip() for x in names if not x.strip().endswith("'s")] - self.names = names - - def __str__(self): - import random - - return f"{random.choice(self.names)}#{str(random.randint(1, 9999)).zfill(4)}" - - -async def send_verification_code(user: discord.User, student_number: str, **kwargs) -> str: - """Sends a verification code, returning said verification code, to the student.""" - code = secrets.token_hex(TOKEN_LENGTH) - text = ( - f"Hey {user} ({student_number})! The code to join Unscrupulous Nonsense is '{code}'.\n\n" - f"Go back to the #verify channel, and click 'I have a verification code!', and put {code} in the modal" - f" that pops up\n\n" - f"If you have any issues getting in, feel free to reply to this email, or DM eek#7574.\n" - f"~Nex\n\n\n" - f"(P.S you can now go to http://droplet.nexy7574.co.uk/jimmy/verify/{code} instead)" - ) - msg = EmailMessage() - msg["From"] = msg["bcc"] = "B593764@my.leedscitycollege.ac.uk" - msg["To"] = f"{student_number}@my.leedscitycollege.ac.uk" - msg["Subject"] = "Server Verification" - msg.set_content(text) - - kwargs.setdefault("hostname", gmail_cfg["addr"]) - kwargs.setdefault("port", gmail_cfg["port"]) - kwargs.setdefault("use_tls", True) - kwargs.setdefault("username", gmail_cfg["username"]) - kwargs.setdefault("password", gmail_cfg["password"]) - kwargs.setdefault("start_tls", not kwargs["use_tls"]) - - assert kwargs["start_tls"] != kwargs["use_tls"] - - await smtp.send(msg, **kwargs) - return code diff --git a/utils/client.py b/utils/client.py index db9defa..b7b9c12 100644 --- a/utils/client.py +++ b/utils/client.py @@ -1,14 +1,16 @@ import asyncio +import logging import sys from asyncio import Lock from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, Dict, Optional, Union -import config import discord from discord.ext import commands +import config + if TYPE_CHECKING: from asyncio import Task @@ -30,37 +32,41 @@ class Bot(commands.Bot): super().__init__( command_prefix=commands.when_mentioned_or(*prefixes), debug_guilds=guilds, - allowed_mentions=discord.AllowedMentions.none(), + allowed_mentions=discord.AllowedMentions(everyone=False, users=True, roles=False, replied_user=True), intents=intents, max_messages=5000, case_insensitive=True, ) self.loop.run_until_complete(registry.create_all()) self.training_lock = Lock() - self.started_at = datetime.now(tz=timezone.utc) + self.started_at = discord.utils.utcnow() self.console = console - self.incidents = {} + self.log = log = logging.getLogger("jimmy.client") + self.debug = log.debug + self.info = log.info + self.warning = self.warn = log.warning + self.error = self.log.error + self.critical = self.log.critical for ext in extensions: try: self.load_extension(ext) except discord.ExtensionNotFound: - console.log(f"[red]Failed to load extension {ext}: Extension not found.") + log.error(f"[red]Failed to load extension {ext}: Extension not found.") except (discord.ExtensionFailed, OSError) as e: - console.log(f"[red]Failed to load extension {ext}: {e}") - if getattr(config, "dev", False): - console.print_exception() + log.error(f"[red]Failed to load extension {ext}: {e}", exc_info=True) else: - console.log(f"Loaded extension [green]{ext}") + log.info(f"Loaded extension [green]{ext}") if getattr(config, "CONNECT_MODE", None) == 2: async def connect(self, *, reconnect: bool = True) -> None: - self.console.log("Exit target 2 reached, shutting down (not connecting to discord).") + self.log.critical("Exit target 2 reached, shutting down (not connecting to discord).") return async def on_error(self, event: str, *args, **kwargs): e_type, e, tb = sys.exc_info() if isinstance(e, discord.NotFound) and e.code == 10062: # invalid interaction + self.log.warning(f"Invalid interaction received, ignoring. {e!r}") return if isinstance(e, discord.CheckFailure) and "The global check once functions failed." in str(e): return @@ -69,25 +75,27 @@ class Bot(commands.Bot): async def close(self) -> None: await self.http.close() if getattr(self, "web", None) is not None: - self.console.log("Closing web server...") - await self.web["server"].shutdown() - if hasattr(self, "web"): + self.log.info("Closing web server...") + try: + await asyncio.wait_for(self.web["server"].shutdown(), timeout=5) self.web["task"].cancel() self.console.log("Web server closed.") try: - await self.web["task"] - except asyncio.CancelledError: + await asyncio.wait_for(self.web["task"], timeout=5) + except (asyncio.CancelledError, asyncio.TimeoutError): pass del self.web["server"] del self.web["config"] del self.web["task"] del self.web + except asyncio.TimeoutError: + pass try: await super().close() except asyncio.TimeoutError: - self.console.log("Timed out while closing, forcing shutdown.") + self.log.critical("Timed out while closing, forcing shutdown.") sys.exit(1) - self.console.log("Finished shutting down.") + self.log.info("Finished shutting down.") try: diff --git a/utils/console.py b/utils/console.py index b3df204..be581b1 100644 --- a/utils/console.py +++ b/utils/console.py @@ -8,4 +8,4 @@ if _col == 80: __all__ = ("console",) -console = Console(width=_col, soft_wrap=True, tab_size=4) +console = Console(width=_col, soft_wrap=False, tab_size=4) diff --git a/utils/sentiment_analysis.py b/utils/sentiment_analysis.py deleted file mode 100644 index 7fddd78..0000000 --- a/utils/sentiment_analysis.py +++ /dev/null @@ -1,112 +0,0 @@ -# I have NO idea how this works -# I copied it from the tutorial -# However it works -import random -import re -import string - -from nltk import FreqDist, NaiveBayesClassifier, classify -from nltk.corpus import movie_reviews, stopwords, twitter_samples -from nltk.sentiment.vader import SentimentIntensityAnalyzer -from nltk.stem.wordnet import WordNetLemmatizer -from nltk.tag import pos_tag - -positive_tweets = twitter_samples.strings("positive_tweets.json") -negative_tweets = twitter_samples.strings("negative_tweets.json") -positive_reviews = movie_reviews.categories("pos") -negative_reviews = movie_reviews.categories("neg") -positive_tweets += positive_reviews -# negative_tweets += negative_reviews -positive_tweet_tokens = twitter_samples.tokenized("positive_tweets.json") -negative_tweet_tokens = twitter_samples.tokenized("negative_tweets.json") -text = twitter_samples.strings("tweets.20150430-223406.json") -tweet_tokens = twitter_samples.tokenized("positive_tweets.json") -stop_words = stopwords.words("english") - - -def lemmatize_sentence(_tokens): - lemmatizer = WordNetLemmatizer() - lemmatized_sentence = [] - for word, tag in pos_tag(_tokens): - if tag.startswith("NN"): - pos = "n" - elif tag.startswith("VB"): - pos = "v" - else: - pos = "a" - lemmatized_sentence.append(lemmatizer.lemmatize(word, pos)) - return lemmatized_sentence - - -def remove_noise(_tweet_tokens, _stop_words=()): - cleaned_tokens = [] - - for token, tag in pos_tag(_tweet_tokens): - token = re.sub("https?://(?:[a-zA-Z]|[0-9]|[$-_@.&+#]|[!*(),]|" "%[0-9a-fA-F][0-9a-fA-F])+", "", token) - token = re.sub("(@[A-Za-z0-9_]+)", "", token) - - if tag.startswith("NN"): - pos = "n" - elif tag.startswith("VB"): - pos = "v" - else: - pos = "a" - - lemmatizer = WordNetLemmatizer() - token = lemmatizer.lemmatize(token, pos) - - if len(token) > 0 and token not in string.punctuation and token.lower() not in _stop_words: - cleaned_tokens.append(token.lower()) - return cleaned_tokens - - -positive_cleaned_tokens_list = [] -negative_cleaned_tokens_list = [] - -for tokens in positive_tweet_tokens: - positive_cleaned_tokens_list.append(remove_noise(tokens, stop_words)) - -for tokens in negative_tweet_tokens: - negative_cleaned_tokens_list.append(remove_noise(tokens, stop_words)) - - -def get_all_words(cleaned_tokens_list): - for _tokens in cleaned_tokens_list: - for token in _tokens: - yield token - - -all_pos_words = get_all_words(positive_cleaned_tokens_list) -freq_dist_pos = FreqDist(all_pos_words) - - -def get_tweets_for_model(cleaned_tokens_list): - for _tweet_tokens in cleaned_tokens_list: - yield {token: True for token in _tweet_tokens} - - -positive_tokens_for_model = get_tweets_for_model(positive_cleaned_tokens_list) -negative_tokens_for_model = get_tweets_for_model(negative_cleaned_tokens_list) - -positive_dataset = [(tweet_dict, "Positive") for tweet_dict in positive_tokens_for_model] - -negative_dataset = [(tweet_dict, "Negative") for tweet_dict in negative_tokens_for_model] - -dataset = positive_dataset + negative_dataset - -random.shuffle(dataset) - -train_data = dataset[:7000] -test_data = dataset[7000:] -classifier = NaiveBayesClassifier.train(train_data) -intensity_analyser = SentimentIntensityAnalyzer() - -if __name__ == "__main__": - while True: - try: - ex = input("> ") - except KeyboardInterrupt: - break - else: - print(classifier.classify({token: True for token in remove_noise(ex.split())})) - print(intensity_analyser.polarity_scores(ex)) diff --git a/utils/views.py b/utils/views.py index 40beabb..67da8c2 100644 --- a/utils/views.py +++ b/utils/views.py @@ -8,15 +8,9 @@ import discord import orm from discord.ui import View -from utils import ( - TOKEN_LENGTH, - BannedStudentID, - Student, - VerifyCode, - console, - get_or_none, - send_verification_code, -) +from utils import BannedStudentID, Student, VerifyCode, console, get_or_none + +TOKEN_LENGTH = 16 if typing.TYPE_CHECKING: from cogs.timetable import TimeTableCog @@ -133,7 +127,8 @@ class VerifyView(View): ) try: - _code = await send_verification_code(interaction.user, st) + # _code = await send_verification_code(interaction.user, st) + raise RuntimeError("Disabled.") except Exception as e: return await interaction.followup.send(f"\N{cross mark} Failed to send email - {e}. Try again?") console.log(f"Sending verification email to {interaction.user} ({interaction.user.id}/{st})...") diff --git a/web/server.py b/web/server.py index 95b60c9..ff349d9 100644 --- a/web/server.py +++ b/web/server.py @@ -1,9 +1,9 @@ import asyncio import ipaddress +import logging import os -import sys import textwrap -from rich import print +from asyncio import Lock from datetime import datetime, timezone from hashlib import sha512 from http import HTTPStatus @@ -11,13 +11,13 @@ from pathlib import Path import discord import httpx -from config import guilds -from asyncio import Lock -from fastapi import FastAPI, Header, HTTPException, Request, WebSocketException as _WSException -from websockets.exceptions import WebSocketException +from fastapi import FastAPI, Header, HTTPException, Request +from fastapi import WebSocketException as _WSException from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from starlette.websockets import WebSocket, WebSocketDisconnect +from websockets.exceptions import WebSocketException +from config import guilds from utils import BannedStudentID, Student, VerifyCode, console, get_or_none from utils.db import AccessTokens @@ -37,7 +37,9 @@ try: except ImportError: WEB_ROOT_PATH = "" -GENERAL = "https://ptb.discord.com/channels/994710566612500550/1018915342317277215/" +log = logging.getLogger("jimmy.api") + +GENERAL = "https://discord.com/channels/994710566612500550/" OAUTH_ENABLED = OAUTH_ID and OAUTH_SECRET and OAUTH_REDIRECT_URI @@ -87,7 +89,7 @@ async def authenticate(req: Request, code: str = None, state: str = None): if not (code and state) or state not in app.state.states: value = os.urandom(4).hex() if value in app.state.states: - print("Generated a state that already exists. Cleaning up", file=sys.stderr) + log.warning("Generated a state that already exists. Cleaning up") # remove any states older than 5 minutes removed = 0 for _value in list(app.state.states): @@ -95,17 +97,17 @@ async def authenticate(req: Request, code: str = None, state: str = None): del app.state.states[_value] removed += 1 value = os.urandom(4).hex() - print(f"Removed {removed} states.", file=sys.stderr) + log.warning(f"Removed {removed} old states.") if value in app.state.states: - print("Critical: Generated a state that already exists and could not free any slots.", file=sys.stderr) + log.critical("Generated a state that already exists and could not free any slots.") raise HTTPException( HTTPStatus.SERVICE_UNAVAILABLE, "Could not generate a state token (state container full, potential (D)DOS attack?). " "Please try again later.", # Saying a suspected DDOS makes sense, there are 4,294,967,296 possible states, the likelyhood of a # collision is 1 in 4,294,967,296. - headers={"Retry-After": "300"}, + headers={"Retry-After": "60"}, ) app.state.states[value] = datetime.now() return RedirectResponse( @@ -239,7 +241,7 @@ async def verify(code: str): # And delete the code await verify_code.delete() - console.log(f"[green]{verify_code.bind} verified ({verify_code.bind}/{verify_code.student_id})") + log.info(f"[green]{verify_code.bind} verified ({verify_code.bind}/{verify_code.student_id})") return RedirectResponse(GENERAL, status_code=308) @@ -293,12 +295,12 @@ async def bridge(req: Request): @app.websocket("/bridge/recv") async def bridge_recv(ws: WebSocket, secret: str = Header(None)): await ws.accept() - print("Websocket %r accepted.", ws) + log.info("Websocket %s:%s accepted.", ws.client.host, ws.client.port) if secret != app.state.bot.http.token: - print("Closing websocket %r, invalid secret." % ws) + log.warning("Closing websocket %r, invalid secret.", ws.client.host) raise _WSException(code=1008, reason="Invalid Secret") if app.state.ws_connected.locked(): - print("Closing websocket %r, already connected." % ws) + log.warning("Closing websocket %r, already connected." % ws) raise _WSException(code=1008, reason="Already connected.") queue: asyncio.Queue = app.state.bot.bridge_queue @@ -307,7 +309,7 @@ async def bridge_recv(ws: WebSocket, secret: str = Header(None)): try: await ws.send_json({"status": "ping"}) except (WebSocketDisconnect, WebSocketException): - print("Websocket %r disconnected." % ws) + log.info("Websocket %r disconnected.", ws) break try: @@ -316,10 +318,10 @@ async def bridge_recv(ws: WebSocket, secret: str = Header(None)): continue try: - print("Sent data %r to websocket %r." % (data, ws)) await ws.send_json(data) + log.debug("Sent data %r to websocket %r.", data, ws) except (WebSocketDisconnect, WebSocketException): - print("Websocket %r disconnected." % ws) + log.info("Websocket %r disconnected." % ws) break finally: queue.task_done()