├── frontroomsbot ├── __init__.py ├── consts.py ├── cogs │ ├── message_utils.py │ ├── pin_squash.py │ ├── devtools.py │ ├── misc.py │ ├── random_utils.py │ ├── config_cog.py │ ├── avatar_emoji.py │ ├── image_gen.py │ ├── superkauf.py │ ├── reaction_utils.py │ ├── utils │ │ └── bookmarks.py │ ├── _config.py │ ├── llm.py │ ├── tldr.py │ ├── imitation.py │ └── beer_tracker.py └── bot.py ├── .gitignore ├── Dockerfile ├── README.md ├── pyproject.toml ├── .github └── workflows │ ├── ci.yml │ └── deploy.yml └── ansible └── playbook.yaml /frontroomsbot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | .idea 4 | .env 5 | __pycache__ 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | ENV PYTHONDONTWRITEBYTECODE 1 5 | 6 | WORKDIR /app 7 | 8 | RUN pip install poetry 9 | RUN poetry config virtualenvs.create false 10 | RUN adduser --shell /bin/bash bot 11 | 12 | COPY . /app/ 13 | 14 | RUN poetry install 15 | 16 | USER bot 17 | 18 | WORKDIR /app/frontroomsbot 19 | 20 | CMD ["python", "bot.py"] 21 | -------------------------------------------------------------------------------- /frontroomsbot/consts.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv() 5 | TOKEN = os.getenv("DISCORD_TOKEN") 6 | GUILD = os.getenv("GUILD_ID") 7 | HF_TOKEN = os.getenv("HF_TOKEN") 8 | GEMINI_TOKEN = os.getenv("GEMINI_TOKEN") 9 | GROQ_TOKEN = os.getenv("GROQ_TOKEN") 10 | DB_CONN = os.getenv("DB_CONN") 11 | ERROR_WH = os.getenv("ERROR_WH") 12 | PANTRY_GUILD = os.getenv("PANTRY_GUILD") 13 | 14 | COGS_DIR = "cogs" 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Frontrooms bot 2 | 3 | ## Install 4 | 5 | `poetry install` 6 | 7 | In `.env` add `DISCORD_TOKEN`, which is your bot discord token, and `GUILD_ID`, which is your server id, and `HF_TOKEN`, which is huggingface token (https://huggingface.co/settings/tokens), and `GEMINI_TOKEN`, which is Google Gemini LLM API token (https://makersuite.google.com/app/apikey). 8 | 9 | Before push run `poetry run ruff .` and `poetry run black .`. 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "frontroomsbot" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["S1ro1 "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.11" 10 | ruff = "^0.1.8" 11 | black = "^23.12.0" 12 | discord-py = "^2.3.2" 13 | sqlalchemy = "^2.0.23" 14 | python-dotenv = "^1.0.0" 15 | httpx = {extras = ["socks"], version = "^0.26.0"} 16 | pytz = "^2023.3.post1" 17 | toml = "^0.10.2" 18 | motor = "^3.3.2" 19 | websockets = "^12.0" 20 | pillow = "^10.4.0" 21 | httpx-ws = "^0.6.0" 22 | google-generativeai = "^0.8.2" 23 | 24 | [build-system] 25 | requires = ["poetry-core"] 26 | build-backend = "poetry.core.masonry.api" 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Python CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - name: Set up Python 13 | uses: actions/setup-python@v2 14 | with: 15 | python-version: '3.11' 16 | 17 | - name: Install Poetry 18 | run: | 19 | python -m pip install -U pip poetry 20 | poetry check --no-interaction 21 | poetry config virtualenvs.in-project true 22 | poetry install --no-interaction 23 | 24 | - name: Run Black Formatter 25 | run: poetry run black --check . 26 | 27 | - name: Run Ruff Linter 28 | run: poetry run ruff . -------------------------------------------------------------------------------- /ansible/playbook.yaml: -------------------------------------------------------------------------------- 1 | - name: Deploy new container to the server 2 | hosts: all 3 | tasks: 4 | - name: Login to ghcr.io 5 | docker_login: 6 | url: ghcr.io 7 | username: '{{ username }}' 8 | password: '{{ password }}' 9 | 10 | - name: Ensure a container is running 11 | docker_container: 12 | name: bot 13 | state: started 14 | platform: linux/arm64/v8 15 | image: 'ghcr.io/{{ username }}/bot:latest' 16 | env: 17 | DISCORD_TOKEN: '{{ DISCORD_TOKEN }}' 18 | GUILD_ID: '{{ GUILD }}' 19 | PANTRY_GUILD: '{{ PANTRY_GUILD }}' 20 | HF_TOKEN: '{{ HF_TOKEN }}' 21 | ERROR_WH: '{{ ERROR_WH }}' 22 | GEMINI_TOKEN: '{{ GEMINI_TOKEN }}' 23 | GROQ_TOKEN: '{{ GROQ_TOKEN }}' 24 | DB_CONN: mongodb://172.17.0.1 25 | pull: true 26 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/message_utils.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | from discord import app_commands 4 | 5 | from bot import BackroomsBot 6 | 7 | 8 | class StringUtilsCog(commands.Cog): 9 | def __init__(self, bot: BackroomsBot) -> None: 10 | self.bot = bot 11 | 12 | @app_commands.command(name="mock", description="Mocks a message") 13 | async def mock(self, interaction: discord.Interaction, message: str): 14 | result = "" 15 | alpha_cnt = 0 16 | for c in message: 17 | new_c = c 18 | if c.isalpha(): 19 | new_c = c.upper() if alpha_cnt % 2 == 0 else c.lower() 20 | alpha_cnt += 1 21 | 22 | result += new_c 23 | 24 | await interaction.response.send_message(f"{result}") 25 | 26 | 27 | async def setup(bot: BackroomsBot) -> None: 28 | await bot.add_cog(StringUtilsCog(bot), guild=bot.backrooms) 29 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/pin_squash.py: -------------------------------------------------------------------------------- 1 | from bot import BackroomsBot 2 | from discord import app_commands 3 | import discord 4 | from ._config import ConfigCog 5 | 6 | 7 | class PinSquashCog(ConfigCog): 8 | def __init__(self, bot: BackroomsBot) -> None: 9 | super().__init__(bot) 10 | 11 | @app_commands.command(name="pin_squash", description="Squashes pins") 12 | async def pin_squash(self, interaction: discord.Interaction): 13 | channel = interaction.channel 14 | pins = await channel.pins() 15 | 16 | # Create a list to store links to pinned messages 17 | pin_links = [] 18 | 19 | # Unpin all messages and collect their links 20 | for pin in pins: 21 | await pin.unpin() 22 | pin_links.append(f"[Message]({pin.jump_url})") 23 | 24 | if pin_links: 25 | chunks = [] 26 | current_chunk = "Previously pinned messages:\n" 27 | 28 | for link in pin_links: 29 | if len(current_chunk) + len(link) + 1 > 2000: 30 | chunks.append(current_chunk) 31 | current_chunk = link + "\n" 32 | else: 33 | current_chunk += link + "\n" 34 | 35 | if current_chunk: 36 | chunks.append(current_chunk) 37 | 38 | for i, chunk in enumerate(chunks): 39 | new_pin = await channel.send(chunk) 40 | if i == 0: 41 | await new_pin.pin() 42 | else: 43 | await interaction.response.send_message("No pins found to squash.") 44 | 45 | 46 | async def setup(bot: BackroomsBot) -> None: 47 | await bot.add_cog(PinSquashCog(bot), guild=bot.backrooms) 48 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/devtools.py: -------------------------------------------------------------------------------- 1 | from bot import BackroomsBot 2 | from discord.ext import commands 3 | from consts import ERROR_WH 4 | import httpx 5 | from pathlib import Path 6 | import time 7 | 8 | # strip filename, leave cogs/, then frontroomsbot/ 9 | DOT_GIT = Path(__file__).parent.parent.parent / ".git" 10 | 11 | 12 | class DevTools(commands.Cog): 13 | def __init__(self, bot: BackroomsBot) -> None: 14 | self.bot = bot 15 | 16 | def doctor(self): 17 | """ 18 | Run various self-checks to find common problems with the bot deployment 19 | """ 20 | guilds = list(self.bot.guilds) 21 | assert ( 22 | guilds[0].id == self.bot.backrooms.id 23 | ), f"The configured GUILD_ID is not the guild the bot is in, {self.bot.backrooms.id} vs {guilds[0].id}" 24 | assert len(guilds) == 1, "The bot is in multiple guilds" 25 | 26 | bad_commands = [] 27 | for cmd in self.bot.tree.walk_commands(): 28 | if not cmd.guild_only: 29 | bad_commands.append(cmd) 30 | if bad_commands: 31 | raise RuntimeError( 32 | f"Found commands that aren't guild only: {bad_commands}, these will not sync via /sync" 33 | ) 34 | print("self check ok") 35 | 36 | @commands.Cog.listener() 37 | async def on_ready(self): 38 | print("bot ready, running self check") 39 | self.doctor() 40 | try: 41 | git_revision = (DOT_GIT / "refs/heads/master").read_text() 42 | except IOError: 43 | git_revision = "git revision not found" 44 | data = { 45 | "content": f"{self.bot.user} is up at using git: `{git_revision.strip()}`.", 46 | "allowed_mentions": {"parse": []}, 47 | } 48 | async with httpx.AsyncClient() as cl: 49 | await cl.post(ERROR_WH, json=data) 50 | 51 | 52 | async def setup(bot): 53 | await bot.add_cog(DevTools(bot), guild=bot.backrooms) 54 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy server 2 | permissions: 3 | packages: write 4 | 5 | on: 6 | workflow_dispatch: 7 | push: 8 | branches: 9 | - 'master' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v3 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v3 22 | 23 | - name: Login to Github Container Registry 24 | uses: docker/login-action@v3 25 | with: 26 | registry: ghcr.io 27 | username: ${{ github.repository_owner }} 28 | password: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Build and push 31 | uses: docker/build-push-action@v5 32 | with: 33 | context: . 34 | file: ./Dockerfile 35 | push: true 36 | platforms: linux/arm64/v8 37 | tags: | 38 | ghcr.io/backroomsorg/bot:latest 39 | cache-from: type=gha 40 | cache-to: type=gha,mode=max 41 | deploy: 42 | needs: build 43 | runs-on: ubuntu-latest 44 | environment: production 45 | 46 | steps: 47 | - name: Checkout code 48 | uses: actions/checkout@v4 49 | 50 | - name: Run playbook 51 | uses: dawidd6/action-ansible-playbook@v2 52 | with: 53 | playbook: playbook.yaml 54 | 55 | directory: ./ansible 56 | 57 | key: ${{secrets.SSH_PRIVATE_KEY}} 58 | 59 | options: | 60 | --inventory ${{vars.BOT_HOST}}, 61 | --extra-vars username=backroomsorg 62 | --extra-vars password=${{secrets.GITHUB_TOKEN}} 63 | --extra-vars DISCORD_TOKEN=${{secrets.BOT_TOKEN}} 64 | --extra-vars GUILD=${{vars.GUILD_ID}} 65 | --extra-vars PANTRY_GUILD=${{vars.PANTRY_GUILD}} 66 | --extra-vars HF_TOKEN=${{secrets.HF_TOKEN}} 67 | --extra-vars ERROR_WH=${{secrets.ERROR_WH}} 68 | --extra-vars GEMINI_TOKEN=${{secrets.GEMINI_TOKEN}} 69 | --extra-vars GROQ_TOKEN=${{secrets.GROQ_TOKEN}} 70 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/misc.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | from discord import app_commands 4 | import httpx 5 | 6 | from bot import BackroomsBot 7 | 8 | 9 | class MiscellaneousCog(commands.Cog): 10 | def __init__(self, bot: BackroomsBot) -> None: 11 | self.bot = bot 12 | 13 | @app_commands.command(name="increment", description="Makes a number go up") 14 | async def increment(self, interaction: discord.Interaction): 15 | db = self.bot.db 16 | fld = await db.counting.find_one({"count": {"$exists": True}}) or {"count": 0} 17 | nfld = fld.copy() 18 | nfld["count"] += 1 19 | await db.counting.replace_one(fld, nfld, upsert=True) 20 | await interaction.response.send_message(str(nfld["count"])) 21 | 22 | @app_commands.command(name="sync", description="Syncs commands") 23 | async def sync(self, interaction: discord.Interaction): 24 | print("Syncing commands") 25 | ret = await self.bot.tree.sync(guild=self.bot.backrooms) 26 | print(ret) 27 | await interaction.response.send_message("Synced!") 28 | print("Command tree synced") 29 | 30 | @app_commands.command(name="nameday", description="Whose name day is it today?") 31 | @app_commands.describe(date="Date to get name day for in format YYYY-MM-DD") 32 | async def nameday(self, interaction: discord.Interaction, date: str | None = None): 33 | uri = "https://svatkyapi.cz/api/day" 34 | if date is not None: 35 | uri += f"/{date}" 36 | 37 | async with httpx.AsyncClient() as ac: 38 | response = await ac.get(uri) 39 | 40 | if response.status_code == 200: 41 | json = response.json() 42 | name = json["name"] 43 | date_str = f"Dne {date}" if date is not None else "Dnes" 44 | 45 | await interaction.response.send_message(f"{date_str} má svátek {name}") 46 | else: 47 | print(response) 48 | print("Nameday failed") 49 | 50 | 51 | async def setup(bot: BackroomsBot) -> None: 52 | await bot.add_cog(MiscellaneousCog(bot), guild=bot.backrooms) 53 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/random_utils.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord import app_commands 3 | from discord.ext import commands 4 | from random import choices, randint, uniform 5 | 6 | from bot import BackroomsBot 7 | 8 | 9 | class RandomUtilsCog(commands.Cog): 10 | def __init__(self, bot: BackroomsBot) -> None: 11 | self.bot = bot 12 | 13 | @app_commands.command(name="roll", description="Rolls a number") 14 | async def roll( 15 | self, interaction: discord.Interaction, first: int = 100, second: int = None 16 | ): 17 | if second is None: 18 | result = randint(0, first) 19 | 20 | else: 21 | if second < first: 22 | await interaction.response.send_message( 23 | "Second needs to be higher than first." 24 | ) 25 | return 26 | result = randint(first, second) 27 | 28 | await interaction.response.send_message(f"{result}") 29 | 30 | @app_commands.command(name="flip", description="Flips a coin") 31 | async def flip(self, interaction: discord.Interaction): 32 | # randint(0, 1) ? "True" : "False" <- same thing 33 | result = "True" if randint(0, 1) else "False" 34 | await interaction.response.send_message(f"{result}") 35 | 36 | @app_commands.checks.cooldown(1, 60.0) 37 | @app_commands.command(name="kasparek", description="Zjistí jakého máš kašpárka") 38 | async def kasparek(self, interaction: discord.Interaction): 39 | unit = choices(["cm", "mm"], weights=(95, 5), k=1)[0] 40 | result = round(uniform(0, 50), 2) 41 | 42 | message = f"{result}{unit}" if unit else f"{result}" 43 | await interaction.response.send_message(message) 44 | 45 | @kasparek.error 46 | async def on_kasparek_error( 47 | self, interaction: discord.Interaction, error: app_commands.AppCommandError 48 | ): 49 | if isinstance(error, app_commands.CommandOnCooldown): 50 | await interaction.response.send_message(str(error), ephemeral=True) 51 | 52 | 53 | async def setup(bot: BackroomsBot) -> None: 54 | await bot.add_cog(RandomUtilsCog(bot), guild=bot.backrooms) 55 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/config_cog.py: -------------------------------------------------------------------------------- 1 | from bot import BackroomsBot 2 | from discord.ext import commands 3 | from discord import app_commands, Interaction 4 | from ._config import ConfigCog, clear_cache, gen_modal 5 | 6 | 7 | class ConfigCommands(commands.Cog): 8 | def __init__(self, bot: BackroomsBot) -> None: 9 | self.bot = bot 10 | 11 | async def cog_autocomplete(self, interaction: Interaction, current: str): 12 | cogs = ConfigCog.__subclasses__() 13 | return [ 14 | app_commands.Choice(name=cog.key, value=cog.key) 15 | for cog in cogs 16 | if current in cog.key 17 | ] + [app_commands.Choice(name="purge-cache", value="purge-cache")] 18 | 19 | @app_commands.command(name="config", description="Configure a specific cog") 20 | @app_commands.checks.has_permissions(moderate_members=True) 21 | @app_commands.autocomplete(cog_module=cog_autocomplete) 22 | async def get(self, interaction: Interaction, cog_module: str): 23 | """/config""" 24 | if cog_module == "purge-cache": 25 | clear_cache() 26 | await interaction.response.send_message("Cache purged", ephemeral=True) 27 | return 28 | cogs = ConfigCog.__subclasses__() 29 | try: 30 | cog = next(cog for cog in cogs if cog.key == cog_module) 31 | except StopIteration: 32 | await interaction.response.send_message( 33 | "No such configurable cog.", ephemeral=True 34 | ) 35 | else: 36 | cog_instance = self.bot.get_cog(cog.__cog_name__) 37 | if not cog_instance: 38 | await interaction.response.send_message( 39 | "That cog is not loaded", ephemeral=True 40 | ) 41 | assert isinstance( 42 | cog_instance, ConfigCog 43 | ), "Found a non-configurable cog, perhaps two cogs live in the same module" 44 | await interaction.response.send_modal( 45 | await gen_modal(cog_module, cog.options, cog_instance) 46 | ) 47 | 48 | 49 | async def setup(bot): 50 | clear_cache() 51 | await bot.add_cog(ConfigCommands(bot), guild=bot.backrooms) 52 | -------------------------------------------------------------------------------- /frontroomsbot/bot.py: -------------------------------------------------------------------------------- 1 | import os 2 | import discord 3 | from traceback import print_exc 4 | from io import StringIO 5 | import httpx 6 | 7 | from discord.ext import commands 8 | import motor.motor_asyncio as ma 9 | 10 | from consts import TOKEN, GUILD, DB_CONN, COGS_DIR, ERROR_WH, PANTRY_GUILD 11 | 12 | intents = discord.Intents.default() 13 | intents.message_content = True 14 | intents.reactions = True 15 | 16 | # simple object only with id attribute used on cogs setup 17 | backrooms = discord.Object(id=GUILD) 18 | 19 | 20 | class BackroomsBot(commands.Bot): 21 | def __init__(self, *args, **kwargs): 22 | super().__init__(*args, **kwargs) 23 | db_client = ma.AsyncIOMotorClient(DB_CONN) 24 | self.db = db_client.bot_database 25 | self.backrooms = backrooms 26 | self.pantry_id = int(PANTRY_GUILD) 27 | 28 | async def setup_hook(self): 29 | # loads all cogs 30 | for filename in os.listdir(COGS_DIR): 31 | if filename.endswith("py") and not filename.startswith("_"): 32 | await self.load_extension( 33 | f"{'.'.join(COGS_DIR.split('/'))}.{filename[:-3]}" 34 | ) 35 | 36 | # syncs the command tree with the backrooms guild 37 | await self.tree.sync(guild=self.backrooms) 38 | 39 | async def on_error(self, event: str, *args, **kwargs): 40 | content = StringIO() 41 | print_exc(file=content) 42 | print(content.getvalue()) 43 | data = { 44 | "content": f"Bot ran into an error in event {event!r} with \n`{args=!r}`\n`{kwargs=!r}`", 45 | "allowed_mentions": {"parse": []}, 46 | "embeds": [ 47 | { 48 | "title": "Traceback", 49 | "description": "```" + content.getvalue()[-3950:] + "```", 50 | } 51 | ], 52 | } 53 | async with httpx.AsyncClient() as cl: 54 | # use a webhook instead of the discord connection in 55 | # case the error is caused by being disconnected from discord 56 | # also prevents error reporting from breaking API limits 57 | await cl.post(ERROR_WH, json=data) 58 | 59 | 60 | client = BackroomsBot(command_prefix="!", intents=intents) 61 | 62 | if __name__ == "__main__": 63 | client.run(TOKEN) 64 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/avatar_emoji.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord import app_commands 3 | import tempfile 4 | from PIL import Image 5 | import httpx 6 | 7 | from bot import BackroomsBot 8 | from ._config import ConfigCog, Cfg 9 | 10 | 11 | class AvatarEmojiCog(ConfigCog): 12 | backrooms_channel_id = Cfg(int) 13 | 14 | def __init__(self, bot: BackroomsBot) -> None: 15 | super().__init__(bot) 16 | 17 | self.bot = bot 18 | 19 | @app_commands.command( 20 | name="reload_avatars", 21 | description="Force avatars emojis to be reloaded in pantry", 22 | ) 23 | async def reload_avatars(self, interaction: discord.Interaction): 24 | # create an emoji for each member in the backrooms channel 25 | backrooms_channel = self.bot.get_channel(await self.backrooms_channel_id) 26 | for member in backrooms_channel.members: 27 | await self.create_avatar_emoji_in_pantry( 28 | member.id, member.display_avatar.url 29 | ) 30 | await interaction.response.send_message("Avatars reloaded", ephemeral=True) 31 | 32 | async def create_avatar_emoji_in_pantry( 33 | self, member_id: int, avatar_url: str 34 | ) -> discord.Emoji: 35 | # delete the emoji if it already exists 36 | pantry = self.bot.get_guild(self.bot.pantry_id) 37 | for em in pantry.emojis: 38 | if em.name == str(member_id): 39 | await em.delete() 40 | 41 | with tempfile.TemporaryDirectory() as tempdir: 42 | # download the avatar 43 | async with httpx.AsyncClient() as client: 44 | response = await client.get(avatar_url, timeout=10) 45 | image_path = f"{tempdir}/{member_id}.png" 46 | with open(image_path, "wb") as f: 47 | f.write(response.content) 48 | # resize the avatar 49 | image = Image.open(image_path) 50 | image = image.resize((128, 128)) 51 | image.save(image_path) 52 | # upload the avatar as a new emoji to the pantry 53 | return await pantry.create_custom_emoji( 54 | name=member_id, image=open(image_path, "rb").read() 55 | ) 56 | 57 | 58 | async def setup(bot: BackroomsBot) -> None: 59 | await bot.add_cog(AvatarEmojiCog(bot), guild=bot.backrooms) 60 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/image_gen.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord import app_commands 3 | from bot import BackroomsBot 4 | from ._config import ConfigCog, Cfg 5 | from httpx_ws import aconnect_ws 6 | import uuid 7 | import base64 8 | from io import BytesIO 9 | 10 | 11 | def base64_to_bytes(uri: str) -> BytesIO: 12 | """ 13 | 'data:image/webp;base64,[bytes] 14 | """ 15 | base64_raw = uri.split(",")[1].encode("ascii") 16 | return BytesIO(base64.b64decode(base64_raw)) 17 | 18 | 19 | class ImageGenCog(ConfigCog): 20 | api_key = Cfg(str) 21 | 22 | def __init__(self, bot: BackroomsBot) -> None: 23 | super().__init__(bot) 24 | 25 | @app_commands.command( 26 | name="generate_image", description="Generování obrázku z textového popisu" 27 | ) 28 | async def generate_image( 29 | self, 30 | interaction: discord.Interaction, 31 | prompt: str, 32 | image_count: int = 1, 33 | width: int = 1024, 34 | height: int = 1024, 35 | ): 36 | await interaction.response.defer() 37 | async with aconnect_ws( 38 | "wss://ws-api.runware.ai/v1", 39 | # To avoid 403 40 | headers={ 41 | "Origin": "https://fastflux.ai", 42 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", 43 | }, 44 | ) as ws: 45 | # Authenticate 46 | await ws.send_json( 47 | [ 48 | { 49 | "taskType": "authentication", 50 | "apiKey": await self.api_key, 51 | } 52 | ] 53 | ) 54 | # Wait for response 55 | await ws.receive_json() 56 | # Generate images 57 | await ws.send_json( 58 | [ 59 | { 60 | "taskType": "imageInference", 61 | "model": "runware:100@1", 62 | "numberResults": image_count, 63 | "outputFormat": "WEBP", 64 | "outputType": ["dataURI", "URL"], 65 | "positivePrompt": prompt, 66 | "width": width, 67 | "height": height, 68 | "taskUUID": str(uuid.uuid4()), 69 | } 70 | ] 71 | ) 72 | # Wait for response 73 | images = [] 74 | for _ in range(image_count): 75 | images.append(await ws.receive_json()) 76 | # Convert to discord.File 77 | files = [ 78 | discord.File( 79 | fp=base64_to_bytes(image["data"][0]["imageDataURI"]), 80 | filename=f"image{i}.webp", 81 | ) 82 | for i, image in enumerate(images) 83 | ] 84 | # Send files 85 | await interaction.followup.send(files=files) 86 | 87 | 88 | async def setup(bot: BackroomsBot) -> None: 89 | await bot.add_cog(ImageGenCog(bot), guild=bot.backrooms) 90 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/superkauf.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | import websockets 3 | from discord import Embed, Colour 4 | import json 5 | from ._config import ConfigCog, Cfg 6 | import asyncio 7 | 8 | 9 | from bot import BackroomsBot 10 | 11 | 12 | class WebSocketClient: 13 | def __init__(self, bot, websocket_url): 14 | self.bot = bot 15 | self.websocket_url = websocket_url 16 | 17 | async def connect(self, channel_id): 18 | reconnect_timeout = 1 19 | while True: 20 | try: 21 | async with websockets.connect(self.websocket_url) as ws: 22 | while True: 23 | reconnect_timeout = 1 24 | parsedMessage = json.loads(await ws.recv()) 25 | user_data = parsedMessage["user"] 26 | post_data = parsedMessage["post"] 27 | store_data = parsedMessage["store"] 28 | 29 | channel = self.bot.get_channel(channel_id) 30 | 31 | embed = Embed( 32 | description=post_data["description"], 33 | colour=Colour.from_rgb(113, 93, 242), 34 | ) 35 | embed.add_field( 36 | name="Price", 37 | value=str(post_data["price"]) + "Kč", 38 | inline=True, 39 | ) 40 | embed.add_field( 41 | name="Store", value=str(store_data["name"]), inline=True 42 | ) 43 | embed.set_author( 44 | name="SuperKauf", 45 | icon_url="https://storage.googleapis.com/superkauf/logos/logo1.png", 46 | url="https://superkauf.krejzac.cz", 47 | ) 48 | embed.set_image(url=post_data["image"]) 49 | embed.set_footer( 50 | text=user_data["username"], 51 | icon_url=user_data["profile_picture"], 52 | ) 53 | 54 | await channel.send(embed=embed) 55 | 56 | except websockets.ConnectionClosed: 57 | reconnect_timeout *= 2 58 | print( 59 | f"Connection closed. Reconnecting in {reconnect_timeout} seconds." 60 | ) 61 | await asyncio.sleep(reconnect_timeout) 62 | 63 | 64 | class SuperkaufCog(ConfigCog): 65 | superkaufroom_id = Cfg(int) 66 | 67 | def __init__(self, bot: BackroomsBot) -> None: 68 | super().__init__(bot) 69 | self.bot = bot 70 | websocket_url = "wss://superkauf-updates.krejzac.cz" 71 | 72 | self.websocket_client = WebSocketClient(bot, websocket_url) 73 | 74 | @commands.Cog.listener() 75 | async def on_ready(self): 76 | self.bot.loop.create_task( 77 | self.websocket_client.connect(await self.superkaufroom_id) 78 | ) 79 | 80 | 81 | async def setup(bot: BackroomsBot) -> None: 82 | await bot.add_cog(SuperkaufCog(bot), guild=bot.backrooms) 83 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/reaction_utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import discord 3 | from discord.ext import commands 4 | 5 | from bot import BackroomsBot 6 | from .utils.bookmarks import Bookmark, BookmarkView 7 | from ._config import ConfigCog, Cfg 8 | 9 | 10 | class ReactionUtilsCog(ConfigCog): 11 | pin_count = Cfg(int) 12 | timeout_count = Cfg(int) 13 | timeout_duration = Cfg(float) 14 | 15 | def __init__(self, bot: BackroomsBot) -> None: 16 | super().__init__(bot) 17 | 18 | @commands.Cog.listener() 19 | async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): 20 | reaction = payload.emoji.name 21 | channel = self.bot.get_channel(payload.channel_id) 22 | message = await channel.fetch_message(payload.message_id) 23 | 24 | user = await self.bot.fetch_user(payload.user_id) 25 | 26 | match reaction: 27 | case "🔖": 28 | direct = await user.create_dm() 29 | if channel == direct: 30 | return 31 | 32 | bookmark = Bookmark(message.author, message, direct) 33 | await bookmark.add_media() 34 | await bookmark.send() 35 | 36 | case "📌": 37 | await self.pin_handle(message, channel) 38 | case "🔇": 39 | await self.timeout_handle(message) 40 | case _: 41 | return 42 | 43 | async def pin_handle( 44 | self, message: discord.message.Message, channel: discord.channel.TextChannel 45 | ): 46 | """Handles auto pinning of messages 47 | 48 | :param message: Message that received a reaction 49 | :param channel: Channel where the message is 50 | :return: 51 | """ 52 | for react in message.reactions: 53 | if ( 54 | react.emoji == "📌" 55 | and not message.pinned 56 | and not message.is_system() 57 | and react.count >= await self.pin_count 58 | ): 59 | # FIXME 60 | # pins = await channel.pins() 61 | # we need to maintain when was the last warning about filled pins, 62 | # otherwise we will get spammed by the pins full message 63 | await message.pin() 64 | break 65 | 66 | async def timeout_handle(self, message: discord.message.Message): 67 | """Handles auto timeout of users 68 | 69 | :param message: Message that received a reaction 70 | :return 71 | """ 72 | for react in message.reactions: 73 | author = await message.guild.fetch_member(message.author.id) 74 | if ( 75 | react.emoji == "🔇" 76 | and not author.is_timed_out() 77 | and not message.is_system() 78 | and react.count >= await self.timeout_count 79 | ): 80 | # FIXME 81 | # we need to maintain when was the last timeout, 82 | # otherwise someone could get locked out 83 | duration = datetime.timedelta(minutes=await self.timeout_duration) 84 | await author.timeout(duration) 85 | break 86 | 87 | 88 | async def setup(bot: BackroomsBot) -> None: 89 | bot.add_view(BookmarkView()) 90 | await bot.add_cog(ReactionUtilsCog(bot), guild=bot.backrooms) 91 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/utils/bookmarks.py: -------------------------------------------------------------------------------- 1 | from discord import Embed, Colour, User, Message, DMChannel, Interaction, ButtonStyle 2 | from discord.ui import Button, View, button 3 | from datetime import datetime 4 | 5 | EMBED_MAXLEN = 1024 6 | 7 | 8 | class Bookmark: 9 | def __init__(self, author: User, message: Message, channel: DMChannel): 10 | self.author = author 11 | self.message = message 12 | self.channel = channel 13 | 14 | self.create_embed() 15 | self.view = BookmarkView() 16 | self.attachments = [] 17 | self.files = [] 18 | 19 | async def send(self): 20 | await self.channel.send(embed=self.embed, files=self.files, view=self.view) 21 | 22 | def create_embed(self): 23 | self.embed = Embed( 24 | title="Záložka!", description=self.message.jump_url, colour=Colour.purple() 25 | ) 26 | 27 | self.embed.set_author( 28 | name=self.author.display_name, icon_url=self.author.avatar 29 | ) 30 | 31 | time = datetime.now() 32 | self.embed.set_footer(text=f"Záložka vytvořena: {time}") 33 | 34 | if len(self.message.content) > EMBED_MAXLEN: 35 | content = self.message.content[: EMBED_MAXLEN + 1].split(" ")[0:-1] 36 | if len(content): 37 | self.split_words() 38 | else: 39 | self.split_string() 40 | 41 | else: 42 | self.embed.add_field( 43 | name="Obsah:", value=self.message.content, inline=False 44 | ) 45 | 46 | def split_words(self): 47 | content = " ".join(self.message.content[: EMBED_MAXLEN + 1].split(" ")[0:-1]) 48 | self.message.content = self.message.content[len(content) :] 49 | self.embed.add_field(name="Obsah:", value=content, inline=False) 50 | 51 | for i in range(len(self.message.content) // EMBED_MAXLEN + 1): 52 | if len(self.message.content) < EMBED_MAXLEN: 53 | self.embed.add_field(name="", value=self.message.content, inline=True) 54 | return 55 | 56 | content = " ".join( 57 | self.message.content[: EMBED_MAXLEN + 1].split(" ")[0:-1] 58 | ) 59 | self.message.content = self.message.content[EMBED_MAXLEN + 1 :] 60 | self.embed.add_field(name="", value=content, inline=True) 61 | 62 | def split_string(self): 63 | content = self.message.content[:EMBED_MAXLEN] 64 | self.message.content = self.message.content[EMBED_MAXLEN:] 65 | self.embed.add_field(name="Obsah:", value=content, inline=False) 66 | for i in range(len(self.message.content) // EMBED_MAXLEN + 1): 67 | content = self.message.content[:EMBED_MAXLEN] 68 | self.message.content = self.message.content[EMBED_MAXLEN:] 69 | self.embed.add_field(name="", value=content, inline=True) 70 | 71 | async def add_media(self): 72 | if not self.message.attachments: 73 | return 74 | 75 | msg_attachments = self.message.attachments 76 | 77 | if "image" in msg_attachments[0].content_type and len(msg_attachments) == 1: 78 | self.embed.set_image(url=(msg_attachments.pop()).url) 79 | 80 | for attachment in msg_attachments: 81 | file = await attachment.to_file() 82 | self.files.append(file) 83 | 84 | 85 | class BookmarkView(View): 86 | def __init__(self): 87 | super().__init__(timeout=None) 88 | 89 | @button( 90 | label="Delete", 91 | style=ButtonStyle.danger, 92 | emoji="💥", 93 | custom_id="bookmark_delete_button", 94 | ) 95 | async def delete_button(self, interaction: Interaction, button: Button): 96 | await interaction.message.delete() 97 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/_config.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Any, Union, Awaitable, overload 2 | from bot import BackroomsBot 3 | from discord.ext import commands 4 | from discord import Interaction, AllowedMentions 5 | import discord.ui as ui 6 | import asyncio 7 | from contextlib import suppress 8 | import motor.motor_asyncio as maio 9 | 10 | _NO_VALUE = object() 11 | 12 | _CACHE: dict[str, Any] = {} 13 | 14 | 15 | def clear_cache(): 16 | _CACHE.clear() 17 | 18 | 19 | async def _cached_get(col: maio.AsyncIOMotorCollection, key: str) -> dict[str, Any]: 20 | """ 21 | Load a cogs configuration, or find it in the cache if it is present there 22 | """ 23 | try: 24 | return _CACHE[key] 25 | except KeyError: 26 | result = await col.find_one({"key": key}) or {} 27 | _CACHE[key] = result 28 | return result 29 | 30 | 31 | async def _cached_update( 32 | col: maio.AsyncIOMotorCollection, key: str, values: dict[str, Any] 33 | ): 34 | """ 35 | Update a cogs configuration, invalidating its cache entry. 36 | 37 | The parameter values must include the key as well 38 | """ 39 | with suppress(KeyError): 40 | # leave the race condition to mongodb 41 | del _CACHE[key] 42 | await col.update_one({"key": key}, {"$set": values}, upsert=True) 43 | 44 | 45 | class Cfg: 46 | def __init__( 47 | self, t: Callable[[Any], Any], default=_NO_VALUE, description: str = "" 48 | ) -> None: 49 | """ 50 | A configuration option for a ConfigCog. A descriptor that provides an **awaitable** returning the configuration value at that point. 51 | 52 | t is the function that parses and validates the config value to string - generally str or int. Make sure that `t(str(v)) == v`, and that `motor` will accept the result as part of a collection. 53 | 54 | If `default` is not set, an exception will occur when no value was configured, otherwise, the default is used. 55 | 56 | description is used as the name in the configuration modal if set. 57 | """ 58 | self.name: str 59 | self.t = t 60 | self.default = default 61 | self.description = description 62 | 63 | def convert(self, value): 64 | """ 65 | Given a string value, convert it into the value for the config. 66 | """ 67 | return self.t(value) 68 | 69 | async def get(self, obj): 70 | """ 71 | Get this config option, given the ConfigCog instance 72 | """ 73 | if self.default is _NO_VALUE: 74 | return self.convert((await obj._cfg())[self.name]) 75 | else: 76 | if (value := (await obj._cfg()).get(self.name, _NO_VALUE)) is _NO_VALUE: 77 | return self.default 78 | else: 79 | return self.convert(value) 80 | 81 | @property 82 | def label(self): 83 | """ 84 | The label of the config option in the configuration modal. 85 | """ 86 | return self.description or self.name 87 | 88 | def __set_name__(self, owner, name): 89 | if not issubclass(owner, ConfigCog): 90 | raise RuntimeError("Make sure you only use Cfg in ConfigCog classes") 91 | owner.options = getattr(owner, "options", []) + [self] 92 | self.name = name 93 | 94 | @overload 95 | def __get__(self, obj: None, objtype=None) -> "Cfg": 96 | ... 97 | 98 | @overload 99 | def __get__(self, obj: "ConfigCog", objtype=None) -> Awaitable[Any]: 100 | ... 101 | 102 | def __get__( 103 | self, obj: Union["ConfigCog", None], objtype=None 104 | ) -> "Cfg" | Awaitable[Any]: 105 | if obj is None: 106 | return self 107 | else: 108 | return self.get(obj) 109 | 110 | def __set__(self, obj, v, objtype=None): 111 | raise RuntimeError("cannot set config value") 112 | 113 | 114 | class ConfigCog(commands.Cog): 115 | key: str 116 | options: list[Cfg] 117 | 118 | def __init__(self, bot: BackroomsBot): 119 | """ 120 | Setup the ConfigCog - Make sure you user `super().__init__(bot)` if overriding this. 121 | """ 122 | self.bot = bot 123 | self.config = bot.db.config 124 | 125 | async def _cfg(self) -> dict[Any, Any]: 126 | return await _cached_get(self.config, self.key) 127 | 128 | def __init_subclass__(cls) -> None: 129 | cls.key = cls.__module__ 130 | 131 | 132 | async def gen_modal(t: str, items: list[Cfg], inst: ConfigCog) -> ui.Modal: 133 | """ 134 | Given a ConfigCog instance, the title of the modal, and the list of configuration items it provides, construct a modal that provides a form for each of the fields, using the new values to update the configuration 135 | """ 136 | fields: dict[str, ui.TextInput] = {} 137 | for item in items: 138 | # TODO better picking of fields here 139 | try: 140 | value = str(await item.get(inst)) 141 | except KeyError: 142 | value = None 143 | fields[item.name] = ui.TextInput( 144 | label=item.label, 145 | default=value, 146 | required=False, 147 | ) 148 | 149 | async def on_submit(self, interaction: Interaction): 150 | errors = [] 151 | new_data: Any = {"key": inst.__module__} 152 | for item in items: 153 | raw_value = getattr(self, item.name).value 154 | # discord seems to offer up '' instead of None when a field is left unset 155 | if not raw_value: 156 | continue 157 | try: 158 | value = item.convert(raw_value) 159 | except Exception: 160 | errors.append((item, raw_value)) 161 | else: 162 | try: 163 | old_value = await item.get(inst) 164 | except KeyError: 165 | new_data[item.name] = value 166 | else: 167 | if value != old_value: 168 | new_data[item.name] = value 169 | if errors: 170 | error_msg = [f"`{item.name}={v!r}`" for item, v in errors] 171 | await interaction.response.send_message( 172 | "Failed to assign some values:\n" + "\n".join(error_msg), 173 | allowed_mentions=AllowedMentions.none(), 174 | ) 175 | elif len(new_data) > 1: # more than just key: module 176 | changes = [f"`{k}={v!r}`" for k, v in new_data.items()] 177 | send = interaction.response.send_message( 178 | "Updating with new values:\n" + "\n".join(changes), 179 | allowed_mentions=AllowedMentions.none(), 180 | ) 181 | db = _cached_update(inst.config, inst.__module__, new_data) 182 | await asyncio.gather(send, db) 183 | else: 184 | await interaction.response.send_message( 185 | "No changes were made", ephemeral=True 186 | ) 187 | 188 | methods = dict(on_submit=on_submit) 189 | return type("CogModal", (ui.Modal,), fields | methods, title="Configure " + t)() # type: ignore 190 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/llm.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | import httpx 4 | 5 | from bot import BackroomsBot 6 | from consts import GEMINI_TOKEN, GROQ_TOKEN 7 | from ._config import ConfigCog, Cfg 8 | 9 | 10 | def replace_suffix(message: discord.Message, suffix: str) -> str: 11 | return message.content[: -len(suffix)] + "?" 12 | 13 | 14 | class TolerableLLMError(Exception): 15 | """An error that won't be logged, only sent to the user""" 16 | 17 | pass 18 | 19 | 20 | class LLMCog(ConfigCog): 21 | proxy_url = Cfg(str) 22 | botroom_id = Cfg(int, default=1187163442814128128) 23 | req_timeout = Cfg(int, default=30) 24 | 25 | def __init__(self, bot: BackroomsBot) -> None: 26 | super().__init__(bot) 27 | 28 | async def handle_google_gemini(self, conversation: list[dict]): 29 | API_URL = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key={GEMINI_TOKEN}" 30 | # Convert conversation to the format required by the API 31 | conversation = [ 32 | { 33 | "role": "model" if msg["role"] == "assistant" else "user", 34 | "parts": [{"text": msg["content"]}], 35 | } 36 | for msg in conversation 37 | ] 38 | data = { 39 | "contents": conversation, 40 | "safetySettings": [ # Maximum power 41 | { 42 | "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", 43 | "threshold": "BLOCK_NONE", 44 | }, 45 | { 46 | "category": "HARM_CATEGORY_HATE_SPEECH", 47 | "threshold": "BLOCK_NONE", 48 | }, 49 | {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, 50 | { 51 | "category": "HARM_CATEGORY_DANGEROUS_CONTENT", 52 | "threshold": "BLOCK_NONE", 53 | }, 54 | ], 55 | } 56 | # US socks5 proxy, because API allows only some regions 57 | proxy = await self.proxy_url 58 | async with httpx.AsyncClient(proxy=proxy, verify=False) as ac: 59 | response = await ac.post(API_URL, json=data, timeout=await self.req_timeout) 60 | json = response.json() 61 | if response.status_code == 200: 62 | return json["candidates"][0]["content"]["parts"][0]["text"] 63 | elif response.status_code == 500: 64 | raise TolerableLLMError(json["error"]["message"]) 65 | else: 66 | raise RuntimeError(f"Gemini failed {response.status_code}: {json}") 67 | 68 | async def handle_groq(self, model, conversation: list[dict], system_prompt=True): 69 | API_URL = "https://api.groq.com/openai/v1/chat/completions" 70 | data = { 71 | "messages": conversation, 72 | "model": model, 73 | } 74 | 75 | if system_prompt: 76 | data["messages"].insert( 77 | 0, 78 | { 79 | "role": "system", 80 | "content": "Jsi digitální asistent, který odpovídá v češtině", 81 | }, 82 | ) 83 | 84 | headers = { 85 | "Authorization": f"Bearer {GROQ_TOKEN}", 86 | "Content-Type": "application/json", 87 | } 88 | async with httpx.AsyncClient() as ac: 89 | response = await ac.post( 90 | API_URL, json=data, headers=headers, timeout=await self.req_timeout 91 | ) 92 | json = response.json() 93 | if response.status_code == 200: 94 | return json["choices"][0]["message"]["content"] 95 | else: 96 | raise RuntimeError(f"Groq failed {response.status_code}: {json}") 97 | 98 | async def handle_llama(self, conversation: list[dict]): 99 | return await self.handle_groq( 100 | "meta-llama/llama-4-scout-17b-16e-instruct", 101 | conversation, 102 | system_prompt=False, 103 | ) 104 | 105 | async def handle_gpt_oss(self, conversation: list[dict]): 106 | return await self.handle_groq( 107 | "openai/gpt-oss-120b", conversation, system_prompt=False 108 | ) 109 | 110 | async def handle_reasoning(self, conversation: list[dict]): 111 | return await self.handle_groq( 112 | "deepseek-r1-distill-llama-70b", conversation, system_prompt=False 113 | ) 114 | 115 | @commands.Cog.listener() 116 | async def on_message(self, message: discord.Message): 117 | suffix_map = { 118 | "??": self.handle_google_gemini, 119 | "?!": self.handle_llama, 120 | "?.": self.handle_gpt_oss, 121 | "?r": self.handle_reasoning, 122 | } 123 | 124 | if message.channel.id != await self.botroom_id: 125 | return 126 | if message.author == self.bot.user: 127 | return 128 | for suffix, handler in suffix_map.items(): 129 | if message.content.endswith(suffix): 130 | conversation = [] 131 | # If the message is a reply to AI, get the original message and add it to the prompt 132 | if ( 133 | message.reference 134 | and message.reference.resolved 135 | and message.reference.resolved.author == self.bot.user 136 | ): 137 | ai_msg = message.reference.resolved 138 | user_msg_id = message.reference.resolved.reference.message_id 139 | user_msg = await message.channel.fetch_message(user_msg_id) 140 | # User question 141 | conversation.append( 142 | {"role": "user", "content": replace_suffix(user_msg, suffix)} 143 | ) 144 | # AI answer 145 | conversation.append( 146 | {"role": "assistant", "content": ai_msg.content} 147 | ) 148 | 149 | conversation.append( 150 | {"role": "user", "content": replace_suffix(message, suffix)} 151 | ) 152 | 153 | try: 154 | response = await handler(conversation) 155 | allowed = discord.AllowedMentions( 156 | roles=False, everyone=False, users=True, replied_user=True 157 | ) 158 | chunks = [ 159 | response[i : i + 2000] for i in range(0, len(response), 2000) 160 | ] 161 | for chunk in chunks: 162 | await message.reply(chunk, allowed_mentions=allowed) 163 | except httpx.ReadTimeout: 164 | await message.reply("*Response timed out*") 165 | except (KeyError, IndexError): 166 | await message.reply("*Did not get a response*") 167 | except TolerableLLMError as e: 168 | await message.reply(f"*{str(e)}*") 169 | except RuntimeError as e: 170 | await message.reply(f"*{str(e)}*") 171 | raise RuntimeError(e) 172 | 173 | 174 | async def setup(bot: BackroomsBot) -> None: 175 | await bot.add_cog(LLMCog(bot), guild=bot.backrooms) 176 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/tldr.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord import ( 3 | app_commands, 4 | MessageType, 5 | TextChannel, 6 | Message, 7 | Interaction, 8 | AppCommandType, 9 | ) 10 | from bot import BackroomsBot 11 | import google.generativeai as genai 12 | import re 13 | import json 14 | from collections import defaultdict 15 | from ._config import Cfg, ConfigCog 16 | 17 | from consts import GEMINI_TOKEN 18 | 19 | 20 | class TldrError(Exception): 21 | def __init__(self, error_msg: str): 22 | self.error_msg = error_msg 23 | super().__init__(self.error_msg) 24 | 25 | 26 | class TokensLimitExceededError(TldrError): 27 | pass 28 | 29 | 30 | class MessageIdInvalidError(TldrError): 31 | pass 32 | 33 | 34 | class StartMsgOlderThanEndMsgError(TldrError): 35 | def __init__( 36 | self, 37 | error_msg: str = "Starting message must not be newer than the ending message.", 38 | ): 39 | super().__init__(error_msg) 40 | 41 | 42 | class TldrCog(ConfigCog): 43 | # TODO: make this configurable 44 | EPHEMERAL = True # make the response ephemeral 45 | GEMINI_MODEL_NAME = Cfg(str, "gemini-1.5-flash") 46 | TOKEN_LIMIT = Cfg(int, 100_000) 47 | MESSAGES_LIMIT = Cfg(int, 10_000) 48 | 49 | def __init__(self, bot: BackroomsBot) -> None: 50 | super().__init__(bot) 51 | self.bot = bot 52 | genai.configure(api_key=GEMINI_TOKEN) 53 | # { (user_id, channel_id): [message_after, message_before] } 54 | self.boundaries: defaultdict[ 55 | tuple[int, int], list[Message | None] 56 | ] = defaultdict(lambda: [None, None]) 57 | 58 | # Register the context menu commands 59 | self.ctx_menu_tldr_after = app_commands.ContextMenu( 60 | name="TL;DR After This", 61 | callback=self.ctx_menu_tldr_after_command, 62 | type=AppCommandType.message, # only for messages 63 | guild_ids=[ 64 | self.bot.backrooms.id 65 | ], # lock the command to the backrooms guild 66 | ) 67 | self.ctx_menu_tldr_before = app_commands.ContextMenu( 68 | name="TL;DR Before This", 69 | callback=self.ctx_menu_tldr_before_command, 70 | type=AppCommandType.message, # only for messages 71 | guild_ids=[ 72 | self.bot.backrooms.id 73 | ], # lock the command to the backrooms guild 74 | ) 75 | self.ctx_menu_tldr_this = app_commands.ContextMenu( 76 | name="TL;DR This One", 77 | callback=self.ctx_menu_tldr_this_command, 78 | type=AppCommandType.message, # only for messages 79 | guild_ids=[ 80 | self.bot.backrooms.id 81 | ], # lock the command to the backrooms guild 82 | ) 83 | self.bot.tree.add_command(self.ctx_menu_tldr_after) 84 | self.bot.tree.add_command(self.ctx_menu_tldr_before) 85 | self.bot.tree.add_command(self.ctx_menu_tldr_this) 86 | 87 | @app_commands.command( 88 | name="tldr", 89 | description="Vytvoří krátký souhrn mezi zprávami. Je nutné nastavit začátek.", 90 | ) 91 | async def tldr(self, interaction: Interaction): 92 | """ 93 | Command to generate a summary between two messages. 94 | The starting message must be set first. 95 | If the ending message is not set, the last message in the channel is used. 96 | """ 97 | 98 | # defer response to avoid timeout 99 | await interaction.response.defer(ephemeral=self.EPHEMERAL) 100 | 101 | async def respond(content: str): 102 | """Helper function to send a followup response after defer to the interaction.""" 103 | return await interaction.followup.send( 104 | content=content, ephemeral=self.EPHEMERAL 105 | ) 106 | 107 | boundaries_key = (interaction.user.id, interaction.channel.id) 108 | message_after, message_before = self.boundaries.get( 109 | boundaries_key, [None, None] 110 | ) 111 | if message_after is None: 112 | await respond("Please set the starting message first.") 113 | return 114 | if message_before is None: 115 | message_before = await self._get_last_message(interaction.channel) 116 | 117 | try: 118 | tldr = await self._tldr(interaction.channel, message_after, message_before) 119 | await respond(tldr) 120 | except TldrError as e: 121 | await respond(e.error_msg) 122 | except Exception as e: 123 | await respond("An unexpected error occurred.") 124 | raise e 125 | 126 | async def ctx_menu_tldr_after_command( 127 | self, interaction: Interaction, message_after: Message 128 | ): 129 | boundaries_key = (interaction.user.id, interaction.channel.id) 130 | self.boundaries[boundaries_key][0] = message_after 131 | await interaction.response.send_message( 132 | f"TL;DR after message set to {message_after.jump_url}", ephemeral=True 133 | ) 134 | 135 | async def ctx_menu_tldr_before_command( 136 | self, interaction: Interaction, message_before: Message 137 | ): 138 | boundaries_key = (interaction.user.id, interaction.channel.id) 139 | self.boundaries[boundaries_key][1] = message_before 140 | await interaction.response.send_message( 141 | f"TL;DR before message set to {message_before.jump_url}", ephemeral=True 142 | ) 143 | 144 | async def ctx_menu_tldr_this_command( 145 | self, interaction: Interaction, message: Message 146 | ): 147 | await interaction.response.defer(ephemeral=self.EPHEMERAL) 148 | msg_content = await self._replace_user_mentions_with_their_names( 149 | message.content 150 | ) 151 | tldr = await self._generate_tldr_from_single_message(msg_content) 152 | await interaction.followup.send(tldr, ephemeral=self.EPHEMERAL) 153 | 154 | # Remove the commands from the tree when the cog is unloaded 155 | async def cog_unload(self) -> None: 156 | self.bot.tree.remove_command(self.ctx_menu_tldr_after) 157 | self.bot.tree.remove_command(self.ctx_menu_tldr_before) 158 | self.bot.tree.remove_command(self.ctx_menu_tldr_this) 159 | 160 | async def _get_last_message(self, channel: TextChannel) -> Message: 161 | # try to get the last message in the channel from cache 162 | message_before = channel.last_message 163 | if message_before is not None: 164 | return message_before 165 | # if not found, fetch the last message manually 166 | async for msg in channel.history(limit=1): 167 | return msg 168 | 169 | async def _generate_tldr_from_conversation(self, messages: str) -> str: 170 | model = genai.GenerativeModel(await self.GEMINI_MODEL_NAME) 171 | tokens = model.count_tokens(messages) 172 | if tokens.total_tokens > await self.TOKEN_LIMIT: 173 | raise TokensLimitExceededError( 174 | f"Input exceeds the token limit: {await self.TOKEN_LIMIT}, total tokens: {tokens.total_tokens}." 175 | ) 176 | prompt = ( 177 | "You are given a Discord conversation. Summarize the main points and key ideas " 178 | "in a concise manner in Czech. Focus on the most important information and provide " 179 | "a clear and coherent summary.\n\n" 180 | f"Conversation:\n{messages}" 181 | ) 182 | return model.generate_content(prompt).text 183 | 184 | async def _generate_tldr_from_single_message(self, message: str) -> str: 185 | model = genai.GenerativeModel(await self.GEMINI_MODEL_NAME) 186 | tokens = model.count_tokens(message) 187 | if tokens.total_tokens > await self.TOKEN_LIMIT: 188 | raise TokensLimitExceededError( 189 | f"Input exceeds the token limit: {await self.TOKEN_LIMIT}, total tokens: {tokens.total_tokens}." 190 | ) 191 | prompt = ( 192 | "You are given a Discord message. Summarize the main points and key ideas " 193 | "in a concise manner in Czech. Focus on the most important information and provide " 194 | "a clear and coherent summary.\n\n" 195 | f"Message:\n{message}" 196 | ) 197 | return model.generate_content(prompt).text 198 | 199 | async def _parse_message_id_to_message( 200 | self, channel: TextChannel, message_id: str 201 | ) -> Message: 202 | try: 203 | message_id = int(message_id) 204 | except ValueError: 205 | raise MessageIdInvalidError("Invalid message ID format.") 206 | try: 207 | message = await channel.fetch_message(message_id) 208 | except discord.NotFound: 209 | raise MessageIdInvalidError("Message not found based on the provided ID.") 210 | return message 211 | 212 | async def _replace_user_mentions_with_their_names(self, message_content: str): 213 | USER_RE = re.compile(r"<@\d+>") 214 | searches = USER_RE.findall(message_content) 215 | for search in searches: 216 | user_id = search[2:-1] 217 | user = await self.bot.fetch_user(user_id) 218 | message_content = message_content.replace(search, user.name) 219 | return message_content 220 | 221 | async def _tldr( 222 | self, 223 | channel: TextChannel, 224 | message_after: Message, 225 | message_before: Message, 226 | ) -> str: 227 | """ 228 | Generate a TL;DR summary between two messages. 229 | 230 | :param channel: The channel where the messages are located 231 | :param message_after: The starting message 232 | :param message_before: The ending message 233 | :return: The generated TL;DR summary 234 | 235 | :raises TldrError: If an error occurs during the process 236 | 237 | """ 238 | if message_after.created_at > message_before.created_at: 239 | raise StartMsgOlderThanEndMsgError() 240 | 241 | # Fetch the messages between the two messages and simplify them 242 | messages = [] 243 | 244 | async for msg in channel.history( 245 | after=message_after, 246 | before=message_before, 247 | oldest_first=True, 248 | limit=await self.MESSAGES_LIMIT, 249 | ): 250 | if msg.author.bot: # skip bot messages 251 | continue 252 | 253 | msg_content = msg.content 254 | 255 | msg_content = await self._replace_user_mentions_with_their_names( 256 | msg_content 257 | ) 258 | 259 | simplified_message = { 260 | "id": msg.id, 261 | "author": msg.author.name, 262 | "created_at": msg.created_at.strftime("%Y-%m-%d %H:%M:%S"), 263 | "message": msg_content, 264 | } 265 | if msg.type == MessageType.reply: 266 | simplified_message["reply_to"] = msg.reference.message_id 267 | messages.append(simplified_message) 268 | 269 | serialized = json.dumps(messages) 270 | tldr = await self._generate_tldr_from_conversation(serialized) 271 | return tldr 272 | 273 | 274 | async def setup(bot: BackroomsBot) -> None: 275 | await bot.add_cog(TldrCog(bot), guild=bot.backrooms) 276 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/imitation.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord import app_commands 3 | import httpx 4 | import re 5 | import asyncio 6 | 7 | from bot import BackroomsBot 8 | from ._config import ConfigCog, Cfg 9 | from discord import Interaction 10 | 11 | START_HEADER_ID = "<|start_header_id|>" 12 | END_HEADER_ID = "<|end_header_id|>" 13 | MESSAGE_ID = "<|reserved_special_token_0|>" 14 | REPLY_ID = "<|reserved_special_token_1|>" 15 | END_MESSAGE_ID = "<|reserved_special_token_4|>" 16 | 17 | 18 | class InvalidResponseException(Exception): 19 | """The response from the model was invalid""" 20 | 21 | pass 22 | 23 | 24 | class ImitationCog(ConfigCog): 25 | server = Cfg(str) 26 | req_timeout = Cfg(int, default=30) 27 | 28 | def __init__(self, bot: BackroomsBot) -> None: 29 | super().__init__(bot) 30 | 31 | self.bot = bot 32 | self.lock = asyncio.Lock() 33 | self.context = "" 34 | 35 | async def user_autocomplete(self, interaction: Interaction, current: str): 36 | authors = [ 37 | "kubikon", 38 | "theramsay", 39 | "metjuas", 40 | "logw", 41 | "throwdemgunz", 42 | "s1r_o", 43 | "gzvv", 44 | "ithislen", 45 | "_.spoot._", 46 | "roytak", 47 | "tominoftw", 48 | ".stepha", 49 | "jurge_chorche", 50 | "ericc727", 51 | "Backrooms bot", 52 | "andrejmokris", 53 | "noname7571", 54 | "frogerius", 55 | "kulvplote", 56 | "soromis", 57 | "krekon_", 58 | "lakmatiol", 59 | "josefkuchar", 60 | "mrstinky", 61 | "louda7658", 62 | "tamoka.", 63 | "dajvid", 64 | "kocotom", 65 | "kubosh", 66 | "upwell", 67 | "padi142", 68 | "prity_joke", 69 | "jankaxdd", 70 | "tokugawa6139", 71 | "toaster", 72 | "oty_suvenyr", 73 | "Rubbergod", 74 | "GrillBot", 75 | "jezko", 76 | ".jerrys", 77 | "redak", 78 | "donmegonasayit", 79 | "fpmk", 80 | "whoislisalisa", 81 | "Dank Memer", 82 | "nevarilovav", 83 | "OpenBB Bot", 84 | "avepanda", 85 | "bonobonobono", 86 | "man1ak", 87 | "t1mea_", 88 | "nycella", 89 | "headclass", 90 | "puroki", 91 | "_blaza_", 92 | "natyhr", 93 | "Dyno", 94 | "Jockie Music (2)", 95 | "louda", 96 | "Tokugawa", 97 | "cultsauce_", 98 | "Agent Smith", 99 | "Vlčice", 100 | "Compiler", 101 | "Cappuccino", 102 | "solumath", 103 | ] 104 | authors = [app_commands.Choice(name=a, value=a) for a in authors] 105 | if not current: 106 | return authors 107 | 108 | return [a for a in authors if a.name.startswith(current.lower())] 109 | 110 | def get_emoji(self, name: str): 111 | """Get the emoji by name""" 112 | 113 | for emoji in self.bot.emojis: 114 | if emoji.name.lower() == name.lower(): 115 | return emoji 116 | return f":{name}:" 117 | 118 | def get_formatted_message( 119 | self, author: str, content: str, id: int, reply_id: int = None 120 | ): 121 | """ 122 | Format the message to be sent 123 | 124 | :param author: The author of the message 125 | :param content: The content of the message 126 | :param id: The ID of the message 127 | :param reply_id: The ID of the message being replied to 128 | :return: The formatted message 129 | """ 130 | backrooms = self.bot.get_guild(self.bot.backrooms.id) 131 | pantry = self.bot.get_guild(self.bot.pantry_id) 132 | 133 | emoji = "❄️" # Default emoji 134 | member = backrooms.get_member_named(author) 135 | if member: 136 | for em in pantry.emojis: 137 | if em.name == str(member.id): 138 | emoji = em 139 | break 140 | 141 | # Patch emojis 142 | content = re.sub(r":(\w+):", lambda x: str(self.get_emoji(x.group(1))), content) 143 | 144 | message = f"{emoji} **{author}** | *msg ID: [{id}]*" 145 | if reply_id: 146 | message += f" | *Reply to: [{reply_id}]*" 147 | message += f"\n>>> {content}" 148 | return message 149 | 150 | def get_message_from_raw(self, raw: str): 151 | """ 152 | Get the message from the raw model response 153 | 154 | :param raw: The raw model response 155 | :return: The formatted message 156 | """ 157 | 158 | match = re.match( 159 | r"<\|start_header_id\|>(\d+)<\|reserved_special_token_0\|>(.*)<\|reserved_special_token_1\|>(\d*)<\|end_header_id\|>\n([\S\n\t\v ]+)<\|reserved_special_token_4\|>\n\n", 160 | raw, 161 | ) 162 | if not match: 163 | raise InvalidResponseException() 164 | 165 | author = match.group(2).strip() 166 | content = match.group(4).strip() 167 | id = match.group(1).strip() 168 | reply_id = match.group(3).strip() 169 | 170 | return self.get_formatted_message(author, content, id, reply_id) 171 | 172 | async def respond(self, interaction: discord.Interaction, raw: str, first=True): 173 | """ 174 | Respond to the interaction 175 | 176 | :param interaction: The interaction to respond to 177 | :param raw: The raw model response 178 | :param first: Whether this is the first message in the conversation 179 | """ 180 | 181 | print(raw) 182 | try: 183 | message = self.get_message_from_raw(raw) 184 | self.context += raw 185 | except InvalidResponseException: 186 | message = "*Nepodařilo se získat odpověď od modelu*" 187 | if first: 188 | await interaction.followup.send(message) 189 | else: 190 | await interaction.channel.send(message) 191 | 192 | async def send_busy_message(self, interaction: discord.Interaction): 193 | """Send a message that the bot is busy""" 194 | 195 | await interaction.response.send_message( 196 | "*Momentálně je vykonávána jiná akce, prosím čekejte*", ephemeral=True 197 | ) 198 | 199 | async def get_prediction(self, prompt: str, stop: str = START_HEADER_ID): 200 | """ 201 | Get the prediction from the model 202 | Context is included 203 | 204 | :param prompt: The prompt to send to the model 205 | :param stop: The stop token to stop the model at 206 | :return: The model response 207 | """ 208 | 209 | data = { 210 | "prompt": self.context + prompt, 211 | "stop": [stop], 212 | "cache_prompt": True, # Existing context won't have to be evaluated again 213 | } 214 | 215 | async with httpx.AsyncClient() as ac: 216 | response = await ac.post( 217 | f"{await self.server}/completion", 218 | json=data, 219 | timeout=await self.req_timeout, 220 | ) 221 | json = response.json() 222 | 223 | if response.status_code != 200: 224 | raise RuntimeError(f"Model failed {response.status_code}: {json}") 225 | 226 | return json["content"] 227 | 228 | @app_commands.command( 229 | name="imitation_continue", description="Volné pokračování kontextu" 230 | ) 231 | async def continue_context( 232 | self, interaction: discord.Interaction, message_count: int = 1 233 | ): 234 | """ 235 | Continue context with the model 236 | 237 | :param message_count: The number of messages to generate 238 | """ 239 | 240 | if message_count < 1 or message_count > 10: 241 | await interaction.response.send_message( 242 | "*Počet zpráv musí být v rozmezí 1 až 10*", ephemeral=True 243 | ) 244 | return 245 | 246 | if not self.lock.locked(): 247 | async with self.lock: 248 | await interaction.response.defer() 249 | for i, _ in enumerate(range(message_count)): 250 | # Build the header 251 | header = START_HEADER_ID 252 | # Get continuation from the model 253 | prediction = await self.get_prediction(header) 254 | # Include header, because model only returns new tokens 255 | content = header + prediction 256 | await self.respond(interaction, content, i == 0) 257 | else: 258 | await self.send_busy_message(interaction) 259 | 260 | @app_commands.command( 261 | name="imitation_insert", 262 | description="Vložení zprávy do kontextu", 263 | ) 264 | @app_commands.autocomplete(author=user_autocomplete) 265 | async def insert_context( 266 | self, 267 | interaction: discord.Interaction, 268 | author: str = None, 269 | content: str = None, 270 | continue_content: bool = False, 271 | reply_id: str = None, 272 | ): 273 | """ 274 | Insert a message into the imitation context 275 | 276 | :param author: The author of the message 277 | :param content: The content of the message 278 | :param continue_content: Whether to continue the content 279 | :param reply_id: The ID of the message being replied to 280 | """ 281 | 282 | if not self.lock.locked(): 283 | async with self.lock: 284 | await interaction.response.defer() 285 | 286 | # Build the header 287 | header = START_HEADER_ID 288 | 289 | if author: 290 | header += ( 291 | await self.get_prediction(header, MESSAGE_ID) 292 | + MESSAGE_ID 293 | + author 294 | + REPLY_ID 295 | ) 296 | else: 297 | header += await self.get_prediction(header, REPLY_ID) + REPLY_ID 298 | 299 | # Only include reply ID if it is provided 300 | if reply_id: 301 | header += reply_id 302 | header += END_HEADER_ID + "\n" 303 | 304 | # If content is not provided, get it from the model 305 | if not content: 306 | prediction = await self.get_prediction(header) 307 | content = header + prediction 308 | # Continue the content if specified 309 | elif content and continue_content: 310 | content = header + content 311 | prediction = await self.get_prediction(content) 312 | content += prediction 313 | # Otherwise, just include the content with correct footer 314 | else: 315 | content = header + content + END_MESSAGE_ID + "\n\n" 316 | await self.respond(interaction, content) 317 | else: 318 | await self.send_busy_message(interaction) 319 | 320 | @app_commands.command(name="imitation_clear", description="Smazání kontextu") 321 | async def clear_context(self, interaction: discord.Interaction): 322 | """Clear the imitation context""" 323 | 324 | if not self.lock.locked(): 325 | async with self.lock: 326 | self.context = "" 327 | 328 | await interaction.response.send_message("*Kontext byl smazán*") 329 | else: 330 | await self.send_busy_message(interaction) 331 | 332 | 333 | async def setup(bot: BackroomsBot) -> None: 334 | await bot.add_cog(ImitationCog(bot), guild=bot.backrooms) 335 | -------------------------------------------------------------------------------- /frontroomsbot/cogs/beer_tracker.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | from discord import app_commands 4 | from datetime import datetime, timedelta 5 | from typing import Optional, Literal 6 | import uuid 7 | from zoneinfo import ZoneInfo 8 | 9 | from bot import BackroomsBot 10 | 11 | prague_tz = ZoneInfo("Europe/Prague") 12 | 13 | 14 | def ts_to_prague_time(ts: datetime) -> datetime: 15 | """Convert a UTC timestamp to Prague timezone.""" 16 | if ts.tzinfo is None: 17 | ts = ts.replace(tzinfo=ZoneInfo("UTC")) 18 | return ts.astimezone(prague_tz) 19 | 20 | 21 | class BeerTrackerCog(commands.Cog): 22 | def __init__(self, bot: BackroomsBot) -> None: 23 | self.bot = bot 24 | 25 | @app_commands.command( 26 | name="beer", description="Log a beer for yourself or someone else! 🍺" 27 | ) 28 | @app_commands.describe(user="Who drank the beer? (Defaults to you)") 29 | async def log_beer( 30 | self, interaction: discord.Interaction, user: Optional[discord.User] = None 31 | ): 32 | db = self.bot.db 33 | target_user = user or interaction.user 34 | current_time = datetime.now() 35 | 36 | # Get or create user's beer data 37 | user_data = await db.beer_tracker.find_one({"user_id": target_user.id}) or { 38 | "user_id": target_user.id, 39 | "username": target_user.name, 40 | "beers": [], 41 | "total_beers": 0, 42 | } 43 | 44 | # Add new beer entry with timestamp and UUID 45 | beer_id = str(uuid.uuid4()) 46 | user_data["beers"].append({"id": beer_id, "timestamp": current_time}) 47 | 48 | # update total beers count and username if necessary 49 | user_data["total_beers"] += 1 50 | user_data["username"] = target_user.name 51 | 52 | # Save to DB 53 | await db.beer_tracker.replace_one( 54 | {"user_id": target_user.id}, user_data, upsert=True 55 | ) 56 | 57 | await interaction.response.send_message( 58 | f"{target_user.mention} has now drunk **{user_data['total_beers']}** beers total! 🍺" 59 | ) 60 | 61 | @app_commands.command( 62 | name="my_beers", 63 | description="List your beer logs with UUIDs (used for deletion)", 64 | ) 65 | @app_commands.describe( 66 | limit="Number of beers to show (1-50)", page="Page number to view" 67 | ) 68 | async def my_beers( 69 | self, 70 | interaction: discord.Interaction, 71 | limit: app_commands.Range[int, 1, 50] = 10, 72 | page: app_commands.Range[int, 1] = 1, 73 | ): 74 | db = self.bot.db 75 | user_data = await db.beer_tracker.find_one({"user_id": interaction.user.id}) 76 | if not user_data or not user_data["beers"]: 77 | await interaction.response.send_message( 78 | "You haven't logged any beers yet! 🚱", ephemeral=True 79 | ) 80 | return 81 | 82 | # sort by newest 83 | all_beers = sorted( 84 | user_data["beers"], key=lambda x: x["timestamp"], reverse=True 85 | ) 86 | total_beers = len(all_beers) 87 | total_pages = (total_beers + limit - 1) // limit 88 | 89 | # validate page number 90 | if page > total_pages: 91 | await interaction.response.send_message( 92 | f"Page {page} doesn't exist! There are only {total_pages} page(s) available.", 93 | ephemeral=True, 94 | ) 95 | return 96 | 97 | # pagination logic 98 | start_idx = (page - 1) * limit 99 | end_idx = start_idx + limit 100 | paginated_beers = all_beers[start_idx:end_idx] 101 | 102 | # build the embed 103 | embed = discord.Embed( 104 | title=f"Your Beer Logs (Page {page}/{total_pages})", 105 | description=f"Total beers: {total_beers}", 106 | color=discord.Color.blue(), 107 | ) 108 | 109 | for beer in paginated_beers: 110 | beer_time = ts_to_prague_time(beer["timestamp"]).strftime("%Y-%m-%d %H:%M") 111 | embed.add_field( 112 | name=f"🍺 {beer_time}", value=f"`{beer['id']}`", inline=False 113 | ) 114 | 115 | # add footer for pagination 116 | footer_parts = [] 117 | if page > 1: 118 | footer_parts.append(f"Previous: /my_beers page={page-1}") 119 | if page < total_pages: 120 | footer_parts.append(f"Next: /my_beers page={page+1}") 121 | 122 | if footer_parts: 123 | embed.set_footer(text=" | ".join(footer_parts)) 124 | 125 | await interaction.response.send_message(embed=embed, ephemeral=True) 126 | 127 | @app_commands.command( 128 | name="beer_delete", description="Delete a beer entry by its UUID" 129 | ) 130 | @app_commands.describe( 131 | beer_uuid="The UUID of the beer to delete", 132 | ) 133 | async def beer_delete(self, interaction: discord.Interaction, beer_uuid: str): 134 | db = self.bot.db 135 | 136 | # verify UUID format 137 | try: 138 | uuid.UUID(beer_uuid) 139 | except ValueError: 140 | await interaction.response.send_message( 141 | "❌ Invalid beer UUID format!", ephemeral=True 142 | ) 143 | return 144 | 145 | # find which user owns this beer 146 | user_data = await db.beer_tracker.find_one({"beers.id": beer_uuid}) 147 | 148 | if not user_data: 149 | await interaction.response.send_message( 150 | "❌ No beer found with that UUID!", ephemeral=True 151 | ) 152 | return 153 | 154 | # permission check 155 | if user_data["user_id"] != interaction.user.id: 156 | await interaction.response.send_message( 157 | "❌ You can only delete your own beers!", ephemeral=True 158 | ) 159 | return 160 | 161 | # remove the beer entry 162 | updated_beers = [b for b in user_data["beers"] if b["id"] != beer_uuid] 163 | 164 | try: 165 | deleted_beer = next(b for b in user_data["beers"] if b["id"] == beer_uuid) 166 | except StopIteration: 167 | await interaction.response.send_message( 168 | "❌ No beer found with that UUID!", ephemeral=True 169 | ) 170 | return 171 | 172 | # update database 173 | await db.beer_tracker.update_one( 174 | {"user_id": user_data["user_id"]}, 175 | {"$set": {"beers": updated_beers, "total_beers": len(updated_beers)}}, 176 | ) 177 | 178 | # build confirmation message 179 | beer_time = ts_to_prague_time(deleted_beer["timestamp"]).strftime( 180 | "%Y-%m-%d %H:%M" 181 | ) 182 | 183 | response = [ 184 | f"✅ Successfully deleted beer entry for {interaction.user.mention}!", 185 | f"**Timestamp:** {beer_time}", 186 | f"**UUID:** `{beer_uuid}`", 187 | ] 188 | 189 | await interaction.response.send_message("\n".join(response), ephemeral=True) 190 | 191 | @app_commands.command(name="beer_stats", description="Check beer stats for a user") 192 | @app_commands.describe( 193 | user="The user to check (defaults to you)", 194 | period="Time period to filter (default: all time)", 195 | ) 196 | async def beer_stats( 197 | self, 198 | interaction: discord.Interaction, 199 | user: Optional[discord.User] = None, 200 | period: Optional[Literal["day", "week", "month", "year"]] = None, 201 | ): 202 | target_user = user or interaction.user 203 | db = self.bot.db 204 | 205 | user_data = await db.beer_tracker.find_one({"user_id": target_user.id}) 206 | 207 | if not user_data or not user_data["beers"]: 208 | await interaction.response.send_message( 209 | f"{target_user.mention} hasn't drunk any beers yet! 🚱" 210 | ) 211 | return 212 | 213 | # Filter beers by time period if specified 214 | beers = user_data["beers"] 215 | if period: 216 | now = datetime.now(prague_tz) 217 | if period == "day": 218 | cutoff = now - timedelta(days=1) 219 | elif period == "week": 220 | cutoff = now - timedelta(weeks=1) 221 | elif period == "month": 222 | cutoff = now - timedelta(days=30) 223 | elif period == "year": 224 | cutoff = now - timedelta(days=365) 225 | 226 | filtered_beers = [ 227 | b for b in beers if ts_to_prague_time(b["timestamp"]) >= cutoff 228 | ] 229 | count = len(filtered_beers) 230 | else: 231 | count = user_data["total_beers"] 232 | period = "all time" 233 | 234 | await interaction.response.send_message( 235 | f"**{target_user.name}** has drunk **{count}** beers ({period})! 🍻" 236 | ) 237 | 238 | @app_commands.command( 239 | name="beer_leaderboard", description="Show the top beer drinkers!" 240 | ) 241 | @app_commands.describe( 242 | period="Time period to filter (defaults to all time)", 243 | limit="How many users to show (defaults to 10)", 244 | ) 245 | async def beer_leaderboard( 246 | self, 247 | interaction: discord.Interaction, 248 | period: Optional[Literal["day", "week", "month", "year"]] = None, 249 | limit: Optional[app_commands.Range[int, 1, 30]] = 10, 250 | ): 251 | db = self.bot.db 252 | all_users = await db.beer_tracker.find().to_list(None) 253 | 254 | if not all_users: 255 | await interaction.response.send_message("No beers have been logged yet! 🚱") 256 | return 257 | 258 | # Calculate beer counts for each user (filtering by period if needed) 259 | leaderboard = [] 260 | for user_data in all_users: 261 | if not user_data.get("beers"): 262 | continue 263 | 264 | if period: 265 | now = datetime.now(prague_tz) 266 | if period == "day": 267 | # cutoff is the start of today 268 | cutoff = now.replace(hour=0, minute=0, second=0, microsecond=0) 269 | elif period == "week": 270 | cutoff = now - timedelta(weeks=1) 271 | elif period == "month": 272 | cutoff = now - timedelta(days=30) 273 | elif period == "year": 274 | cutoff = now - timedelta(days=365) 275 | 276 | filtered_beers = [ 277 | b 278 | for b in user_data["beers"] 279 | if ts_to_prague_time(b["timestamp"]) >= cutoff 280 | ] 281 | count = len(filtered_beers) 282 | else: 283 | count = user_data["total_beers"] 284 | 285 | if count > 0: 286 | leaderboard.append((user_data["username"], count, user_data["user_id"])) 287 | 288 | # Sort by beer count (descending) 289 | leaderboard.sort(key=lambda x: x[1], reverse=True) 290 | leaderboard = leaderboard[:limit] 291 | 292 | if not leaderboard: 293 | await interaction.response.send_message( 294 | f"No beers logged in the last {period}! 🚱" 295 | ) 296 | return 297 | 298 | # Format the leaderboard 299 | period_str = period if period else "all time" 300 | embed = discord.Embed( 301 | title=f"🍺 Top Beer Drinkers ({period_str}) 🍺", color=discord.Color.gold() 302 | ) 303 | 304 | description = [] 305 | for idx, (_, count, user_id) in enumerate(leaderboard, 1): 306 | description.append(f"**{idx}.** <@{user_id}> - **{count}** beers 🍻") 307 | 308 | embed.description = "\n".join(description) 309 | await interaction.response.send_message(embed=embed) 310 | 311 | @app_commands.command( 312 | name="beer_last", description="Check when a user last had a beer" 313 | ) 314 | @app_commands.describe(user="The user to check (defaults to you)") 315 | async def beer_last( 316 | self, interaction: discord.Interaction, user: Optional[discord.User] = None 317 | ): 318 | target_user = user or interaction.user 319 | db = self.bot.db 320 | 321 | # fetch user data 322 | user_data = await db.beer_tracker.find_one({"user_id": target_user.id}) 323 | 324 | if not user_data or not user_data["beers"]: 325 | await interaction.response.send_message( 326 | f"{target_user.name} hasn't drunk any beers yet! 🚱" 327 | ) 328 | return 329 | 330 | last_beer = user_data["beers"][-1] 331 | last_time = ts_to_prague_time(last_beer["timestamp"]) 332 | now = datetime.now(prague_tz) 333 | time_diff = now - last_time 334 | 335 | # time formatting 336 | if time_diff < timedelta(minutes=1): 337 | time_str = "just now" 338 | elif time_diff < timedelta(hours=1): 339 | minutes = int(time_diff.total_seconds() // 60) 340 | time_str = f"{minutes} minute{'s' if minutes != 1 else ''} ago" 341 | elif time_diff < timedelta(days=1): 342 | hours = int(time_diff.total_seconds() // 3600) 343 | time_str = f"{hours} hour{'s' if hours != 1 else ''} ago" 344 | elif time_diff < timedelta(days=30): 345 | days = int(time_diff.total_seconds() // 86400) 346 | time_str = f"{days} day{'s' if days != 1 else ''} ago" 347 | elif time_diff < timedelta(days=365): 348 | months = int(time_diff.days // 30) 349 | time_str = f"{months} month{'s' if months != 1 else ''} ago" 350 | else: 351 | years = int(time_diff.days // 365) 352 | time_str = f"{years} year{'s' if years != 1 else ''} ago" 353 | 354 | # Add exact time for reference 355 | exact_time = last_time.strftime("%Y-%m-%d %H:%M:%S") 356 | 357 | await interaction.response.send_message( 358 | f"🍺 {target_user.name}'s last beer was **{time_str}** ({exact_time})" 359 | ) 360 | 361 | 362 | async def setup(bot: BackroomsBot) -> None: 363 | await bot.add_cog(BeerTrackerCog(bot), guild=bot.backrooms) 364 | --------------------------------------------------------------------------------