holy shit esef still hasnt forgotten about this bot
This commit is contained in:
1 parent
4fa7d5ca50
commit
2691806453
6 files changed
+317
-96
No files matched your search
@@ -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.
|
- **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).
|
- **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.
|
- **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.
|
- **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 `<t:unix:FORMAT>` tags. accepts day numbers or day names ("Sunday"), handles month rollover, outputs all 7 Discord time formats.
|
- **timestamp** — `/timestamp` modal that generates Discord `<t:unix:FORMAT>` 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.
|
- **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.
|
||||||
|
|||||||
+14
-18
@@ -3,6 +3,7 @@ import time
|
|||||||
import json
|
import json
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import discord
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
# load aliases from json
|
# load aliases from json
|
||||||
@@ -47,37 +48,32 @@ class CurrencyConverter(commands.Cog):
|
|||||||
_RATE_CACHE[base] = (now, rates)
|
_RATE_CACHE[base] = (now, rates)
|
||||||
return rates
|
return rates
|
||||||
|
|
||||||
@commands.command(name='currency')
|
@app_commands.command(name='currency')
|
||||||
async def currency(self, ctx, amount: float = 1, from_cur: str = None, to_cur: str = None):
|
@app_commands.rename(from_cur='from', to_cur='to')
|
||||||
"""Convert *amount* from *from_cur* to *to_cur*.
|
@app_commands.describe(from_cur='Currency to convert from, e.g. usd, euro, lira',
|
||||||
If *amount* is omitted the command is parsed as ``!currency <from> <to>``.
|
to_cur='Currency to convert to',
|
||||||
"""
|
amount='How much to convert (default 1)')
|
||||||
# discord.py passes arguments left-to-right; we need to support two signatures.
|
async def currency(self, interaction: discord.Interaction, from_cur: str, to_cur: str, amount: float = 1.0):
|
||||||
# When the user supplies only two strings, *amount* will be parsed as the first string.
|
"""Convert money between currencies."""
|
||||||
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
|
|
||||||
try:
|
try:
|
||||||
from_code = self._norm(str(from_cur))
|
from_code = self._norm(from_cur)
|
||||||
to_code = self._norm(str(to_cur))
|
to_code = self._norm(to_cur)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return await ctx.reply(f"❌ {e}")
|
return await interaction.response.send_message(f"❌ {e}", ephemeral=True)
|
||||||
try:
|
try:
|
||||||
rates = await self._get_rates(from_code)
|
rates = await self._get_rates(from_code)
|
||||||
except Exception:
|
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)
|
rate = rates.get(to_code)
|
||||||
if rate is None:
|
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
|
result = amount * rate
|
||||||
embed = discord.Embed(title='Currency conversion', color=0x00ff00)
|
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='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='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.add_field(name='Rate', value=f"1 {from_code} = {rate:g} {to_code}", inline=False)
|
||||||
embed.set_footer(text='Data from frankfurter.app')
|
embed.set_footer(text='Data from frankfurter.app')
|
||||||
await ctx.reply(embed=embed)
|
await interaction.response.send_message(embed=embed)
|
||||||
|
|
||||||
async def setup(bot):
|
async def setup(bot):
|
||||||
await bot.add_cog(CurrencyConverter(bot))
|
await bot.add_cog(CurrencyConverter(bot))
|
||||||
+60
-74
@@ -1,9 +1,9 @@
|
|||||||
"""FFmpeg media conversion cog.
|
"""FFmpeg media conversion cog.
|
||||||
|
|
||||||
Commands operate on a message attachment, or the attachment of a replied-to
|
Slash commands that take an uploaded file: /togif, /caption <text>,
|
||||||
message: !togif, !caption <text>, !reverse, !speed <x>, !tomp4, !tomp3,
|
/reverse, /speed <x>, /tomp4, /tomp3, /toopus. All ffmpeg invocations use
|
||||||
!toopus. All ffmpeg invocations use argument lists (no shell) and captions
|
argument lists (no shell) and captions are passed via a textfile to avoid
|
||||||
are passed via a textfile to avoid drawtext injection.
|
drawtext injection.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
@@ -11,6 +11,7 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
MAX_UPLOAD = 50 * 1024 * 1024 # 50 MB input cap
|
MAX_UPLOAD = 50 * 1024 * 1024 # 50 MB input cap
|
||||||
@@ -29,36 +30,33 @@ class FfmpegThings(commands.Cog):
|
|||||||
def __init__(self, bot: commands.Bot) -> None:
|
def __init__(self, bot: commands.Bot) -> None:
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
|
||||||
async def _get_attachment(self, message: discord.Message):
|
async def _transcode(self, interaction: discord.Interaction, file: discord.Attachment,
|
||||||
if message.attachments:
|
args: list[str], ext: str, caption: str | None = None):
|
||||||
return message.attachments[0]
|
if file.size > MAX_UPLOAD:
|
||||||
ref = message.reference
|
await interaction.response.send_message(
|
||||||
if ref is not None:
|
f"File too big ({file.size // (1024*1024)} MB). Max {MAX_UPLOAD // (1024*1024)} MB.",
|
||||||
resolved = ref.resolved
|
ephemeral=True)
|
||||||
if isinstance(resolved, discord.Message) and resolved.attachments:
|
return
|
||||||
return resolved.attachments[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _transcode(self, ctx: commands.Context, args: list[str], ext: str):
|
# ffmpeg easily takes longer than the 3s interaction window
|
||||||
attachment = await self._get_attachment(ctx.message)
|
await interaction.response.defer(thinking=True)
|
||||||
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
|
|
||||||
|
|
||||||
tmpdir = tempfile.mkdtemp(prefix="ffmpeg_")
|
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)
|
out = os.path.join(tmpdir, "out" + ext)
|
||||||
try:
|
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)
|
await self._run_ffmpeg(["-i", inp, *args, out], tmpdir)
|
||||||
if not os.path.exists(out) or os.path.getsize(out) == 0:
|
if not os.path.exists(out) or os.path.getsize(out) == 0:
|
||||||
raise RuntimeError("ffmpeg produced no output")
|
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:
|
except Exception as exc:
|
||||||
await ctx.reply(f"ffmpeg failed: {exc}"[:2000])
|
await interaction.followup.send(f"ffmpeg failed: {exc}"[:2000])
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
@@ -77,105 +75,93 @@ class FfmpegThings(commands.Cog):
|
|||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
raise RuntimeError(stderr.decode(errors="replace")[:500])
|
raise RuntimeError(stderr.decode(errors="replace")[:500])
|
||||||
|
|
||||||
@commands.command()
|
@app_commands.command()
|
||||||
async def togif(self, ctx: commands.Context):
|
@app_commands.describe(file="Video to convert")
|
||||||
|
async def togif(self, interaction: discord.Interaction, file: discord.Attachment):
|
||||||
"""Convert a video to a GIF."""
|
"""Convert a video to a GIF."""
|
||||||
await self._transcode(
|
await self._transcode(
|
||||||
ctx,
|
interaction, file,
|
||||||
["-vf", "fps=10,scale='min(480,iw)':-2:flags=lanczos"],
|
["-vf", "fps=10,scale='min(480,iw)':-2:flags=lanczos"],
|
||||||
".gif",
|
".gif",
|
||||||
)
|
)
|
||||||
|
|
||||||
@commands.command()
|
@app_commands.command()
|
||||||
async def tomp4(self, ctx: commands.Context):
|
@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)."""
|
"""Convert media to MP4 (h264/aac)."""
|
||||||
await self._transcode(
|
await self._transcode(
|
||||||
ctx,
|
interaction, file,
|
||||||
["-c:v", "libx264", "-preset", "fast", "-crf", "23",
|
["-c:v", "libx264", "-preset", "fast", "-crf", "23",
|
||||||
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart"],
|
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart"],
|
||||||
".mp4",
|
".mp4",
|
||||||
)
|
)
|
||||||
|
|
||||||
@commands.command()
|
@app_commands.command()
|
||||||
async def tomp3(self, ctx: commands.Context):
|
@app_commands.describe(file="Video or audio to extract from")
|
||||||
|
async def tomp3(self, interaction: discord.Interaction, file: discord.Attachment):
|
||||||
"""Extract audio to MP3."""
|
"""Extract audio to MP3."""
|
||||||
await self._transcode(
|
await self._transcode(
|
||||||
ctx,
|
interaction, file,
|
||||||
["-vn", "-c:a", "libmp3lame", "-q:a", "2"],
|
["-vn", "-c:a", "libmp3lame", "-q:a", "2"],
|
||||||
".mp3",
|
".mp3",
|
||||||
)
|
)
|
||||||
|
|
||||||
@commands.command()
|
@app_commands.command()
|
||||||
async def toopus(self, ctx: commands.Context):
|
@app_commands.describe(file="Video or audio to extract from")
|
||||||
|
async def toopus(self, interaction: discord.Interaction, file: discord.Attachment):
|
||||||
"""Extract audio to Opus."""
|
"""Extract audio to Opus."""
|
||||||
await self._transcode(
|
await self._transcode(
|
||||||
ctx,
|
interaction, file,
|
||||||
["-vn", "-c:a", "libopus", "-b:a", "128k"],
|
["-vn", "-c:a", "libopus", "-b:a", "128k"],
|
||||||
".opus",
|
".opus",
|
||||||
)
|
)
|
||||||
|
|
||||||
@commands.command()
|
@app_commands.command()
|
||||||
async def reverse(self, ctx: commands.Context):
|
@app_commands.describe(file="Video to reverse")
|
||||||
|
async def reverse(self, interaction: discord.Interaction, file: discord.Attachment):
|
||||||
"""Reverse a video/audio clip."""
|
"""Reverse a video/audio clip."""
|
||||||
await self._transcode(
|
await self._transcode(
|
||||||
ctx,
|
interaction, file,
|
||||||
["-vf", "reverse", "-af", "areverse"],
|
["-vf", "reverse", "-af", "areverse"],
|
||||||
".mp4",
|
".mp4",
|
||||||
)
|
)
|
||||||
|
|
||||||
@commands.command()
|
@app_commands.command()
|
||||||
async def speed(self, ctx: commands.Context, factor: float = 1.0):
|
@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)."""
|
"""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(
|
await self._transcode(
|
||||||
ctx,
|
interaction, file,
|
||||||
["-filter_complex",
|
["-filter_complex",
|
||||||
f"[0:v]setpts=PTS/{factor}[v];[0:a]atempo={factor}[a]",
|
f"[0:v]setpts=PTS/{factor}[v];[0:a]atempo={factor}[a]",
|
||||||
"-map", "[v]", "-map", "[a]"],
|
"-map", "[v]", "-map", "[a]"],
|
||||||
".mp4",
|
".mp4",
|
||||||
)
|
)
|
||||||
|
|
||||||
@commands.command()
|
@app_commands.command()
|
||||||
async def caption(self, ctx: commands.Context, *, text: str):
|
@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."""
|
"""Add a caption to the top of a video."""
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
if not text:
|
if not text:
|
||||||
await ctx.reply("Provide a caption, e.g. `!caption hello world`.")
|
await interaction.response.send_message("Provide a caption.", ephemeral=True)
|
||||||
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).")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
font = next((f for f in FONT_CANDIDATES if os.path.exists(f)), None)
|
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 ""
|
font_arg = f":fontfile={font}" if font else ""
|
||||||
vf = (
|
vf = (
|
||||||
f"drawtext=textfile={caption_file}:fontsize=40:fontcolor=white"
|
"drawtext=textfile={caption_file}:fontsize=40:fontcolor=white"
|
||||||
f":box=1:[email protected]:boxborderw=10:x=(w-text_w)/2:y=20{font_arg}"
|
f":box=1:[email protected]:boxborderw=10:x=(w-text_w)/2:y=20{font_arg}"
|
||||||
)
|
)
|
||||||
await self._run_ffmpeg(
|
await self._transcode(
|
||||||
["-i", inp, "-vf", vf, "-c:a", "copy", out],
|
interaction, file,
|
||||||
tmpdir,
|
["-vf", vf, "-c:a", "copy"],
|
||||||
|
".mp4",
|
||||||
|
caption=text,
|
||||||
)
|
)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot: commands.Bot) -> None:
|
async def setup(bot: commands.Bot) -> None:
|
||||||
|
|||||||
+239
@@ -0,0 +1,239 @@
|
|||||||
|
"""Help cog — one place that explains everything the bot can do.
|
||||||
|
|
||||||
|
!help → overview + a dropdown to open any category
|
||||||
|
!help <category> → 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 <category>` (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 <song or link>` (`!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 <time>` — jump to a time, e.g. `!seek 1:30` or `!seek 90`\n"
|
||||||
|
"`!looptrack` — repeat the current song\n"
|
||||||
|
"`!loopqueue` — repeat the whole queue\n"
|
||||||
|
"`!autoplay` — keep playing similar songs when the queue runs out\n"
|
||||||
|
"`!remove <pos>` — remove a song from the queue\n"
|
||||||
|
"`!move <from> <to>` — move a song in the queue\n"
|
||||||
|
"`!shuffle` — shuffle the queue\n"
|
||||||
|
"`!clear` — empty the queue"
|
||||||
|
)),
|
||||||
|
"media": ("🎬", "Media tools", "Convert, reverse, speed up or caption videos", (
|
||||||
|
"Slash commands: type `/`, pick one, and upload your file in the `file` box.\n\n"
|
||||||
|
"`/togif` — turn a video into a GIF\n"
|
||||||
|
"`/tomp4` — convert any video/audio to MP4\n"
|
||||||
|
"`/tomp3` — pull the audio out as MP3\n"
|
||||||
|
"`/toopus` — pull the audio out as Opus\n"
|
||||||
|
"`/reverse` — play a clip backwards\n"
|
||||||
|
"`/speed` — speed up or slow down (`factor` 0.5 to 2.0)\n"
|
||||||
|
"`/caption` — put text on top of a video\n\n"
|
||||||
|
"Limits: files up to 50 MB, output up to 10 minutes."
|
||||||
|
)),
|
||||||
|
"utilities": ("🧰", "Utilities", "Currency, weather, timestamps, calendar, bucketlist", (
|
||||||
|
"`/currency` — convert money, e.g. from `usd` to `try`, amount `100`. "
|
||||||
|
"Understands names like `dollar`, `euro`, `lira`\n"
|
||||||
|
"`!weather [city]` — weather forecast (defaults to Kayseri)\n"
|
||||||
|
"`!time` — opens a form (day, time, country) and gives you a Discord timestamp "
|
||||||
|
"that shows up in everyone's own time zone\n"
|
||||||
|
"`!strinova` — how many people are playing Strinova on Steam right now\n\n"
|
||||||
|
"**Calendar** (members only) — events show on https://esefos.netlify.app/calendar\n"
|
||||||
|
"`!book` — book an event with a form\n"
|
||||||
|
"`!unbook` — remove an upcoming event\n\n"
|
||||||
|
"**Bucketlist** (approved users only) — shows on https://esefos.netlify.app/bucketlist\n"
|
||||||
|
"`!bucket` — add an item with a form\n"
|
||||||
|
"`!unbucket` — remove an item\n"
|
||||||
|
"`!bucketlist` — list the bucketlist commands"
|
||||||
|
)),
|
||||||
|
"fun": ("🎲", "Fun & games", "Dice, coin, 8ball, slots, ship and more", (
|
||||||
|
"`!coin` — flip a coin\n"
|
||||||
|
"`!dice` — roll a 6-sided die\n"
|
||||||
|
"`!d4` · `!d8` · `!d10` · `!d12` · `!d20` · `!d100` — roll that die\n"
|
||||||
|
"`!roll <dice>` — custom roll: `!roll 2d6`, `!roll d20` or `!roll 20`\n"
|
||||||
|
"`!rps <rock|paper|scissors>` — rock paper scissors\n"
|
||||||
|
"`!8ball <question>` — ask the magic 8 ball\n"
|
||||||
|
"`!slots` — spin the slot machine\n"
|
||||||
|
"`!roulette` — russian roulette (1 in 6)\n"
|
||||||
|
"`!ship <@user> [@user]` — love meter\n"
|
||||||
|
"`!pp` · `!rank` · `!aura` — very scientific measurements\n"
|
||||||
|
"`!yuuka` — kanpeki!\n"
|
||||||
|
"`!koharu` — shikei!\n"
|
||||||
|
"`!fun` — short list of these commands"
|
||||||
|
)),
|
||||||
|
"memes": ("📜", "Copypastas & GIFs", "Copypastas and reaction GIFs", (
|
||||||
|
"**Copypastas**\n"
|
||||||
|
"`!yuukapasta` — the Yuuka devotion pasta\n"
|
||||||
|
"`!yuyuko` — Yuyuko ASCII art\n"
|
||||||
|
"`!anlaki` — bro anlaki\n"
|
||||||
|
"`!copypasta` — list all copypastas\n\n"
|
||||||
|
"**Reaction GIFs**\n"
|
||||||
|
"{gifs}\n"
|
||||||
|
"`!gifs` — list all GIF commands"
|
||||||
|
)),
|
||||||
|
"bluearchive": ("🌸", "Blue Archive gacha", "Pull students, spark, show off your collection", (
|
||||||
|
"**Banners**\n"
|
||||||
|
"`!gacha` (`!banners`, `!g`) — current banners\n"
|
||||||
|
"`!gacha pick <n>` — choose banner #n (`!gacha pick regular` for the permanent pool)\n"
|
||||||
|
"`!gacha info` — your active banner, spark points and collection summary\n"
|
||||||
|
"`!gacha help` — gacha command list\n"
|
||||||
|
"`!gacha health` — check that everything a pull needs is working\n\n"
|
||||||
|
"**Pulling**\n"
|
||||||
|
"`!pull` — 10-pull (2★ or better guaranteed on the 10th)\n"
|
||||||
|
"`!pull single` — 1 pull\n"
|
||||||
|
"`!spark <name>` — claim a rate-up student with 200 recruitment points\n\n"
|
||||||
|
"**Collection**\n"
|
||||||
|
"`!inv` (`!collection`, `!i`) — browse your collection with buttons\n"
|
||||||
|
"`!inv @user` — look at someone else's\n"
|
||||||
|
"`!eligma` — your Eligma balance"
|
||||||
|
)),
|
||||||
|
"ai": ("🤖", "AI chat", "Talk to Yuuka (approved users only)", (
|
||||||
|
"**Ping me** (`@Yuuka ...`) to talk. I remember the last 15 messages in the channel.\n\n"
|
||||||
|
"`!activechat` — talk without pinging in this channel\n"
|
||||||
|
"`!stopchat` — turn that off again\n"
|
||||||
|
"`!setpersona <name>` — switch personality (wipes memory)\n"
|
||||||
|
"`!personas` — list personalities\n"
|
||||||
|
"`!clearchat` — wipe my memory of this channel\n"
|
||||||
|
"`!summary` — see my long-term summary of this channel\n"
|
||||||
|
"`!clearsummary` — wipe that summary\n"
|
||||||
|
"`!think` — thinking mode menu (off / high / max)\n"
|
||||||
|
"`!thinkstatus` — is thinking on here?\n"
|
||||||
|
"`!turbo` — switch between the fast and the smart model\n"
|
||||||
|
"`!current` — which model I'm using\n"
|
||||||
|
"`!temperature <0-2>` — lower = stricter, higher = more chaotic\n"
|
||||||
|
"`!aicost` — tokens used and cost so far\n"
|
||||||
|
"`!ai` — list the AI commands"
|
||||||
|
)),
|
||||||
|
"stats": ("🖥️", "PC stats", "Live stats of the machine I run on", (
|
||||||
|
"`!stats` — CPU, RAM and disk at a glance\n"
|
||||||
|
"`!fetch` — full system info (fastfetch)\n"
|
||||||
|
"`!cpu` · `!mem` · `!load` — CPU usage, memory, load average\n"
|
||||||
|
"`!temps` — temperature sensors\n"
|
||||||
|
"`!gpu` — current graphics mode\n"
|
||||||
|
"`!top` — top 5 processes by CPU\n"
|
||||||
|
"`!processes` — how many processes are running\n"
|
||||||
|
"`!net` — network traffic since boot\n"
|
||||||
|
"`!uptime` — how long the PC has been on\n"
|
||||||
|
"`!battery` — battery level\n"
|
||||||
|
"`!sysinfo` — list these commands"
|
||||||
|
)),
|
||||||
|
"automatic": ("⚙️", "Automatic stuff", "Things I do without a command", (
|
||||||
|
"**Telegram bridge** — messages in linked channels are mirrored to Telegram groups "
|
||||||
|
"and back, including replies, edits, stickers and media. "
|
||||||
|
"`!bridge` shows its status.\n\n"
|
||||||
|
"**\"my time\"** — when certain friends write *my time* next to a time, I reply "
|
||||||
|
"with a timestamp that shows in everyone's own time zone.\n\n"
|
||||||
|
"**Strinova player count** — a channel name that shows how many people are "
|
||||||
|
"playing Strinova, updated every 10 minutes.\n\n"
|
||||||
|
"**Custom roles** — in the roles channel, press *Get my role* and I'll DM you "
|
||||||
|
"to make your own colored role.\n\n"
|
||||||
|
"**Website** — the PC's live stats are shown on https://esefos.netlify.app"
|
||||||
|
)),
|
||||||
|
"admin": ("🔒", "Admin", "Owner-only commands", (
|
||||||
|
"These only work for the bot owner (`!ferox` only for one specific member).\n\n"
|
||||||
|
"`!purge <n>` — delete the last n messages\n"
|
||||||
|
"`!ban @users/@roles [reason]` — ban everyone mentioned (asks to confirm)\n"
|
||||||
|
"`!ferox <10m|2h|1d|1w|off>` — time out ferox\n"
|
||||||
|
"`!rolepanel` — re-post the custom role panel\n"
|
||||||
|
"`!gitpull` — pull the latest code\n"
|
||||||
|
"`!update` — pull the latest code and restart if anything changed\n"
|
||||||
|
"`!restart` — restart the bot\n"
|
||||||
|
"`!status` — bot service status\n"
|
||||||
|
"`!logs [n]` — last n log lines (default 40)\n"
|
||||||
|
"`!gitlog [n]` — last n commits (default 5)"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
|
||||||
|
ALIASES = {
|
||||||
|
"radio": "music", "ffmpeg": "media", "video": "media", "currency": "utilities",
|
||||||
|
"util": "utilities", "games": "fun", "gifs": "memes", "copypasta": "memes",
|
||||||
|
"gacha": "bluearchive", "ba": "bluearchive", "sysinfo": "stats", "owner": "admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HelpSelect(discord.ui.Select):
|
||||||
|
def __init__(self, cog: "Help", author_id: int) -> None:
|
||||||
|
self._cog = cog
|
||||||
|
self._author_id = author_id
|
||||||
|
super().__init__(
|
||||||
|
placeholder="Pick a category…",
|
||||||
|
options=[
|
||||||
|
discord.SelectOption(label=title, value=key, emoji=emoji, description=summary[:100])
|
||||||
|
for key, (emoji, title, summary, _) in CATEGORIES.items()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction) -> None:
|
||||||
|
embed = self._cog.page(self.values[0])
|
||||||
|
# someone else clicking gets their own private copy instead of flipping ours
|
||||||
|
if interaction.user.id != self._author_id:
|
||||||
|
return await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||||
|
await interaction.response.edit_message(embed=embed)
|
||||||
|
|
||||||
|
|
||||||
|
class HelpView(discord.ui.View):
|
||||||
|
def __init__(self, cog: "Help", author_id: int) -> None:
|
||||||
|
super().__init__(timeout=300)
|
||||||
|
self.add_item(HelpSelect(cog, author_id))
|
||||||
|
|
||||||
|
|
||||||
|
class Help(commands.Cog):
|
||||||
|
"""The !help command."""
|
||||||
|
|
||||||
|
def __init__(self, bot: commands.Bot) -> None:
|
||||||
|
self.bot = bot
|
||||||
|
|
||||||
|
def overview(self) -> discord.Embed:
|
||||||
|
embed = discord.Embed(title="What can Yuuka do?", description=INTRO, color=COLOR)
|
||||||
|
for emoji, title, summary, _ in CATEGORIES.values():
|
||||||
|
embed.add_field(name=f"{emoji} {title}", value=summary, inline=True)
|
||||||
|
return embed
|
||||||
|
|
||||||
|
def page(self, key: str) -> discord.Embed:
|
||||||
|
emoji, title, _, text = CATEGORIES[key]
|
||||||
|
if key == "memes":
|
||||||
|
gifs_cog = self.bot.get_cog("Gifs")
|
||||||
|
names = sorted(getattr(gifs_cog, "_gif_names", []))
|
||||||
|
text = text.replace("{gifs}", " · ".join(f"`!{n}`" for n in names) or "none loaded")
|
||||||
|
embed = discord.Embed(title=f"{emoji} {title}", description=text, color=COLOR)
|
||||||
|
embed.set_footer(text="!help — back to the overview")
|
||||||
|
return embed
|
||||||
|
|
||||||
|
@commands.command(name="help", aliases=["commands"])
|
||||||
|
async def help_cmd(self, ctx: commands.Context, category: str | None = None) -> None:
|
||||||
|
"""Show everything the bot can do."""
|
||||||
|
if category:
|
||||||
|
key = category.lower().replace(" ", "")
|
||||||
|
key = ALIASES.get(key, key)
|
||||||
|
if key in CATEGORIES:
|
||||||
|
return await ctx.reply(embed=self.page(key), view=HelpView(self, ctx.author.id))
|
||||||
|
await ctx.reply(f"No category called `{category}`, here's everything:")
|
||||||
|
await ctx.reply(embed=self.overview(), view=HelpView(self, ctx.author.id))
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(bot: commands.Bot) -> None:
|
||||||
|
await bot.add_cog(Help(bot))
|
||||||
@@ -55,8 +55,7 @@ class SysInfoSync:
|
|||||||
self._task.cancel()
|
self._task.cancel()
|
||||||
self._task = None
|
self._task = None
|
||||||
log.info("SysInfo → Firestore sync stopped")
|
log.info("SysInfo → Firestore sync stopped")
|
||||||
# Clean shutdown → flip the website dot to offline. Crashes can't do
|
# turns the dot on the website red
|
||||||
# this; the website also treats a stale `updatedAt` as offline.
|
|
||||||
try:
|
try:
|
||||||
await get_db().collection("sysinfo").document("server").set(
|
await get_db().collection("sysinfo").document("server").set(
|
||||||
{"online": False, "updatedAt": firestore.SERVER_TIMESTAMP}, merge=True)
|
{"online": False, "updatedAt": firestore.SERVER_TIMESTAMP}, merge=True)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ async def main():
|
|||||||
async with bot:
|
async with bot:
|
||||||
await bot.load_extension('tgbridge')
|
await bot.load_extension('tgbridge')
|
||||||
await bot.load_extension('cogs.general')
|
await bot.load_extension('cogs.general')
|
||||||
|
await bot.load_extension('cogs.help')
|
||||||
await bot.load_extension('cogs.chance')
|
await bot.load_extension('cogs.chance')
|
||||||
await bot.load_extension('cogs.gifs')
|
await bot.load_extension('cogs.gifs')
|
||||||
await bot.load_extension('cogs.copypasta')
|
await bot.load_extension('cogs.copypasta')
|
||||||
|
|||||||
Reference in new issue
Block a user