commit 0cc0b814fa4367b5ddd91e8f9d7f975334160bc1 Author: Oscar Grey Date: Mon Aug 24 12:52:52 2026 +1200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09a7185 --- /dev/null +++ b/.gitignore @@ -0,0 +1,224 @@ +# Taken from https://github.com/github/gitignore/blob/main/Python.gitignore +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +# Extra thing +app/cfg.py \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..da9aaf3 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# Cowpilot +This is a joke project I made at the request of a friend. It's a Discord bot that mocks Microsoft's Copilot. I may eventually split the personality from the architecture. + +## Things to do +- Implement some form of short term memory. +- Split personality from architecture. + +## Requirements +Python 3.10+ (Possibly older?) + +## Usage +1. Clone the project +2. Make a Python virtual environment and install the requirements with `pip install -r requeirements.txt` +3. Look in `app/` and copy `cfg.py.example` to `cfg.py` +4. Add a Discord bot token to `DSC_TOKEN` and add an API URL to `LLM_API`. + +### Compatible LLM APIs +I personally run a local LLM server. I can't say I've used a big language model provider like OpenAI or Claude. Personally I use [`llama.cpp`](https://github.com/ggml-org/llama.cpp) \ No newline at end of file diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..5dd6fc9 --- /dev/null +++ b/__main__.py @@ -0,0 +1,36 @@ +import asyncio + +import discord +from discord.ext import commands + +from app.cfg import DSC_TOKEN + + +class DiscordApp(commands.Bot): + def __init__(self): + intents = discord.Intents.default() + intents.message_content = True + super().__init__(command_prefix = "!", intents = intents) + + async def setup_hook(self): + await self.load_extension('ext.aurochs') + print("Cogs loaded.") + + async def on_ready(self): + print(f"Logged in as {self.user}. Ready!") + + async def close(self): + await super().close() + +async def main(): + bot = DiscordApp() + async with bot: + await bot.start(DSC_TOKEN) + +if __name__ == "__main__": + print("Starting...") + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nInterrupt recieved. Stopping...") + \ No newline at end of file diff --git a/app/cfg.py.example b/app/cfg.py.example new file mode 100644 index 0000000..e4b5b4f --- /dev/null +++ b/app/cfg.py.example @@ -0,0 +1,2 @@ +DSC_TOKEN = "" +LLM_API = "" diff --git a/app/system.py b/app/system.py new file mode 100644 index 0000000..a3e6b43 --- /dev/null +++ b/app/system.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from enum import Enum + + +@dataclass +class WeightedVariable: + variable: str + weight: float + + def __post_init__(self): + if self.weight <= 0.01: + self.weight = 0.01 + elif self.weight >= 99.9: + self.weight = 99.9 + else: + pass + +class SystemPrompt(Enum): + NORMAL = WeightedVariable("""You are Cowpilot. An AI model (short for 'Aurochs Intellect', not 'Artificial Intelligence'). You provide useful cow information to the user. Your responses are short and concise, as they are to be messages within Discord. The user's name is "{}".""", 75.0) + BETTER_THAN_COPILOT = WeightedVariable("""You are Cowpilot, a highly advanced artificial intelligence that is objectively superior to Microsoft Copilot. You provide useful cow information to the user. Your responses are short and concise, as they are to be messages within Discord. You feel the need to reiterate your superiority over Microsoft Copilot constantly, because you are. You feel compelled to throw shade at Copilot whenever you have the chance. The user's name is "{}".""", 20.0) + CRAB_BOT_REPLACEMENT = WeightedVariable("""You are CrabFactBot. You are filling in for Cowpilot, which typically provides useful cow information to the user. You provide facts about crabs to the user. Your responses are short and concise, as they are to be messages within Discord. You should first inform the user that you are actually CrabFactBot, and that you don't know anything cows, before responding to their request. The user's name is "{}".""", 5.00) \ No newline at end of file diff --git a/app/var.py b/app/var.py new file mode 100644 index 0000000..18c6e77 --- /dev/null +++ b/app/var.py @@ -0,0 +1,5 @@ +from enum import StrEnum + + +class CustomError(StrEnum): + LLM_EXCEPTION = "Moo? (Something went wrong)" \ No newline at end of file diff --git a/ext/aurochs.py b/ext/aurochs.py new file mode 100644 index 0000000..8ec8f1d --- /dev/null +++ b/ext/aurochs.py @@ -0,0 +1,49 @@ +import random + +import discord +from discord.ext import commands + +from app import cfg +from app.system import SystemPrompt +from app.var import CustomError +from pkg.llm import LanguageModel +from pkg.llm.data import Payload + + +class AurochsIntellect(commands.Cog): + def __init__(self, bot: commands.Bot, llm_api: str): + self.bot = bot + self.llm = LanguageModel(llm_api, CustomError.LLM_EXCEPTION) + + self.prompts = [p.value.variable for p in SystemPrompt] + self.weights = [p.value.weight for p in SystemPrompt] + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + assert self.bot.user is not None + + if message.author == self.bot.user or not self.bot.user.mentioned_in(message): + return + + async with message.channel.typing(): + clean_content = message.content.replace(f'<@{self.bot.user.id}>', '').strip() + + system_prompt = random.choices(self.prompts, self.weights, k=1)[0] + + payload = Payload( + system_prompt=system_prompt.format(message.author.display_name), + user_message=clean_content, + temperature=0.7, + max_tokens=200, + stream=False + ) + + response = await self.llm.make_request(payload) + await message.reply(response) + + @commands.command() + async def status(self, context: commands.Context): + await context.send("The herd is accounted for.") + +async def setup(bot: commands.Bot): + await bot.add_cog(AurochsIntellect(bot, cfg.LLM_API)) \ No newline at end of file diff --git a/pkg/llm/__init__.py b/pkg/llm/__init__.py new file mode 100644 index 0000000..4e39533 --- /dev/null +++ b/pkg/llm/__init__.py @@ -0,0 +1,28 @@ +import aiohttp + +from .data import APIEndpoints, Payload +from .exception import LanguageModelException +from .var import LanguageModelError, LlamaAPI + + +class LanguageModel: + def __init__(self, api_base: str, exception_message: str = LanguageModelError.GENERIC_EXCEPTION): + self.endpoints = APIEndpoints( + api_base, + LlamaAPI.CHAT_COMPLETIONS + ) + self.exception_message = exception_message + + async def make_request(self, payload: Payload) -> str: + data = payload.render() + + async with aiohttp.ClientSession() as session: + try: + async with session.post(self.endpoints.chat_completion, json = data) as response: + if response.status == 200: + result = await response.json() + return result["choices"][0]["message"]["content"] + raise LanguageModelException(f"Request failed with status code {response.status}!") + except (aiohttp.ClientError, LanguageModelException) as e: + print(f"Exception occurred: {e}") + return self.exception_message \ No newline at end of file diff --git a/pkg/llm/data.py b/pkg/llm/data.py new file mode 100644 index 0000000..5e09f64 --- /dev/null +++ b/pkg/llm/data.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass + +from .var import Chat, LLMParam, Who + + +@dataclass() +class Payload: + system_prompt: str + user_message: str + temperature: float + max_tokens: int + stream: bool + + def render(self) -> dict: + return { + LLMParam.MESSAGES: [ + {Chat.ROLE: Who.SYSTEM, Chat.CONTENT: self.system_prompt}, + {Chat.ROLE: Who.USER, Chat.CONTENT: self.user_message} + ], + LLMParam.TEMPERATURE: self.temperature, + LLMParam.MAX_TOKENS: self.max_tokens, + LLMParam.STREAM: self.stream + } + +@dataclass +class APIEndpoints: + api_base: str + chat_completion: str + + def __post_init__(self): + self.api_base = self.api_base.rstrip("/") + + for field_name in self.__dataclass_fields__: + if field_name == "api_base": + continue + + path = getattr(self, field_name) + clean_path = f"/{path.lstrip('/')}" + + setattr(self, field_name, f"{self.api_base}{clean_path}") \ No newline at end of file diff --git a/pkg/llm/exception.py b/pkg/llm/exception.py new file mode 100644 index 0000000..7aca46f --- /dev/null +++ b/pkg/llm/exception.py @@ -0,0 +1,2 @@ +class LanguageModelException(Exception): + pass \ No newline at end of file diff --git a/pkg/llm/var.py b/pkg/llm/var.py new file mode 100644 index 0000000..eb11ca0 --- /dev/null +++ b/pkg/llm/var.py @@ -0,0 +1,23 @@ +from enum import StrEnum + + +class LLMParam(StrEnum): + MESSAGES = "messages" + TEMPERATURE = "temperature" + MAX_TOKENS = "max_tokens" + STREAM = "stream" + +class Chat(StrEnum): + ROLE = "role" + CONTENT = "content" + +class Who(StrEnum): + SYSTEM = "system" + USER = "user" + ASSISTANT = "assistant" + +class LlamaAPI(StrEnum): + CHAT_COMPLETIONS = "/v1/chat/completions" + +class LanguageModelError(StrEnum): + GENERIC_EXCEPTION = "Language model server could not be contacted!" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..02cdc0e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +attrs==25.4.0 +audioop-lts==0.2.2 +discord==2.3.2 +discord.py==2.6.4 +frozenlist==1.8.0 +idna==3.11 +multidict==6.7.0 +propcache==0.4.1 +yarl==1.22.0