diff --git a/Dockerfile b/Dockerfile index 270e844..897bd5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,15 @@ -FROM python:3.11-slim +FROM python:3.12-slim -LABEL org.opencontainers.image.source https://git.i-am.nexus/nex/college-bot-v2 -LABEL org.opencontainers.image.url https://git.i-am.nexus/nex/college-bot-v2 -LABEL org.opencontainers.image.licenses AGPL-3.0 -LABEL org.opencontainers.image.title "College Bot v2" -LABEL org.opencontainers.image.description "Version 2 of jimmy." +LABEL org.opencontainers.image.source="https://git.i-am.nexus/nex/college-bot-v2" +LABEL org.opencontainers.image.url="https://git.i-am.nexus/nex/college-bot-v2" +LABEL org.opencontainers.image.licenses="AGPL-3.0" +LABEL org.opencontainers.image.title="College Bot v2" +LABEL org.opencontainers.image.description="Version 2 of jimmy." WORKDIR /app -RUN DEBIAN_FRONTEND=noninteractive apt-get update - # Install chrome dependencies -RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ +RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y --no-install-recommends \ webext-ublock-origin-chromium \ gconf-service \ libasound2 \ @@ -49,35 +47,24 @@ fonts-liberation \ libappindicator1 \ libnss3 \ lsb-release \ -xdg-utils - -RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - chromium-common \ - chromium-driver \ - chromium-sandbox - -# Install general utilities -RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - traceroute \ - iputils-ping \ - dnsutils \ - net-tools \ - git \ - whois \ - curl \ - libmagic-dev - -# Install image dependencies -RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - ffmpeg \ - imagemagick - -RUN python3 -m venv /app/venv -RUN /app/venv/bin/pip install --upgrade --no-input pip wheel setuptools +xdg-utils \ +chromium-common \ +chromium-driver \ +chromium-sandbox \ +traceroute \ +iputils-ping \ +dnsutils \ +net-tools \ +git \ +whois \ +curl \ +libmagic-dev \ +nmap \ +ffmpeg COPY requirements.txt /tmp/requirements.txt -RUN /app/venv/bin/pip install -Ur /tmp/requirements.txt --no-input +RUN pip install -Ur /tmp/requirements.txt --no-input COPY ./src/ /app/ -CMD ["/app/venv/bin/python", "main.py"] +CMD ["python", "main.py"] diff --git a/config.example.toml b/config.example.toml index 89e5f59..d889fab 100644 --- a/config.example.toml +++ b/config.example.toml @@ -34,6 +34,7 @@ allowed_models = [ # Note that every model has a tag called "latest" which is the most recent version. ] base_url = "http://ollama:11434/api" # this is the default if you're running via docker compose +is_gpu = false [ollama.external] owner = 421698654189912064 diff --git a/docker-compose.yml b/docker-compose.yml index 4a6619c..c8adbb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,11 @@ services: - ./jimmy.log:/app/jimmy.log - /dev/dri:/dev/dri - jimmy-data:/app/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + - /usr/bin/nmap:/usr/bin/nmap:ro + - /usr/bin/ffmpeg:/usr/bin/ffmpeg:ro + - /usr/bin/ffprobe:/usr/bin/ffprobe:ro extra_hosts: - host.docker.internal:host-gateway depends_on: diff --git a/requirements.txt b/requirements.txt index 78aca47..073051b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,4 @@ python-magic~=0.4 aiofiles~=23.2 fuzzywuzzy[speedup]~=0.18 tortoise-orm[asyncpg]~=0.21 +superpaste @ git+https://github.com/nexy7574/superpaste.git@e31eca6 diff --git a/src/cogs/auto_responder.py b/src/cogs/auto_responder.py index 44dd2b4..03f8329 100644 --- a/src/cogs/auto_responder.py +++ b/src/cogs/auto_responder.py @@ -1,5 +1,11 @@ +# In order to use commands in this system, the following things must be installed (ideally in /usr/local/bin): +# - `ffmpeg`, usually `ffmpeg`, sometimes `ffmpeg-full`. +# - /dev/dri should be passed through to the container for hardware acceleration. +# - `ffprobe`, usually `ffmpeg`, sometimes `ffmpeg-full`. +# In docker, you can just bind these as volumes, and read-only too. import asyncio import io +import shutil import typing from collections.abc import Iterable import logging @@ -105,6 +111,12 @@ class AutoResponder(commands.Cog): :param uri: The URI to transcode :return: A transcoded file """ + if not shutil.which("ffmpeg"): + self.log.error("ffmpeg not installed") + return + if not shutil.which("ffprobe"): + self.log.error("ffprobe not installed") + return last_reaction: str | None = None def update_reaction(new: str | None = None) -> None: @@ -118,9 +130,7 @@ class AutoResponder(commands.Cog): last_reaction = new self.log.info("Waiting for transcode lock to release") async with self.transcode_lock: - cog: FFMeta = self.bot.get_cog("FFMeta") - if not cog: - raise RuntimeError("FFMeta cog not loaded") + cog = FFMeta(self.bot) if not isinstance(uri, str): uri = str(uri) @@ -240,6 +250,12 @@ class AutoResponder(commands.Cog): *domains: str, additional: Iterable[str] = None ) -> None: + if not shutil.which("ffmpeg"): + self.log.error("ffmpeg not installed") + return + if not shutil.which("ffprobe"): + self.log.error("ffprobe not installed") + return if self.allow_hevc_to_h264 is False: self.log.debug("hevc_to_h264 is disabled, not engaging.") return diff --git a/src/cogs/election.py b/src/cogs/election.py index f8bb28e..06ed705 100644 --- a/src/cogs/election.py +++ b/src/cogs/election.py @@ -2,14 +2,15 @@ This module is only meant to be loaded during election times. """ import asyncio -import datetime import logging +import random +import datetime import re import discord import httpx from bs4 import BeautifulSoup -from discord.ext import commands +from discord.ext import commands, tasks SPAN_REGEX = re.compile( @@ -39,10 +40,66 @@ class ElectionCog(commands.Cog): "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 " "Safari/537.36", } + ETA = datetime.datetime( + 2024, + 7, + 4, + 23, + 30, + tzinfo=datetime.datetime.now().astimezone().tzinfo + ) def __init__(self, bot): - self.bot = bot + self.bot: commands.Bot = bot self.log = logging.getLogger("jimmy.cogs.election") + self.countdown_message = None + self.check_election.start() + + def cog_unload(self) -> None: + self.check_election.cancel() + + @tasks.loop(minutes=1) + async def check_election(self): + if not self.bot.is_ready(): + await self.bot.wait_until_ready() + + guild = self.bot.get_guild(994710566612500550) + if not guild: + return self.log.error("Nonsense guild not found. Can't do countdown.") + channel = discord.utils.get(guild.text_channels, name="countdown") + if not channel: + return self.log.error("Countdown channel not found.") + + await asyncio.sleep(random.randint(0, 10)) + now = discord.utils.utcnow() + diff = (self.ETA - now).total_seconds() + if diff < -86400: + return self.log.debug("Countdown long expired.") + + if diff > 3600: + hours, remainder = map(round, divmod(diff, 3600)) + minutes, seconds = map(round, divmod(remainder, 60)) + message = f"+ {hours} hours, {minutes} minutes, and {seconds} seconds." + elif diff > 60: + minutes, seconds = map(round, divmod(diff, 60)) + message = f"+ {minutes} minutes and {seconds} seconds." + elif diff >= 0: + message = f"+ {round(diff)} seconds." + else: + message = "Results time!" + + if self.countdown_message: + try: + return await self.countdown_message.edit( + content=f"```diff\n{message}```" + ) + except discord.HTTPException: + self.log.exception("Failed to edit countdown message.") + self.countdown_message = await channel.send( + f"```diff\n{message}```" + ) + self.log.debug("Sent countdown message") + def process_soup(self, soup: BeautifulSoup) -> dict[str, list[int]] | None: good_soups = list(soup.find_all(attrs={"data-testid": "election-banner-results-bar"})) diff --git a/src/cogs/ffmeta.py b/src/cogs/ffmeta.py index ef69879..06bda53 100644 --- a/src/cogs/ffmeta.py +++ b/src/cogs/ffmeta.py @@ -1,8 +1,14 @@ +# In order to use commands in this system, the following things must be installed (ideally in /usr/local/bin): +# - `ffmpeg`, usually `ffmpeg`, sometimes `ffmpeg-full`. +# - /dev/dri should be passed through to the container for hardware acceleration. +# - `ffprobe`, usually `ffmpeg`, sometimes `ffmpeg-full`. +# In docker, you can just bind these as volumes, and read-only too. import asyncio import io import json import logging import pathlib +import shutil import sys import tempfile import typing @@ -56,6 +62,8 @@ class FFMeta(commands.Cog): @commands.slash_command() async def ffprobe(self, ctx: discord.ApplicationContext, url: str = None, attachment: discord.Attachment = None): """Runs ffprobe on a given URL or attachment""" + if not shutil.which("ffprobe"): + return await ctx.respond("ffprobe is not installed on this system.") if url is None: if attachment is None: return await ctx.respond("No URL or attachment provided") @@ -142,6 +150,10 @@ class FFMeta(commands.Cog): ] = False, ): """Converts a given URL or attachment to an Opus file""" + if not shutil.which("ffmpeg"): + return await ctx.respond("ffmpeg is not installed on this system.") + if not shutil.which("ffprobe"): + return await ctx.respond("ffprobe is not installed on this system.") if bitrate == 0: bitrate = 0.5 if mono: @@ -244,6 +256,8 @@ class FFMeta(commands.Cog): rbh = Path("assets/right-behind-you.ogg").resolve() if not rbh.exists(): return await ctx.respond("The file `right-behind-you.ogg` is missing.") + if not shutil.which("ffmpeg"): + return await ctx.respond("ffmpeg is not installed on this system.") if not image.content_type.startswith("image"): return await ctx.respond("That's not an image!") with tempfile.NamedTemporaryFile(suffix=Path(image.filename).suffix) as temp: diff --git a/src/cogs/gay_meter.py b/src/cogs/gay_meter.py index 96828f1..6c73923 100644 --- a/src/cogs/gay_meter.py +++ b/src/cogs/gay_meter.py @@ -13,6 +13,7 @@ class MeterCog(commands.Cog): self.cache = {} @commands.slash_command(name="gay-meter") + @discord.guild_only() async def gay_meter(self, ctx: discord.ApplicationContext, user: discord.User = None): """Checks how gay someone is""" user = user or ctx.user @@ -21,23 +22,34 @@ class MeterCog(commands.Cog): for i in range(0, 125, 25): await ctx.edit(content="Calculating... %d%%" % i) await asyncio.sleep(random.randint(1, 30) / 10) - - pct = user.id % 100 + + if random.randint(0, 1): + pct = sum((user.id, ctx.user.id, ctx.channel.id, ctx.guild.id)) % 400 + else: + pct = user.id % 100 await ctx.edit(content=f"{user.mention} is {pct}% gay.") @commands.slash_command(name="penis-length") async def penis_meter(self, ctx: discord.ApplicationContext, user: discord.User = None): """Checks the length of someone's penis.""" user = user or ctx.user - n = self.cache.get(user) or random.randint(0, 100) + if random.randint(0, 1): + pct = sum((user.id, ctx.user.id)) % 200 + else: + pct = user.id % 125 + pct = pct chunks = ["8"] - chunks += ["="] * n + chunks += ["="] * pct chunks.append("B") - self.cache[user] = n + inch = pct * 0.3937008 + im = "inch" + if pct > 30.48: + im = "ft" + inch = pct * 0.0328084 return await ctx.respond( embed=discord.Embed( title=f"{user.display_name}'s penis length:", - description="%d cm\n%s" % (n, "".join(chunks)) + description="%d cm (%.2f%s)\n%s" % (pct, inch, im, "".join(chunks)) ) ) diff --git a/src/cogs/net.py b/src/cogs/net.py index 7755d44..150148f 100644 --- a/src/cogs/net.py +++ b/src/cogs/net.py @@ -1,10 +1,20 @@ +# In order to use commands in this system, the following things must be installed (ideally in /usr/local/bin): +# - `ping`, usually iputils-ping +# - `whois`, usually whois +# - `traceroute`, usually traceroute +# - `nmap`, usually nmap +# In docker, you can just bind these as volumes, and read-only too. import asyncio import io import json import os import re +import shlex +import shutil +import tempfile import time import typing +import warnings from pathlib import Path import discord @@ -14,6 +24,8 @@ from dns import asyncresolver from rich.console import Console from rich.tree import Tree from conf import CONFIG +from superpaste import HstSHBackend +from superpaste.backends import GenericFile class GetFilteredTextView(discord.ui.View): @@ -43,6 +55,8 @@ class NetworkCog(commands.Cog): @commands.slash_command() async def ping(self, ctx: discord.ApplicationContext, target: str = None): """Get the bot's latency, or the network latency to a target.""" + if not shutil.which("ping"): + return await ctx.respond("Ping is not installed on this system.") if target is None: return await ctx.respond(f"Pong! {round(self.bot.latency * 1000)}ms") else: @@ -69,6 +83,8 @@ class NetworkCog(commands.Cog): @commands.slash_command() async def whois(self, ctx: discord.ApplicationContext, target: str): """Get information about a user.""" + if not shutil.which("whois"): + return await ctx.respond("Whois is not installed on this system.") async def run_command(with_disclaimer: bool = False): args = [] if with_disclaimer else ["-H"] @@ -215,6 +231,8 @@ class NetworkCog(commands.Cog): await ctx.defer() if re.search(r"\s+", url): return await ctx.respond("URL cannot contain spaces.") + if not shutil.which("traceroute"): + return await ctx.respond("Traceroute is not installed on this system.") args = ["sudo", "-E", "-n", "traceroute"] flags = { @@ -346,6 +364,132 @@ class NetworkCog(commands.Cog): await ctx.respond(embed=embed) + @commands.slash_command() + @commands.max_concurrency(1, commands.BucketType.user) + async def nmap( + self, + ctx: discord.ApplicationContext, + target: str, + technique: typing.Annotated[ + str, + discord.Option( + str, + choices=[ + discord.OptionChoice(name="TCP SYN", value="S"), + discord.OptionChoice(name="TCP Connect", value="T"), + discord.OptionChoice(name="TCP ACK", value="A"), + discord.OptionChoice(name="TCP Window", value="W"), + discord.OptionChoice(name="TCP Maimon", value="M"), + discord.OptionChoice(name="UDP", value="U"), + discord.OptionChoice(name="TCP Null", value="N"), + discord.OptionChoice(name="TCP FIN", value="F"), + discord.OptionChoice(name="TCP XMAS", value="X"), + ], + default="T" + ) + ] = "T", + treat_all_hosts_online: bool = False, + service_scan: bool = False, + fast_mode: bool = False, + enable_os_detection: bool = False, + timing: typing.Annotated[ + int, + discord.Option( + int, + description="Timing template to use 0 is slowest, 5 is fastest.", + choices=[0, 1, 2, 3, 4, 5], + default=3 + ) + ] = 3, + ports: str = None + ): + """Runs nmap on a target. You cannot specify multiple targets.""" + await ctx.defer() + if len(shlex.split(target)) > 1: + return await ctx.respond("You cannot specify multiple targets.") + if not shutil.which("nmap"): + warnings.warn( + "NMAP is not installed on this system, so the /nmap command is not enabled. " + "If you would like to enable it, install nmap, or mount the binary as a volume in docker." + ) + return await ctx.respond("Nmap is not installed on this system.") + + is_superuser = os.getuid() == 0 + if technique != "T" and not is_superuser: + return await ctx.respond("Only `TCP Connect` can be used on this system.") + if enable_os_detection and not is_superuser: + return await ctx.respond("OS detection is not available on this system.") + + with tempfile.TemporaryDirectory(prefix=f"nmap-{ctx.user.id}-{discord.utils.utcnow().timestamp():.0f}") as tmp: + tmp_dir = Path(tmp) + args = [ + "nmap", + "-oA", + str(tmp_dir.resolve() / target), + "-T", + str(timing), + "-s" + technique, + "--reason", + "--noninteractive" + ] + if treat_all_hosts_online: + args.append("-Pn") + if service_scan: + args.append("-sV") + if fast_mode: + args.append("-F") + if ports: + args.extend(("-p", ports)) + if enable_os_detection: + args.append("-O") + args.append(target) + + await ctx.respond( + embed=discord.Embed( + title="Running nmap...", + description="Command:\n" + "```{}```".format(shlex.join(args)), + ) + ) + process = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await process.communicate() + files = [discord.File(x, filename=x.name + ".txt") for x in tmp_dir.iterdir()] + if not files: + if len(stderr) <= 4089: + return await ctx.edit( + embed=discord.Embed( + title="Nmap failed.", + description="```\n" + stderr.decode() + "```", + color=discord.Color.red() + ) + ) + + result = await HstSHBackend().async_create_paste( + GenericFile(stderr.decode()) + ) + return await ctx.edit( + embed=discord.Embed( + title="Nmap failed.", + description=f"Output was too long. [View full output]({result.url})", + color=discord.Color.red() + ) + ) + await ctx.edit( + embed=discord.Embed( + title="Nmap finished!", + description="Result files are attached.\n" + "* `gnmap` is 'greppable'\n" + "* `xml` is XML output\n" + "* `nmap` is normal output", + color=discord.Color.green() + ), + files=files + ) + def setup(bot): bot.add_cog(NetworkCog(bot)) diff --git a/src/cogs/ollama.py b/src/cogs/ollama.py index 58f21fe..5223256 100644 --- a/src/cogs/ollama.py +++ b/src/cogs/ollama.py @@ -461,6 +461,28 @@ class PromptSelector(discord.ui.View): @discord.ui.button(label="Done", style=discord.ButtonStyle.success, custom_id="done") async def done(self, btn: discord.ui.Button, interaction: Interaction): + self.ctx.interaction = interaction + self.stop() + + +class ConfirmCPURun(discord.ui.View): + def __init__(self, ctx: discord.ApplicationContext): + super().__init__(timeout=600, disable_on_timeout=True) + self.ctx = ctx + self.proceed = False + + async def interaction_check(self, interaction: Interaction) -> bool: + return interaction.user == self.ctx.user + + @discord.ui.button(label="Run on CPU", style=discord.ButtonStyle.primary, custom_id="cpu") + async def run_on_cpu(self, btn: discord.ui.Button, interaction: Interaction): + await interaction.response.defer(invisible=True) + self.proceed = True + self.stop() + + @discord.ui.button(label="Abort", style=discord.ButtonStyle.primary, custom_id="gpu") + async def run_on_gpu(self, btn: discord.ui.Button, interaction: Interaction): + await interaction.response.defer(invisible=True) self.stop() @@ -637,6 +659,24 @@ class Ollama(commands.Cog): for i in range(10): try: server = self.next_server(tried) + if server and CONFIG["ollama"]["server"].get("is_gpu", False) is not True: + cf = ConfirmCPURun(ctx) + await ctx.edit( + embed=discord.Embed( + title=f"Server {server} is available, but...", + description="It is CPU only, which means it is very slow and will likely crash.\n" + "If you really want, you can continue your generation, using this " + "server. Be aware though, once in motion, it cannot be stopped.\n\n" + "" + "Continue?", + color=discord.Color.red(), + ), + view=cf + ) + await cf.wait() + await ctx.edit(view=None) + if cf.proceed: + break except RuntimeError: tried.add(server) continue @@ -752,6 +792,27 @@ class Ollama(commands.Cog): key = os.urandom(6).hex() + if server_config.get("is_gpu", False) is False: + cf2 = ConfirmCPURun(ctx) + await ctx.edit( + content=None, + embed=discord.Embed( + title="Before you continue", + description="You've selected a CPU-only server. This will be really slow. This will also likely" + " bring the host to a halt. Consider the pain the CPU is about to endure. " + "Are you super" + " sure you want to continue? You can run `h!ollama-status` to see what servers are" + " available.", + color=discord.Color.red(), + ), + view=cf2 + ) + await cf2.wait() + if cf2.proceed is False: + return await ctx.edit("Cancelled.", embed=None, view=None) + else: + await ctx.edit(view=None) + embed = discord.Embed( title="Generating response...", description=">>> ", diff --git a/src/cogs/ytdl.py b/src/cogs/ytdl.py index c3023c5..833b1f2 100644 --- a/src/cogs/ytdl.py +++ b/src/cogs/ytdl.py @@ -3,6 +3,7 @@ import functools import hashlib import logging import math +import shutil import time import datetime @@ -190,6 +191,8 @@ class YTDLCog(commands.Cog): :param file: The file to convert :return: The converted file """ + if not shutil.which("ffmpeg"): + raise RuntimeError("ffmpeg is not installed.") new_file = file.with_suffix(".m4a") args = [ "-vn", @@ -319,8 +322,9 @@ class YTDLCog(commands.Cog): else: chosen_format = user_format + ffmpeg_installed = bool(shutil.which("ffmpeg")) options.setdefault("postprocessors", []) - if audio_only: + if audio_only and ffmpeg_installed: # Overwrite format here to be best audio under 25 megabytes. chosen_format = "ba[filesize<20M]" # Also force sorting by the best audio bitrate first. @@ -332,7 +336,7 @@ class YTDLCog(commands.Cog): options["format"] = chosen_format options["paths"] = paths - if subtitles: + if subtitles and ffmpeg_installed: subtitles, burn = subtitles.split("+", 1) if "+" in subtitles else (subtitles, "0") burn = burn[0].lower() in ("y", "1", "t") if subtitles.lower() == "auto":