From 2691806453de4702dd797a772e851c5dc5cbe9f7 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sat, 3 Oct 2026 17:59:11 +0300 Subject: [PATCH] holy shit esef still hasnt forgotten about this bot --- README.md | 4 +- cogs/currency_converter.py | 32 ++--- cogs/ffmpeg_things.py | 144 +++++++++---------- cogs/help.py | 239 +++++++++++++++++++++++++++++++ firebase_website/sysinfo_sync.py | 3 +- main.py | 1 + 6 files changed, 322 insertions(+), 101 deletions(-) create mode 100644 cogs/help.py diff --git a/README.md b/README.md index 5fec3c4..5343e73 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ it started as a simple music bot and became a massive mess of features i sometim - **ai** — uses deepseek v4 (flash/pro). text-only (no image/vision input), continuous memory summarization (so it doesn't forget context), per-channel personas (yuuka/rem), and native deepseek-reasoner thinking toggles. doesn't break character. - **bridges** — two-way telegram <-> discord message forwarding. twitter/x leaks via rss is implemented but currently disabled (Nitter RSS instances are dead). -- **media & music** — lavalink + wavelink music playback (youtube search + http/icecast radio streams). also has ffmpeg media conversion commands (!togif, !caption, !reverse, !speed, !tomp4, !tomp3, !toopus). +- **media & music** — lavalink + wavelink music playback (youtube search + http/icecast radio streams). also has ffmpeg media conversion slash commands (/togif, /caption, /reverse, /speed, /tomp4, /tomp3, /toopus). - **sysinfo** — live monitoring of my laptop's cpu, ram, temps (lm-sensors), top processes, and fastfetch. -- **utilities** — live currency conversion, steam stat tracking (strinova player counts). +- **utilities** — live currency conversion (/currency), steam stat tracking (strinova player counts). - **calendar** — firestore-backed event booking via modal. autoconverts local times to UTC using a country list, feeds a web calendar frontend. - **timestamp** — `/timestamp` modal that generates Discord `` tags. accepts day numbers or day names ("Sunday"), handles month rollover, outputs all 7 Discord time formats. - **timestamp (friends)** — passive message listener: when a whitelisted user says "my time" in chat, the bot parses the surrounding text for time expressions, auto-converts to their timezone, and replies with timestamp tags. no commands needed. diff --git a/cogs/currency_converter.py b/cogs/currency_converter.py index bc0d18f..0d5d4f6 100644 --- a/cogs/currency_converter.py +++ b/cogs/currency_converter.py @@ -3,6 +3,7 @@ import time import json import aiohttp import discord +from discord import app_commands from discord.ext import commands # load aliases from json @@ -47,37 +48,32 @@ class CurrencyConverter(commands.Cog): _RATE_CACHE[base] = (now, rates) return rates - @commands.command(name='currency') - async def currency(self, ctx, amount: float = 1, from_cur: str = None, to_cur: str = None): - """Convert *amount* from *from_cur* to *to_cur*. - If *amount* is omitted the command is parsed as ``!currency ``. - """ - # discord.py passes arguments left-to-right; we need to support two signatures. - # When the user supplies only two strings, *amount* will be parsed as the first string. - if from_cur is None and to_cur is None: - # only two arguments were given, treat them as from/to and default amount=1 - to_cur = amount # type: ignore - from_cur = str(amount) # type: ignore - amount = 1.0 + @app_commands.command(name='currency') + @app_commands.rename(from_cur='from', to_cur='to') + @app_commands.describe(from_cur='Currency to convert from, e.g. usd, euro, lira', + to_cur='Currency to convert to', + amount='How much to convert (default 1)') + async def currency(self, interaction: discord.Interaction, from_cur: str, to_cur: str, amount: float = 1.0): + """Convert money between currencies.""" try: - from_code = self._norm(str(from_cur)) - to_code = self._norm(str(to_cur)) + from_code = self._norm(from_cur) + to_code = self._norm(to_cur) except ValueError as e: - return await ctx.reply(f"❌ {e}") + return await interaction.response.send_message(f"❌ {e}", ephemeral=True) try: rates = await self._get_rates(from_code) except Exception: - return await ctx.reply('❌ could not fetch conversion rates, try again later') + return await interaction.response.send_message('❌ could not fetch conversion rates, try again later', ephemeral=True) rate = rates.get(to_code) if rate is None: - return await ctx.reply(f"❌ unsupported conversion: {from_code} → {to_code}") + return await interaction.response.send_message(f"❌ unsupported conversion: {from_code} → {to_code}", ephemeral=True) result = amount * rate embed = discord.Embed(title='Currency conversion', color=0x00ff00) embed.add_field(name='From', value=f"{amount:g} {from_code}", inline=True) embed.add_field(name='To', value=f"{result:g} {to_code}", inline=True) embed.add_field(name='Rate', value=f"1 {from_code} = {rate:g} {to_code}", inline=False) embed.set_footer(text='Data from frankfurter.app') - await ctx.reply(embed=embed) + await interaction.response.send_message(embed=embed) async def setup(bot): await bot.add_cog(CurrencyConverter(bot)) diff --git a/cogs/ffmpeg_things.py b/cogs/ffmpeg_things.py index 34e29c2..fa11302 100644 --- a/cogs/ffmpeg_things.py +++ b/cogs/ffmpeg_things.py @@ -1,9 +1,9 @@ """FFmpeg media conversion cog. -Commands operate on a message attachment, or the attachment of a replied-to -message: !togif, !caption , !reverse, !speed , !tomp4, !tomp3, -!toopus. All ffmpeg invocations use argument lists (no shell) and captions -are passed via a textfile to avoid drawtext injection. +Slash commands that take an uploaded file: /togif, /caption , +/reverse, /speed , /tomp4, /tomp3, /toopus. All ffmpeg invocations use +argument lists (no shell) and captions are passed via a textfile to avoid +drawtext injection. """ import asyncio import os @@ -11,6 +11,7 @@ import shutil import tempfile import discord +from discord import app_commands from discord.ext import commands MAX_UPLOAD = 50 * 1024 * 1024 # 50 MB input cap @@ -29,36 +30,33 @@ class FfmpegThings(commands.Cog): def __init__(self, bot: commands.Bot) -> None: self.bot = bot - async def _get_attachment(self, message: discord.Message): - if message.attachments: - return message.attachments[0] - ref = message.reference - if ref is not None: - resolved = ref.resolved - if isinstance(resolved, discord.Message) and resolved.attachments: - return resolved.attachments[0] - return None + async def _transcode(self, interaction: discord.Interaction, file: discord.Attachment, + args: list[str], ext: str, caption: str | None = None): + if file.size > MAX_UPLOAD: + await interaction.response.send_message( + f"File too big ({file.size // (1024*1024)} MB). Max {MAX_UPLOAD // (1024*1024)} MB.", + ephemeral=True) + return - async def _transcode(self, ctx: commands.Context, args: list[str], ext: str): - attachment = await self._get_attachment(ctx.message) - if attachment is None: - await ctx.reply("Attach a file, or reply to a message that has one.") - return - if attachment.size > MAX_UPLOAD: - await ctx.reply(f"File too big ({attachment.size // (1024*1024)} MB). Max {MAX_UPLOAD // (1024*1024)} MB.") - return + # ffmpeg easily takes longer than the 3s interaction window + await interaction.response.defer(thinking=True) tmpdir = tempfile.mkdtemp(prefix="ffmpeg_") - inp = os.path.join(tmpdir, "in" + os.path.splitext(attachment.filename)[1]) + inp = os.path.join(tmpdir, "in" + os.path.splitext(file.filename)[1]) out = os.path.join(tmpdir, "out" + ext) try: - await attachment.save(inp) + await file.save(inp) + if caption is not None: + caption_file = os.path.join(tmpdir, "caption.txt") + with open(caption_file, "w", encoding="utf-8") as f: + f.write(caption) + args = [a.replace("{caption_file}", caption_file) for a in args] await self._run_ffmpeg(["-i", inp, *args, out], tmpdir) if not os.path.exists(out) or os.path.getsize(out) == 0: raise RuntimeError("ffmpeg produced no output") - await ctx.reply(file=discord.File(out)) + await interaction.followup.send(file=discord.File(out)) except Exception as exc: - await ctx.reply(f"ffmpeg failed: {exc}"[:2000]) + await interaction.followup.send(f"ffmpeg failed: {exc}"[:2000]) finally: shutil.rmtree(tmpdir, ignore_errors=True) @@ -77,105 +75,93 @@ class FfmpegThings(commands.Cog): if proc.returncode != 0: raise RuntimeError(stderr.decode(errors="replace")[:500]) - @commands.command() - async def togif(self, ctx: commands.Context): + @app_commands.command() + @app_commands.describe(file="Video to convert") + async def togif(self, interaction: discord.Interaction, file: discord.Attachment): """Convert a video to a GIF.""" await self._transcode( - ctx, + interaction, file, ["-vf", "fps=10,scale='min(480,iw)':-2:flags=lanczos"], ".gif", ) - @commands.command() - async def tomp4(self, ctx: commands.Context): + @app_commands.command() + @app_commands.describe(file="Video or audio to convert") + async def tomp4(self, interaction: discord.Interaction, file: discord.Attachment): """Convert media to MP4 (h264/aac).""" await self._transcode( - ctx, + interaction, file, ["-c:v", "libx264", "-preset", "fast", "-crf", "23", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart"], ".mp4", ) - @commands.command() - async def tomp3(self, ctx: commands.Context): + @app_commands.command() + @app_commands.describe(file="Video or audio to extract from") + async def tomp3(self, interaction: discord.Interaction, file: discord.Attachment): """Extract audio to MP3.""" await self._transcode( - ctx, + interaction, file, ["-vn", "-c:a", "libmp3lame", "-q:a", "2"], ".mp3", ) - @commands.command() - async def toopus(self, ctx: commands.Context): + @app_commands.command() + @app_commands.describe(file="Video or audio to extract from") + async def toopus(self, interaction: discord.Interaction, file: discord.Attachment): """Extract audio to Opus.""" await self._transcode( - ctx, + interaction, file, ["-vn", "-c:a", "libopus", "-b:a", "128k"], ".opus", ) - @commands.command() - async def reverse(self, ctx: commands.Context): + @app_commands.command() + @app_commands.describe(file="Video to reverse") + async def reverse(self, interaction: discord.Interaction, file: discord.Attachment): """Reverse a video/audio clip.""" await self._transcode( - ctx, + interaction, file, ["-vf", "reverse", "-af", "areverse"], ".mp4", ) - @commands.command() - async def speed(self, ctx: commands.Context, factor: float = 1.0): + @app_commands.command() + @app_commands.describe(file="Video to speed up or slow down", + factor="Playback speed, 0.5 to 2.0") + async def speed(self, interaction: discord.Interaction, file: discord.Attachment, + factor: app_commands.Range[float, 0.5, 2.0] = 1.0): """Change playback speed (0.5 to 2.0).""" - if not 0.5 <= factor <= 2.0: - await ctx.reply("Speed must be between 0.5 and 2.0.") - return await self._transcode( - ctx, + interaction, file, ["-filter_complex", f"[0:v]setpts=PTS/{factor}[v];[0:a]atempo={factor}[a]", "-map", "[v]", "-map", "[a]"], ".mp4", ) - @commands.command() - async def caption(self, ctx: commands.Context, *, text: str): + @app_commands.command() + @app_commands.describe(file="Video to caption", text="Text to put on top") + async def caption(self, interaction: discord.Interaction, file: discord.Attachment, + text: str): """Add a caption to the top of a video.""" text = text.strip() if not text: - await ctx.reply("Provide a caption, e.g. `!caption hello world`.") - return - - attachment = await self._get_attachment(ctx.message) - if attachment is None: - await ctx.reply("Attach a file, or reply to a message that has one.") - return - if attachment.size > MAX_UPLOAD: - await ctx.reply(f"File too big ({attachment.size // (1024*1024)} MB).") + await interaction.response.send_message("Provide a caption.", ephemeral=True) return font = next((f for f in FONT_CANDIDATES if os.path.exists(f)), None) - tmpdir = tempfile.mkdtemp(prefix="ffmpeg_cap_") - inp = os.path.join(tmpdir, "in" + os.path.splitext(attachment.filename)[1]) - caption_file = os.path.join(tmpdir, "caption.txt") - out = os.path.join(tmpdir, "out.mp4") - try: - await attachment.save(inp) - with open(caption_file, "w", encoding="utf-8") as f: - f.write(text) - font_arg = f":fontfile={font}" if font else "" - vf = ( - f"drawtext=textfile={caption_file}:fontsize=40:fontcolor=white" - f":box=1:boxcolor=black@0.5:boxborderw=10:x=(w-text_w)/2:y=20{font_arg}" - ) - await self._run_ffmpeg( - ["-i", inp, "-vf", vf, "-c:a", "copy", out], - tmpdir, - ) - await ctx.reply(file=discord.File(out)) - except Exception as exc: - await ctx.reply(f"ffmpeg failed: {exc}"[:2000]) - finally: - shutil.rmtree(tmpdir, ignore_errors=True) + font_arg = f":fontfile={font}" if font else "" + vf = ( + "drawtext=textfile={caption_file}:fontsize=40:fontcolor=white" + f":box=1:boxcolor=black@0.5:boxborderw=10:x=(w-text_w)/2:y=20{font_arg}" + ) + await self._transcode( + interaction, file, + ["-vf", vf, "-c:a", "copy"], + ".mp4", + caption=text, + ) async def setup(bot: commands.Bot) -> None: diff --git a/cogs/help.py b/cogs/help.py new file mode 100644 index 0000000..a1a3e18 --- /dev/null +++ b/cogs/help.py @@ -0,0 +1,239 @@ +"""Help cog — one place that explains everything the bot can do. + +!help → overview + a dropdown to open any category +!help → jump straight to a category, e.g. !help music + +The text lives in CATEGORIES below. GIF commands are generated from the +gifs/ folder, so that list is read from the Gifs cog at runtime. +""" +###################################################################### +import discord +from discord.ext import commands + +COLOR = 0x5BA0D0 + +INTRO = ( + "Hi, I'm Yuuka! I play music and radio, convert videos, convert money, " + "run a Blue Archive gacha, chat with AI, and a bunch of small fun stuff.\n\n" + "Most commands start with `!`. The media tools and `/currency` are " + "slash commands: type `/` and pick them from the list.\n\n" + "Pick a category below, or type `!help ` (e.g. `!help music`). `!commands` works too." +) + +# key: (emoji, title, one-line summary, full page text) +CATEGORIES: dict[str, tuple[str, str, str, str]] = { + "music": ("🎵", "Music & Radio", "Play songs from YouTube or live radio in voice", ( + "Join a voice channel first.\n\n" + "**Anyone can use**\n" + "`!play ` (`!p`) — search YouTube or paste a video/playlist link. " + "Example: `!play blue archive ost`\n" + "`!radio` — pick a radio station from a menu (category, then station)\n" + "`!nowplaying` (`!np`) — what's playing right now\n" + "`!queue` (`!q`) — what's coming up next\n" + "`!history` (`!h`) — recently played songs\n" + "`!music` — short list of the music commands\n\n" + "**Needs the DJ role**\n" + "`!pause` · `!resume` · `!skip`\n" + "`!stop` (`!leave`, `!disconnect`) — stop music or radio, clear the queue and leave\n" + "`!seek