diff --git a/.idea/dataSources.local.xml b/.idea/dataSources.local.xml index b44f725..357d466 100644 --- a/.idea/dataSources.local.xml +++ b/.idea/dataSources.local.xml @@ -1,6 +1,6 @@ - + " diff --git a/.idea/the-hi5-group.iml b/.idea/the-hi5-group.iml index 380e798..74d515a 100644 --- a/.idea/the-hi5-group.iml +++ b/.idea/the-hi5-group.iml @@ -4,7 +4,7 @@ - + \ No newline at end of file diff --git a/assets/virgin/14330-ping.png b/assets/virgin/14330-ping.png new file mode 100644 index 0000000..18c1468 Binary files /dev/null and b/assets/virgin/14330-ping.png differ diff --git a/assets/visio.png b/assets/visio.png new file mode 100644 index 0000000..ace54cf Binary files /dev/null and b/assets/visio.png differ diff --git a/cogs/events.py b/cogs/events.py index 8f3bf27..9f8c023 100644 --- a/cogs/events.py +++ b/cogs/events.py @@ -19,6 +19,7 @@ import httpx from discord.ext import commands, pages, tasks from utils import Student, get_or_none, console from config import guilds +from utils.db import AccessTokens try: from config import dev except ImportError: @@ -325,21 +326,13 @@ class Events(commands.Cog): ) await message.reply(_content, allowed_mentions=discord.AllowedMentions.none()) - async def send_fuck_you(): - student = await get_or_none(Student, user_id=message.author.id) - if student is None: - return await message.reply("You aren't even verified...", delete_after=10) - elif student.ip_info is None: + async def send_fuck_you() -> str: + student = await get_or_none(AccessTokens, user_id=message.author.id) + if student.ip_info is None or student.expires >= discord.utils.utcnow().timestamp(): if OAUTH_REDIRECT_URI: - return await message.reply( - f"Let me see who you are, and then we'll talk... <{OAUTH_REDIRECT_URI}>", - delete_after=30 - ) + return f"Let me see who you are, and then we'll talk... <{OAUTH_REDIRECT_URI}>" else: - return await message.reply( - "I literally don't even know who you are...", - delete_after=10 - ) + return "I literally don't even know who you are..." else: ip = student.ip_info is_proxy = ip.get("proxy") @@ -354,7 +347,7 @@ class Events(commands.Cog): else: is_hosting = "\N{WHITE HEAVY CHECK MARK}" if is_hosting else "\N{CROSS MARK}" - return await message.reply( + return ( "Nice argument, however,\n" "IP: {0[query]}\n" "ISP: {0[isp]}\n" @@ -366,8 +359,7 @@ class Events(commands.Cog): ip, is_proxy, is_hosting - ), - delete_after=30 + ) ) if not message.guild: @@ -396,9 +388,9 @@ class Events(commands.Cog): "content": "https://ferdi-is.gay/bee", }, r"it just works": { - "func": play_voice(assets / "it-just-works.mp3"), + "func": play_voice(assets / "it-just-works.ogg"), "meta": { - "check": (assets / "it-just-works.mp3").exists + "check": (assets / "it-just-works.ogg").exists } }, r"^linux$": { @@ -442,7 +434,7 @@ class Events(commands.Cog): "delete_after": None }, r"fuck you(\W)*": { - "func": send_fuck_you, + "content": send_fuck_you, "meta": { "check": lambda: message.content.startswith(self.bot.user.mention) } @@ -453,7 +445,7 @@ class Events(commands.Cog): "check": (assets / "mine-diamonds.opus").exists } }, - r"v[ei]r[mg]in(\sme(d|m[a]?)ia\W*)?$": { + r"v[ei]r[mg]in(\sme(d|m[a]?)ia\W*)?(\W\w*\W*)?$": { "content": "Get virgin'd", "file": lambda: discord.File( random.choice(list(Path(assets / 'virgin').iterdir())) @@ -461,7 +453,22 @@ class Events(commands.Cog): "meta": { "check": (assets / 'virgin').exists } - } + }, + r"richard|(dick\W*$)": { + "file": discord.File(assets / "visio.png"), + "meta": { + "check": (assets / "visio.png").exists + } + }, + r"thank(\syou|s)(,)? jimmy": { + "content": "You're welcome, %s!" % message.author.mention, + }, + r"(ok )?jimmy (we|i) g[eo]t it": { + "content": "No need to be so rude! Cunt.", + }, + r"c(mon|ome on) jimmy": { + "content": "IM TRYING" + }, } # Stop responding to any bots if message.author.bot is True: @@ -516,7 +523,7 @@ class Events(commands.Cog): if "func" in data: try: - if inspect.iscoroutinefunction(data["func"]): + if inspect.iscoroutinefunction(data["func"]) or inspect.iscoroutine(data["func"]): await data["func"]() break else: @@ -527,9 +534,12 @@ class Events(commands.Cog): continue else: for k, v in data.copy().items(): - if callable(v): + if inspect.iscoroutinefunction(data[k]) or inspect.iscoroutine(data[k]): + data[k] = await v() + elif callable(v): data[k] = v() data.setdefault("delete_after", 30) + await message.channel.trigger_typing() await message.reply(**data) break diff --git a/cogs/info.py b/cogs/info.py index 07bcc89..2c586ef 100644 --- a/cogs/info.py +++ b/cogs/info.py @@ -1,195 +1,112 @@ -import asyncio -import datetime -import os -import sys -import time -from typing import List - import discord -import humanize -import psutil -from functools import partial +import httpx from discord.ext import commands -from pathlib import Path +from utils import get_or_none +from utils.db import AccessTokens +try: + from config import OAUTH_ID, OAUTH_REDIRECT_URI +except ImportError: + OAUTH_REDIRECT_URI = OAUTH_ID = None class InfoCog(commands.Cog): - EMOJIS = { - "CPU": "\N{brain}", - "RAM": "\N{ram}", - "SWAP": "\N{swan}", - "DISK": "\N{minidisc}", - "NETWORK": "\N{satellite antenna}", - "SENSORS": "\N{thermometer}", - "UPTIME": "\N{alarm clock}", - "ON": "\N{large green circle}", - "OFF": "\N{large red circle}", - } - def __init__(self, bot): self.bot = bot + self.client = httpx.AsyncClient(base_url="https://discord.com/api") + + async def get_user_info(self, token: str): + try: + response = await self.client.get("/users/@me", headers={"Authorization": f"Bearer {token}"}) + response.raise_for_status() + except (httpx.HTTPError, httpx.RequestError, ConnectionError): + return + return response.json() + + async def get_user_guilds(self, token: str): + try: + response = await self.client.get("/users/@me/guilds", headers={"Authorization": f"Bearer {token}"}) + response.raise_for_status() + except (httpx.HTTPError, httpx.RequestError, ConnectionError): + return + return response.json() - async def run_subcommand(self, *args: str): - """Runs a command in a shell in the background, asynchronously, returning status, stdout, and stderr.""" - proc = await asyncio.create_subprocess_exec( - *args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - loop=self.bot.loop, - ) - stdout, stderr = await proc.communicate() - return proc.returncode, stdout.decode("utf-8", "replace"), stderr.decode("utf-8", "replace") - - async def unblock(self, function: callable, *args, **kwargs): - """Runs a function in the background, asynchronously, returning the result.""" - return await self.bot.loop.run_in_executor(None, partial(function, *args, **kwargs)) - - @staticmethod - def bar_fill( - filled: int, - total: int, - bar_width: int = 10, - char: str = "\N{black large square}", - unfilled_char: str = "\N{white large square}" - ): - """Returns a progress bar with the given length and fill character.""" - if filled == 0: - filled = 0.01 - if total == 0: - total = 0.01 - percent = filled / total - to_fill = int(bar_width * percent) - return f"{char * to_fill}{unfilled_char * (bar_width - to_fill)}" - - @commands.slash_command(name="system-info") - @commands.max_concurrency(1, commands.BucketType.user) - async def system_info(self, ctx: discord.ApplicationContext): - """Gather statistics on the current host.""" - bar_emojis = { - 75: "\N{large yellow square}", - 80: "\N{large orange square}", - 90: "\N{large red square}", - } + async def get_user_connections(self, token: str): + try: + response = await self.client.get("/users/@me/connections", headers={"Authorization": f"Bearer {token}"}) + response.raise_for_status() + except (httpx.HTTPError, httpx.RequestError, ConnectionError): + return + return response.json() + + @commands.slash_command() + @commands.cooldown(1, 10, commands.BucketType.user) + async def me(self, ctx: discord.ApplicationCommand): + """Displays oauth info about you""" await ctx.defer() - root_drive = Path(__file__).root - temperature = fans = {} - binary = os.name != "nt" - - # Gather statistics - start = time.time() - cpu: List[float] = await self.unblock(psutil.cpu_percent, interval=1.0, percpu=True) - ram = await self.unblock(psutil.virtual_memory) - swap = await self.unblock(psutil.swap_memory) - disk = await self.unblock(psutil.disk_usage, root_drive) - network = await self.unblock(psutil.net_io_counters) - if getattr(psutil, "sensors_temperatures", None): - temperature = await self.unblock(psutil.sensors_temperatures) - if getattr(psutil, "sensors_fans", None): - fans = await self.unblock(psutil.sensors_fans) - uptime = datetime.datetime.fromtimestamp(await self.unblock(psutil.boot_time), datetime.timezone.utc) - end = time.time() - + + user = await get_or_none(AccessTokens, user_id=ctx.author.id) + if not user: + url = OAUTH_REDIRECT_URI + return await ctx.respond( + embed=discord.Embed( + title="You must link your account first!", + description="Don't worry, [I only store your IP information. And the access token.](%s)" % url, + url=url + ) + ) + user_data = await self.get_user_info(user.access_token) + guilds = await self.get_user_guilds(user.access_token) + connections = await self.get_user_connections(user.access_token) embed = discord.Embed( - title="System Statistics", - description=f"Collected in {humanize.precisedelta(datetime.timedelta(seconds=end - start))}.", - color=discord.Color.blurple(), + title="Your info", ) - - # Format statistics - per_core = "\n".join(f"{i}: {c:.2f}%" for i, c in enumerate(cpu)) - total_cpu = sum(cpu) - pct = total_cpu / len(cpu) - cpu_bar_emoji = "\N{large green square}" - for threshold, emoji in bar_emojis.items(): - if pct >= threshold: - cpu_bar_emoji = emoji - - bar = self.bar_fill(sum(cpu), len(cpu) * 100, 16, cpu_bar_emoji, "\u2581") - embed.add_field( - name=f"{self.EMOJIS['CPU']} CPU", - value=f"**Usage:** {sum(cpu):.2f}%\n" - f"**Cores:** {len(cpu)}\n" - f"**Usage Per Core:**\n{per_core}\n" - f"{bar}", - inline=False, - ) - if "coretemp" in temperature: + if user_data: + for field in ("bot", "system", "mfa_enabled", "banner", "accent_color", "mfa_enabled", "locale", + "verified", "email", "flags", "premium_type", "public_flags"): + user_data.setdefault(field, None) + lines = [ + "ID: {0[id]}", + "Username: {0[username]}", + "Discriminator: #{0[discriminator]}", + "Avatar: {0[avatar]}", + "Bot: {0[bot]}", + "System: {0[system]}", + "MFA Enabled: {0[mfa_enabled]}", + "Banner: {0[banner]}", + "Banner Color: {0[banner_color]}", + "Locale: {0[locale]}", + "Email Verified: {0[verified]}", + "Email: {1}", + "Flags: {0[flags]}", + "Premium Type: {0[premium_type]}", + "Public Flags: {0[public_flags]}", + ] + email = user_data["email"] + if email: + email = email.replace("@", "\u200b@\u200b").replace(".", "\u200b.\u200b") embed.add_field( - name=f"{self.EMOJIS['SENSORS']} Temperature (coretemp)", - value="\n".join(f"{s.label}: {s.current:.2f}°C" for s in temperature["coretemp"]), - inline=True, + name="User Info", + value="\n".join(lines).format(user_data, email), ) - elif "acpitz" in temperature: + + if guilds: + guilds = sorted(guilds, key=lambda x: x["name"]) embed.add_field( - name=f"{self.EMOJIS['SENSORS']} Temperature (acpitz)", - value="\n".join(f"{s.label}: {s.current:.2f}°C" for s in temperature["acpitz"]), - inline=True, + name="Guilds (%d):" % len(guilds), + value="\n".join(f"{guild['name']} ({guild['id']})" for guild in guilds), ) - elif "cpu_thermal" in temperature: + + if connections: embed.add_field( - name=f"{self.EMOJIS['SENSORS']} Temperature (cpu_thermal)", - value="\n".join(f"{s.label}: {s.current:.2f}°C" for s in temperature["cpu_thermal"]), - inline=True, + name="Connections (%d):" % len(connections), + value="\n".join(f"{connection['type'].title()} ({connection['id']})" for connection in connections), ) - if fans: - embed.add_field( - name=f"{self.EMOJIS['SENSORS']} Fans", - value="\n".join(f"{s.label}: {s.current:.2f} RPM" for s in fans), - inline=True, - ) - if Path("/tmp/fanstate").exists(): - with open("/tmp/fanstate", "r") as f: - fan_active = f.read().strip() == "1" - LED_colour = "unknown" - fan_state = f"{self.EMOJIS['OFF']} Inactive" - if fan_active: - fan_state = f"{self.EMOJIS['ON']} Active" - embed.add_field( - name=f"{self.EMOJIS['SENSORS']} Fan", - value=f"{fan_state} (LED: #{LED_colour})", - inline=True, - ) - - embed.add_field( - name=f"{self.EMOJIS['RAM']} RAM", - value=f"**Usage:** {ram.percent}%\n" - f"**Total:** {humanize.naturalsize(ram.total, binary=binary)}\n" - f"**Available:** {humanize.naturalsize(ram.available, binary=binary)}", - inline=False, - ) - embed.add_field( - name=f"{self.EMOJIS['SWAP']} Swap", - value=f"**Usage:** {swap.percent}%\n" - f"**Total:** {humanize.naturalsize(swap.total, binary=binary)}\n" - f"**Free:** {humanize.naturalsize(swap.free, binary=binary)}\n" - f"**Used:** {humanize.naturalsize(swap.used, binary=binary)}", - inline=True, - ) - - embed.add_field( - name=f"{self.EMOJIS['DISK']} Disk ({root_drive})", - value=f"**Usage:** {disk.percent}%\n" - f"**Total:** {humanize.naturalsize(disk.total, binary=binary)}\n" - f"**Free:** {humanize.naturalsize(disk.free, binary=binary)}\n" - f"**Used:** {humanize.naturalsize(disk.used, binary=binary)}", - inline=False, - ) - embed.add_field( - name=f"{self.EMOJIS['NETWORK']} Network", - value=f"**Sent:** {humanize.naturalsize(network.bytes_sent, binary=binary)}\n" - f"**Received:** {humanize.naturalsize(network.bytes_recv, binary=binary)}", - inline=True, - ) - embed.add_field( - name=f"{self.EMOJIS['UPTIME']} Uptime", - value=f"Booted {discord.utils.format_dt(uptime, 'R')}" - f"({humanize.precisedelta(datetime.datetime.now(datetime.timezone.utc) - uptime)})", - inline=False, - ) - - await ctx.edit(embed=embed) + await ctx.respond(embed=embed) def setup(bot): - bot.add_cog(InfoCog(bot)) + if OAUTH_REDIRECT_URI and OAUTH_ID: + bot.add_cog(InfoCog(bot)) + else: + print("OAUTH_REDIRECT_URI not set, not loading info cog") diff --git a/cogs/other.py b/cogs/other.py index bcf2b2d..7a5f3c1 100644 --- a/cogs/other.py +++ b/cogs/other.py @@ -2,6 +2,7 @@ import asyncio import io import json import os +import subprocess import random import re import tempfile @@ -711,9 +712,9 @@ class OtherCog(commands.Cog): window_width = max(min(1080 * 6, window_width), 1080 // 6) window_height = max(min(1920 * 6, window_height), 1920 // 6) await ctx.defer() - if ctx.user.id == 1019233057519177778 and ctx.me.guild_permissions.moderate_members: - if ctx.user.communication_disabled_until is None: - await ctx.user.timeout_for(timedelta(minutes=2), reason="no") + # if ctx.user.id == 1019233057519177778 and ctx.me.guild_permissions.moderate_members: + # if ctx.user.communication_disabled_until is None: + # await ctx.user.timeout_for(timedelta(minutes=2), reason="no") url = urlparse(url) if not url.scheme: if "/" in url.path: @@ -846,255 +847,6 @@ class OtherCog(commands.Cog): await blacklist.write(line) await ctx.respond("Removed domain from blacklist.") - # noinspection PyTypeHints - @commands.slash_command(name="yt-dl") - @commands.max_concurrency(1, commands.BucketType.user) - async def yt_dl( - self, - ctx: discord.ApplicationContext, - url: str, - video_format: discord.Option( - description="The format to download the video in.", - autocomplete=format_autocomplete, - default="" - ) = "", - upload_log: bool = True, - list_formats: bool = False, - proxy_mode: discord.Option( - str, - choices=[ - "No Proxy", - "Dedicated Proxy", - "Random Public Proxy" - ], - description="Only use if a download was blocked or 403'd.", - default="No Proxy", - ) = "No Proxy", - ): - """Downloads a video from using youtube-dl""" - use_proxy = ["No Proxy", "Dedicated Proxy", "Random Public Proxy"].index(proxy_mode) - embed = discord.Embed( - description="Loading..." - ) - embed.set_thumbnail(url="https://cdn.discordapp.com/emojis/1101463077586735174.gif?v=1") - await ctx.defer() - - await ctx.respond(embed=embed) - if list_formats: - # Nothing actually downloads here - try: - formats = await self.list_formats(url, use_proxy=use_proxy) - except FileNotFoundError: - _embed = embed.copy() - _embed.description = "yt-dlp not found." - _embed.colour = discord.Colour.red() - _embed.set_thumbnail(url=discord.Embed.Empty) - return await ctx.edit(embed=_embed) - except json.JSONDecodeError: - _embed = embed.copy() - _embed.description = "Unable to find formats. You're on your own. Wing it." - _embed.colour = discord.Colour.red() - _embed.set_thumbnail(url=discord.Embed.Empty) - return await ctx.edit(embed=_embed) - else: - embeds = [] - for fmt in formats.keys(): - fs = formats[fmt]["filesize"] or 0.1 - if fs == float("inf"): - fs = 0 - units = ["B"] - else: - units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] - while fs > 1024: - fs /= 1024 - units.pop(0) - embeds.append( - discord.Embed( - title=fmt, - description="- Encoding: {0[vcodec]} + {0[acodec]}\n" - "- Extension: `.{0[ext]}`\n" - "- Resolution: {0[resolution]}\n" - "- Filesize: {1}\n" - "- Protocol: {0[protocol]}\n".format(formats[fmt], f"{round(fs, 2)}{units[0]}"), - colour=discord.Colour.blurple() - ).add_field( - name="Download:", - value="{} url:{} video_format:{}".format( - self.bot.get_application_command("yt-dl").mention, - url, - fmt - ) - ) - ) - _paginator = pages.Paginator(embeds, loop_pages=True) - await ctx.delete(delay=0.1) - return await _paginator.respond(ctx.interaction) - - with tempfile.TemporaryDirectory(prefix="jimmy-ytdl-") as tempdir: - video_format = video_format.lower() - MAX_SIZE = round(ctx.guild.filesize_limit / 1024 / 1024) - if MAX_SIZE == 8: - MAX_SIZE = 25 - options = [ - "--no-colors", - "--no-playlist", - "--no-check-certificates", - "--no-warnings", - "--newline", - "--restrict-filenames", - "--output", - f"{ctx.user.id}.%(title)s.%(ext)s", - ] - if video_format: - options.extend(["--format", f"({video_format})[filesize<={MAX_SIZE}M]"]) - else: - options.extend(["--format", f"(bv*+ba/b/ba)[filesize<={MAX_SIZE}M]"]) - - if use_proxy == 1 and proxy: - options.append("--proxy") - options.append(proxy) - console.log("yt-dlp using proxy: %r", proxy) - elif use_proxy == 2 and proxies: - options.append("--proxy") - options.append(random.choice(proxies)) - console.log("yt-dlp using random proxy: %r", options[-1]) - - _embed = embed.copy() - _embed.description = "Downloading..." - _embed.colour = discord.Colour.blurple() - await ctx.edit( - embed=_embed, - ) - try: - venv = Path.cwd() / "venv" / ("Scripts" if os.name == "nt" else "bin") - if venv: - venv = venv.absolute().resolve() - if str(venv) not in os.environ["PATH"]: - os.environ["PATH"] += os.pathsep + str(venv) - - process = await asyncio.create_subprocess_exec( - "yt-dlp", - url, - *options, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=Path(tempdir).resolve() - ) - async with ctx.channel.typing(): - stdout, stderr = await process.communicate() - stdout_log = io.BytesIO(stdout) - stdout_log_file = discord.File(stdout_log, filename="stdout.txt") - stderr_log = io.BytesIO(stderr) - stderr_log_file = discord.File(stderr_log, filename="stderr.txt") - await process.wait() - except FileNotFoundError: - return await ctx.edit( - embed=discord.Embed( - description="Downloader not found.", - color=discord.Color.red() - ) - ) - - if process.returncode != 0: - files = [ - stdout_log_file, - stderr_log_file - ] - if b"format is not available" in stderr: - formats = await self.list_formats(url) - embeds = [] - for fmt in formats.keys(): - fs = formats[fmt]["filesize"] or 0.1 - if fs == float("inf"): - fs = 0 - units = ["B"] - else: - units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] - while fs > 1024: - fs /= 1024 - units.pop(0) - embeds.append( - discord.Embed( - title=fmt, - description="- Encoding: {0[vcodec]} + {0[acodec]}\n" - "- Extension: `.{0[ext]}`\n" - "- Resolution: {0[resolution]}\n" - "- Filesize: {1}\n" - "- Protocol: {0[protocol]}\n".format(formats[fmt], - f"{round(fs, 2)}{units[0]}"), - colour=discord.Colour.blurple() - ).add_field( - name="Download:", - value="{} url:{} video_format:{}".format( - self.bot.get_application_command("yt-dl").mention, - url, - fmt - ) - ) - ) - _paginator = pages.Paginator(embeds, loop_pages=True) - await ctx.delete(delay=0.1) - return await _paginator.respond(ctx.interaction) - return await ctx.edit(content=f"Download failed:\n```\n{stderr.decode()}\n```", files=files) - - _embed = embed.copy() - _embed.description = "Download complete." - _embed.colour = discord.Colour.green() - _embed.set_thumbnail(url=discord.Embed.Empty) - await ctx.edit(embed=_embed) - files = [ - stdout_log_file, - stderr_log_file - ] if upload_log else [] - cum_size = 0 - for file in files: - n_b = len(file.fp.read()) - file.fp.seek(0) - if n_b == 0: - files.remove(file) - continue - elif n_b >= 1024 * 1024 * 256: - data = file.fp.read() - compressed = await self.bot.loop.run_in_executor( - gzip.compress, data, 9 - ) - file.fp.close() - file.fp = io.BytesIO(compressed) - file.fp.seek(0) - file.filename += ".gz" - cum_size += len(compressed) - else: - cum_size += n_b - - for file_name in Path(tempdir).glob(f"{ctx.user.id}.*"): - stat = file_name.stat() - size_mb = stat.st_size / 1024 / 1024 - if (size_mb * 1024 * 1024 + cum_size) >= (MAX_SIZE - 0.256) * 1024 * 1024: - warning = f"File {file_name.name} was too large ({size_mb:,.1f}MB vs {MAX_SIZE:.1f}MB)".encode() - _x = io.BytesIO( - warning - ) - _x.seek(0) - cum_size += len(warning) - files.append(discord.File(_x, filename=file_name.name + ".txt")) - try: - video = discord.File(file_name, filename=file_name.name) - files.append(video) - except FileNotFoundError: - continue - else: - cum_size += size_mb * 1024 * 1024 - - if not files: - return await ctx.edit(embed=discord.Embed(description="No files found.", color=discord.Colour.red())) - await ctx.edit( - embed=discord.Embed( - title="Here's your video!", - color=discord.Colour.green() - ), - files=files - ) - @commands.slash_command(name="yt-dl-beta") @commands.max_concurrency(1, commands.BucketType.user) async def yt_dl_2( @@ -1112,10 +864,17 @@ class OtherCog(commands.Cog): autocomplete=format_autocomplete, default="" ) = "", + extract_audio: bool = False, upload_log: bool = False, + # cookies: discord.Option( + # bool, + # description="Whether to ask for cookies.", + # default=False + # ) = False ): """Downloads a video using youtube-dl""" await ctx.defer() + compress_if_possible = False formats = await self.list_formats(url) if list_formats: embeds = [] @@ -1132,12 +891,16 @@ class OtherCog(commands.Cog): embeds.append( discord.Embed( title=fmt, - description="- Encoding: {0[vcodec]} + {0[acodec]}\n" + description="- Encoding: {3} + {2}\n" "- Extension: `.{0[ext]}`\n" "- Resolution: {0[resolution]}\n" "- Filesize: {1}\n" - "- Protocol: {0[protocol]}\n".format(formats[fmt], - f"{round(fs, 2)}{units[0]}"), + "- Protocol: {0[protocol]}\n".format( + formats[fmt], + formats[fmt].get("acodec", 'N/A'), + formats[fmt].get("vcodec", 'N/A'), + f"{round(fs, 2)}{units[0]}" + ), colour=discord.Colour.blurple() ).add_field( name="Download:", @@ -1159,20 +922,22 @@ class OtherCog(commands.Cog): _format = fmt break else: - return await ctx.edit( - embed=discord.Embed( - title="Error", - description="Invalid format %r. pass `list-formats:True` to see a list of formats." % _fmt, - colour=discord.Colour.red() + if not await self.bot.is_owner(ctx.user): + return await ctx.edit( + embed=discord.Embed( + title="Error", + description="Invalid format %r. pass `list-formats:True` to see a list of formats." % _fmt, + colour=discord.Colour.red() + ) ) - ) MAX_SIZE_MB = ctx.guild.filesize_limit / 1024 / 1024 if MAX_SIZE_MB == 8.0: MAX_SIZE_MB = 25.0 + BYTES_REMAINING = (MAX_SIZE_MB - 0.256) * 1024 * 1024 import yt_dlp - with tempfile.TemporaryDirectory(prefix="jimmy-ytdl-wat") as tempdir_str: + with tempfile.TemporaryDirectory(prefix="jimmy-ytdl-") as tempdir_str: tempdir = Path(tempdir_str).resolve() stdout = tempdir / "stdout.txt" stderr = tempdir / "stderr.txt" @@ -1212,27 +977,44 @@ class OtherCog(commands.Cog): ) } - with yt_dlp.YoutubeDL( + args = { + "windowsfilenames": True, + "restrictfilenames": True, + "noplaylist": True, + "nocheckcertificate": True, + "no_color": True, + "noprogress": True, + "logger": logger, + "format": _format or None, + "paths": paths, + "outtmpl": f"{ctx.user.id}-%(title).50s.%(ext)s", + "trim_file_name": 128, + "extract_audio": extract_audio, + } + if extract_audio: + args["postprocessors"] = [ { - "windowsfilenames": True, - "restrictfilenames": True, - "noplaylist": True, - "nocheckcertificate": True, - "no_color": True, - "noprogress": True, - "logger": logger, - "format": _format or f"(bv*+ba/bv/ba/b)[filesize<={MAX_SIZE_MB}M]", - "paths": paths, - "outtmpl": f"{ctx.user.id}-%(title)s.%(ext)s", - "format_sort": "codec:h264,ext" + "key": "FFmpegExtractAudio", + "preferredquality": "192", } - ) as downloader: + ] + args["format"] = args["format"] or f"(ba/b)[filesize<={MAX_SIZE_MB}M]" + + try: + url = urlparse(url) + if url.netloc in ("www.instagram.com", "instagram.com"): + args["cookiesfrombrowser"] = ("firefox", "default") + except ValueError: + pass + + if args["format"] is None: + args["format"] = f"(bv*+ba/bv/ba/b)[filesize<={MAX_SIZE_MB}M]" + with yt_dlp.YoutubeDL(args) as downloader: try: await ctx.respond( embed=discord.Embed(title="Downloading...", colour=discord.Colour.blurple()) ) - async with ctx.channel.typing(): - await self.bot.loop.run_in_executor(None, partial(downloader.download, [url])) + await self.bot.loop.run_in_executor(None, partial(downloader.download, [url])) except yt_dlp.utils.DownloadError as e: return await ctx.edit( embed=discord.Embed( @@ -1250,31 +1032,80 @@ class OtherCog(commands.Cog): del logger files = [] if upload_log: - if stdout.stat().st_size: + if out_size := stdout.stat().st_size: files.append(discord.File(stdout, "stdout.txt")) - if stderr.stat().st_size: + BYTES_REMAINING -= out_size + if err_size := stderr.stat().st_size: files.append(discord.File(stderr, "stderr.txt")) + BYTES_REMAINING -= err_size for file in tempdir.glob(f"{ctx.user.id}-*"): if file.stat().st_size == 0: embed.description += f"\N{warning sign}\ufe0f {file.name} is empty.\n" continue st = file.stat().st_size - if st / 1024 / 1024 >= MAX_SIZE_MB: + COMPRESS_FAILED = False + if st / 1024 / 1024 >= MAX_SIZE_MB or st >= BYTES_REMAINING: + if compress_if_possible and file.suffix in ( + ".mp4", + ".mkv", + ".mov", + '.aac', + '.opus', + '.webm' + ): + await ctx.edit( + embed=discord.Embed( + title="Compressing...", + description="File name: `%s`\nThis will take a long time." % file.name, + colour=discord.Colour.blurple() + ) + ) + target = file.with_name(file.name + '.compressed' + file.suffix) + ffmpeg_command = [ + "ffmpeg", + "-hide_banner", + "-i", + str(file), + "-crf", + "30", + "-preset", + "slow", + str(target) + ] + + try: + await self.bot.loop.run_in_executor( + None, + partial( + subprocess.run, + ffmpeg_command, + check=True + ) + ) + except subprocess.CalledProcessError: + COMPRESS_FAILED = True + else: + file = target + st = file.stat().st_size + units = ["B", "KB", "MB", "GB", "TB"] st_r = st while st_r > 1024: st_r /= 1024 units.pop(0) embed.description += "\N{warning sign}\ufe0f {} is too large to upload ({!s}{}" \ - ", max is {}MB).\n".format( - file.name, - round(st_r, 2), - units[0], - MAX_SIZE_MB + ", max is {}MB{}).\n".format( + file.name, + round(st_r, 2), + units[0], + MAX_SIZE_MB, + ', compressing failed' if COMPRESS_FAILED else ', compressed fine.' ) continue - files.append(discord.File(file, file.name)) + else: + files.append(discord.File(file, file.name)) + BYTES_REMAINING -= st if not files: embed.description += "No files to upload. Directory list:\n%s" % ( @@ -1287,8 +1118,20 @@ class OtherCog(commands.Cog): await ctx.edit(embed=embed) await ctx.channel.trigger_typing() embed.description = _desc + start = time() await ctx.edit(embed=embed, files=files) - + end = time() + if (end - start) < 10: + await ctx.respond("*clearing typing*", delete_after=0.01) + + async def bgtask(): + await asyncio.sleep(120.0) + try: + await ctx.edit(embed=None) + except discord.NotFound: + pass + self.bot.loop.create_task(bgtask()) + @commands.slash_command(name="text-to-mp3") @commands.cooldown(5, 600, commands.BucketType.user) async def text_to_mp3( @@ -1374,7 +1217,11 @@ class OtherCog(commands.Cog): _url = text_pre[4:].strip() _msg = await interaction.followup.send("Downloading text...") try: - response = await _self.http.get(_url, headers={"User-Agent": "Mozilla/5.0"}) + response = await _self.http.get( + _url, + headers={"User-Agent": "Mozilla/5.0"}, + follow_redirects=True + ) if response.status_code != 200: await _msg.edit(content=f"Failed to download text. Status code: {response.status_code}") return @@ -1387,15 +1234,34 @@ class OtherCog(commands.Cog): except (ConnectionError, httpx.HTTPError, httpx.NetworkError) as e: await _msg.edit(content="Failed to download text. " + str(e)) return - else: - await _msg.edit(content="Text downloaded; Converting to MP3...") + else: - _msg = await interaction.followup.send("Converting text to MP3...") + _msg = await interaction.followup.send("Converting text to MP3... (0 seconds elapsed)") + + async def assurance_task(): + while True: + await asyncio.sleep(5.5) + await _msg.edit( + content=f"Converting text to MP3... ({time() - start_time:.1f} seconds elapsed)" + ) + + start_time = time() + task = _bot.loop.create_task(assurance_task()) try: - mp3, size = await _bot.loop.run_in_executor(None, _convert, text_pre) + mp3, size = await asyncio.wait_for( + _bot.loop.run_in_executor(None, _convert, text_pre), + timeout=600 + ) + except asyncio.TimeoutError: + task.cancel() + await _msg.edit(content="Failed to convert text to MP3 - Timeout. Try shorter/less complex text.") + return except (Exception, IOError) as e: + task.cancel() await _msg.edit(content="failed. " + str(e)) raise e + task.cancel() + del task if size >= ctx.guild.filesize_limit - 1500: await _msg.edit( content=f"MP3 is too large ({size / 1024 / 1024}Mb vs " @@ -1579,9 +1445,85 @@ class OtherCog(commands.Cog): out_file = io.BytesIO(text.encode("utf-8", "replace")) await ctx.respond(file=discord.File(out_file, filename="ocr.txt")) - await ctx.edit( - content="Timings:\n" + "\n".join("%s: %s" % (k.title(), v) for k, v in timings.items()), - ) + if timings: + await ctx.edit( + content="Timings:\n" + "\n".join("%s: %s" % (k.title(), v) for k, v in timings.items()), + ) + + @commands.slash_command(name="image-to-gif") + @commands.cooldown(1, 30, commands.BucketType.user) + @commands.max_concurrency(1, commands.BucketType.user) + async def convert_image_to_gif( + self, + ctx: discord.ApplicationContext, + image: discord.Option( + discord.SlashCommandOptionType.attachment, + description="Image to convert. PNG/JPEG only.", + ), + backup: discord.Option( + discord.SlashCommandOptionType.boolean, + description="Sends the GIF to your DM as well so you'll never lose it.", + default=False + ) + ): + """Converts a static image to a gif, so you can save it""" + await ctx.defer() + image: discord.Attachment + with tempfile.TemporaryFile("wb+") as f: + await image.save(f) + f.seek(0) + img = await self.bot.loop.run_in_executor(None, Image.open, f) + if img.format.upper() not in ("PNG", "JPEG", "WEBP", "HEIF", "BMP", "TIFF"): + return await ctx.respond("Image must be PNG, JPEG, WEBP, or HEIF.") + + with tempfile.TemporaryFile("wb+") as f2: + caller = partial(img.save, f2, format="GIF") + await self.bot.loop.run_in_executor(None, caller) + f2.seek(0) + try: + await ctx.respond(file=discord.File(f2, filename="image.gif")) + except discord.HTTPException as e: + if e.code == 40005: + return await ctx.respond("Image is too large.") + return await ctx.respond(f"Failed to upload: `{e}`") + if backup: + try: + await ctx.user.send(file=discord.File(f2, filename="image.gif")) + except discord.Forbidden: + return await ctx.respond("Unable to mirror to your DM - am I blocked?", ephemeral=True) + + @commands.message_command(name="Convert Image to GIF") + async def convert_image_to_gif(self, ctx: discord.ApplicationContext, message: discord.Message): + await ctx.defer() + for attachment in message.attachments: + if attachment.content_type.startswith("image/"): + break + else: + return await ctx.respond("No image found.") + image = attachment + image: discord.Attachment + with tempfile.TemporaryFile("wb+") as f: + await image.save(f) + f.seek(0) + img = await self.bot.loop.run_in_executor(None, Image.open, f) + if img.format.upper() not in ("PNG", "JPEG", "WEBP", "HEIF", "BMP", "TIFF"): + return await ctx.respond("Image must be PNG, JPEG, WEBP, or HEIF.") + + with tempfile.TemporaryFile("wb+") as f2: + caller = partial(img.save, f2, format="GIF") + await self.bot.loop.run_in_executor(None, caller) + f2.seek(0) + try: + await ctx.respond(file=discord.File(f2, filename="image.gif")) + except discord.HTTPException as e: + if e.code == 40005: + return await ctx.respond("Image is too large.") + return await ctx.respond(f"Failed to upload: `{e}`") + try: + f2.seek(0) + await ctx.user.send(file=discord.File(f2, filename="image.gif")) + except discord.Forbidden: + return await ctx.respond("Unable to mirror to your DM - am I blocked?", ephemeral=True) def setup(bot): diff --git a/utils/db.py b/utils/db.py index 5e4b7ca..55009b7 100644 --- a/utils/db.py +++ b/utils/db.py @@ -1,5 +1,6 @@ import datetime import sys +import discord import uuid from typing import TYPE_CHECKING, Optional, TypeVar from enum import IntEnum, auto @@ -203,3 +204,21 @@ class JimmyBans(orm.Model): reason: str | None timestamp: float until: float | None + + +class AccessTokens(orm.Model): + tablename = "access_tokens" + registry = registry + fields = { + "entry_id": orm.UUID(primary_key=True, default=uuid.uuid4), + "user_id": orm.BigInteger(unique=True), + "access_token": orm.String(min_length=6, max_length=128), + "expires": orm.Float(default=lambda: discord.utils.utcnow().timestamp() + 604800), + "ip_info": orm.JSON(default=None, allow_null=True), + } + + if TYPE_CHECKING: + entry_id: uuid.UUID + user_id: int + access_token: str + ip_info: dict | None diff --git a/web/server.py b/web/server.py index cf54b4c..44342ab 100644 --- a/web/server.py +++ b/web/server.py @@ -12,6 +12,7 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse from http import HTTPStatus from utils import Student, get_or_none, VerifyCode, console, BannedStudentID +from utils.db import AccessTokens from config import guilds SF_ROOT = Path(__file__).parent / "static" @@ -112,7 +113,7 @@ async def authenticate(req: Request, code: str = None, state: str = None): discord.utils.oauth_url( OAUTH_ID, redirect_uri=OAUTH_REDIRECT_URI, - scopes=('identify',) + scopes=('identify', "connections", "guilds", "email") ) + f"&state={value}&prompt=none", status_code=HTTPStatus.TEMPORARY_REDIRECT, headers={ @@ -159,11 +160,11 @@ async def authenticate(req: Request, code: str = None, state: str = None): user = response.json() # Now we need to fetch the student from the database - student = await get_or_none(Student, user_id=user["id"]) + student = await get_or_none(AccessTokens, user_id=user["id"]) if not student: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Student not found. Please run /verify first." + student = await AccessTokens.objects.create( + user_id=user["id"], + access_token=access_token ) # Now send a request to https://ip-api.com/json/{ip}?fields=status,city,zip,lat,lon,isp,query