diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0010cf2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# AGENTS.md + +Single Python package `vibe_bot`: a Discord bot (discord.py, `!` prefix) with SQLite RAG chat history, Kokoro TTS, and image gen/edit via OpenAI-compatible APIs. Python 3.13, managed with uv. Everything lives in `vibe_bot/`. + +## Commands + +- Setup: `uv sync --extra dev` — plain `uv sync` already installs the `dev` dependency group (ruff, pyright); the `dev` extra adds black/debugpy/mypy +- Run bot: `uv run python -m vibe_bot.main` — this logs the real bot into Discord with the token from `.env`; don't run it as a smoke test +- Tests: `uv run pytest vibe_bot/tests/ -v`; single test: `uv run pytest vibe_bot/tests/test_main.py::test_name` +- Checks: `uv run ruff check vibe_bot/`, `uv run mypy vibe_bot/` (strict), `uv run pyright vibe_bot/` (strict), `uv run black --check vibe_bot/` +- Container: `./build.sh` (podman). CI (`.gitea/workflows/build-push.yml`, Gitea) only builds/pushes the image on main/release — lint and tests are not gated; run them locally + +## Setup requirements + +- A repo-root `.env` is required even to run tests: `config.py` calls `load_dotenv()` and raises `RuntimeError` at import time if any required var is missing. Placeholder values suffice for the mocked suite. Never commit it. +- PortAudio is a required system library (kokoro-tts → sounddevice). Without it, ~60 tests in `test_main`/`test_tts` error at import time. On this host: `sudo dnf install portaudio` (the Containerfile installs `portaudio19-dev`). +- TTS needs `kokoro-v1.0.onnx` and `voices-v1.0.bin` in the repo root (baked into the container image). The bot runs without them; only `!speak` degrades. + +## Test suite gotchas + +- Baseline: everything passes except `test_llama_wrapper.py::test_chat_completion_think` and `::test_chat_completion_instruct`. Those two are unmocked live calls to the real `CHAT_ENDPOINT` from `.env` and fail without network access to that API. +- `test_config.py` hardcodes `sys.path.insert(0, "/var/home/ducoterra/Projects/vibe_discord_bots")` (a stale repo path). The test only passes because pytest's cwd fallback finds the package — run tests from the repo root. +- If `uv run ` suddenly fails with `ModuleNotFoundError` or "bad interpreter", the `.venv` shebangs are stale from a repo move: `rm -rf .venv && uv sync --extra dev`. + +## Code map + +- `main.py` — entrypoint. The bot is created at module import (module-level `commands.Bot(...)`), `bot.run()` only under `__main__`. Custom-bot "commands" (`! ...`) are matched in `on_message` against the database, not registered with `bot.command`. +- `config.py` — env loading + import-time validation, voice catalog. +- `database.py` — `ChatDatabase` (embeddings stored as float32 blobs, cosine-similarity RAG, schema auto-migrates on startup) and `CustomBotManager`. +- `llama_wrapper.py` — thin OpenAI-compatible clients for chat / image gen / image edit / embeddings, each with its own endpoint, key, and model. +- `tools.py` — `get_channel_members` is a no-op LangChain `@tool` stub used only for name/description/schema; the real implementation is `get_channel_members_impl(channel)`, wired into the tool executor in `main.py`. + +## Style + +- All four checks (ruff, mypy strict, pyright strict, black) are declared gates and currently pass; keep new code clean under all of them. +- ruff is a dev dependency (installed via `uv add --dev`) with config in `pyproject.toml`; it runs with the default ruleset of the locked version. diff --git a/pyproject.toml b/pyproject.toml index 716bc20..6a0a60b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,6 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pyright>=1.1.398", "mypy>=1.17.0", "black>=25.1.0", "debugpy>=1.8.0", @@ -56,7 +55,17 @@ reportPrivateUsage = false line-length = 88 target-version = ["py313"] +[tool.ruff] +line-length = 88 +target-version = "py313" + [tool.pytest.ini_options] filterwarnings = [ "ignore::pytest.PytestUnraisableExceptionWarning", ] + +[dependency-groups] +dev = [ + "pyright>=1.1.409", + "ruff>=0.16.3", +] diff --git a/uv.lock b/uv.lock index 2a913be..2de583b 100644 --- a/uv.lock +++ b/uv.lock @@ -2015,6 +2015,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] +[[package]] +name = "ruff" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +] + [[package]] name = "scikit-learn" version = "1.8.0" @@ -2461,7 +2486,12 @@ dev = [ { name = "black" }, { name = "debugpy" }, { name = "mypy" }, +] + +[package.dev-dependencies] +dev = [ { name = "pyright" }, + { name = "ruff" }, ] [package.metadata] @@ -2476,7 +2506,6 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.17.0" }, { name = "numpy", specifier = ">=1.24.0" }, { name = "openai", specifier = ">=2.24.0" }, - { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.398" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-env", specifier = ">=1.5.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, @@ -2485,6 +2514,12 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.409" }, + { name = "ruff", specifier = ">=0.16.3" }, +] + [[package]] name = "xxhash" version = "3.7.0" diff --git a/vibe_bot/database.py b/vibe_bot/database.py index 10513df..d5e6af8 100644 --- a/vibe_bot/database.py +++ b/vibe_bot/database.py @@ -504,7 +504,7 @@ _chat_db: ChatDatabase | None = None def get_database() -> ChatDatabase: """Get or create the global database instance.""" - global _chat_db # noqa: PLW0603 + global _chat_db if _chat_db is None: _chat_db = ChatDatabase() return _chat_db diff --git a/vibe_bot/llama_wrapper.py b/vibe_bot/llama_wrapper.py index bbcc48c..6c789f2 100644 --- a/vibe_bot/llama_wrapper.py +++ b/vibe_bot/llama_wrapper.py @@ -7,7 +7,8 @@ Allows custom endpoints for each of the above supported functions. from __future__ import annotations import json -from typing import TYPE_CHECKING, Awaitable, Callable, cast +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, cast import openai import requests diff --git a/vibe_bot/main.py b/vibe_bot/main.py index dbb1dbd..d2c3035 100644 --- a/vibe_bot/main.py +++ b/vibe_bot/main.py @@ -13,7 +13,6 @@ from discord import Message from discord.ext import commands from vibe_bot import llama_wrapper, tts -from vibe_bot.tts import DEFAULT_LANG from vibe_bot.config import ( CHAT_ENDPOINT, CHAT_ENDPOINT_KEY, @@ -35,6 +34,7 @@ from vibe_bot.config import ( ) from vibe_bot.database import CustomBotManager, get_database from vibe_bot.tools import get_channel_members, get_channel_members_impl +from vibe_bot.tts import DEFAULT_LANG if TYPE_CHECKING: from discord.ext.commands import Bot @@ -771,7 +771,7 @@ async def retcon(ctx: CommandsContext[Bot], *, message: str) -> None: for discord_image in ctx.message.attachments: image_url = discord_image.url try: - response = requests.get(image_url, timeout=30) + response = requests.get(image_url, timeout=30) # noqa: ASYNC210 image_data = response.content except requests.RequestException as e: logger.warning("Failed to download image from %s: %s", image_url, e) diff --git a/vibe_bot/tests/conftest.py b/vibe_bot/tests/conftest.py index 0fccbed..fdc5cf5 100644 --- a/vibe_bot/tests/conftest.py +++ b/vibe_bot/tests/conftest.py @@ -103,7 +103,7 @@ def custom_bot_manager(temp_db_path: str) -> CustomBotManager: from vibe_bot.database import CustomBotManager manager = CustomBotManager(db_path=temp_db_path) - return manager # noqa: RET504 + return manager @pytest.fixture @@ -123,16 +123,16 @@ def mock_kokoro_tts() -> Generator[dict[str, Any]]: return_value=mock_kokoro_instance, ), patch("vibe_bot.tts.chunk_text", mock_chunk), + patch("vibe_bot.tts.process_chunk_sequential", mock_process), ): - with patch("vibe_bot.tts.process_chunk_sequential", mock_process): - yield { - "Kokoro": mock_kokoro, - "chunk_text": mock_chunk, - "process_chunk_sequential": mock_process, - "kokoro_instance": mock_kokoro_instance, - "mock_samples": mock_samples, - "mock_sr": 24000, - } + yield { + "Kokoro": mock_kokoro, + "chunk_text": mock_chunk, + "process_chunk_sequential": mock_process, + "kokoro_instance": mock_kokoro_instance, + "mock_samples": mock_samples, + "mock_sr": 24000, + } @pytest.fixture @@ -148,18 +148,20 @@ def mock_discord() -> Generator[dict[str, MagicMock]]: mock_bot_instance.user.name = "test-bot" mock_bot_instance.user.id = "123456789" - with patch("vibe_bot.main.discord") as mock_discord_module: - with patch("vibe_bot.main.commands", MagicMock()): - with patch("vibe_bot.main.commands.Bot", mock_bot_class): - mock_bot_class.return_value = mock_bot_instance - mock_discord_module.Intents = mock_intents - mock_discord_module.Message = MagicMock - mock_discord_module.File = MagicMock - yield { - "Intents": mock_intents, - "Bot": mock_bot_class, - "bot_instance": mock_bot_instance, - } + with ( + patch("vibe_bot.main.discord") as mock_discord_module, + patch("vibe_bot.main.commands", MagicMock()), + patch("vibe_bot.main.commands.Bot", mock_bot_class), + ): + mock_bot_class.return_value = mock_bot_instance + mock_discord_module.Intents = mock_intents + mock_discord_module.Message = MagicMock + mock_discord_module.File = MagicMock + yield { + "Intents": mock_intents, + "Bot": mock_bot_class, + "bot_instance": mock_bot_instance, + } @pytest.fixture @@ -167,9 +169,11 @@ def mock_tts_engine() -> Generator[MagicMock]: """Provide a mock TTSEngine.""" mock_engine = MagicMock() mock_engine.generate_audio.return_value = MagicMock() - with patch("vibe_bot.main.tts_engine", mock_engine): - with patch("vibe_bot.main.tts.TTSEngine", return_value=mock_engine): - yield mock_engine + with ( + patch("vibe_bot.main.tts_engine", mock_engine), + patch("vibe_bot.main.tts.TTSEngine", return_value=mock_engine), + ): + yield mock_engine @pytest.fixture diff --git a/vibe_bot/tests/test_config.py b/vibe_bot/tests/test_config.py index 9786607..08835c4 100644 --- a/vibe_bot/tests/test_config.py +++ b/vibe_bot/tests/test_config.py @@ -68,7 +68,7 @@ assert vibe_bot.config.TTS_SPEED == 1.0 print("OK") """ - result = subprocess.run( # noqa: PLW1510, S603 + result = subprocess.run( # noqa: PLW1510 [sys.executable, "-c", code], capture_output=True, text=True, @@ -99,7 +99,7 @@ except Exception as e: print(f"OTHER: {{type(e).__name__}}: {{e}}") """ - result = subprocess.run( # noqa: PLW1510, S603 + result = subprocess.run( # noqa: PLW1510 [sys.executable, "-c", code], capture_output=True, text=True, diff --git a/vibe_bot/tests/test_main.py b/vibe_bot/tests/test_main.py index 37c16dc..c250380 100644 --- a/vibe_bot/tests/test_main.py +++ b/vibe_bot/tests/test_main.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import UTC from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -44,8 +45,8 @@ def mock_ctx_with_member() -> MagicMock: ctx.author.activities = [mock_activity] from datetime import datetime - ctx.author.joined_at = datetime(2024, 1, 15) - ctx.author.created_at = datetime(2023, 6, 1) + ctx.author.joined_at = datetime(2024, 1, 15, tzinfo=UTC) + ctx.author.created_at = datetime(2023, 6, 1, tzinfo=UTC) ctx.channel.id = "channel-1" ctx.guild.id = "guild-1" ctx.message.id = "msg-1" @@ -481,7 +482,6 @@ def test_speak_plain_with_mock_tts( asyncio.run(main_module._speak_plain(mock_ctx, "hello world", mock_tts_engine)) from vibe_bot.config import TTS_SPEED, TTS_VOICE - from vibe_bot.tts import DEFAULT_LANG mock_tts_engine.generate_audio.assert_called_once_with( diff --git a/vibe_bot/tests/test_tts.py b/vibe_bot/tests/test_tts.py index 5908eb0..df6d036 100644 --- a/vibe_bot/tests/test_tts.py +++ b/vibe_bot/tests/test_tts.py @@ -8,6 +8,10 @@ import numpy as np import pytest +class ChunkProcessingError(Exception): + """Simulates a chunk processing failure in the fake Kokoro processor.""" + + def test_tts_engine_init(mock_kokoro_tts: MagicMock) -> None: """Test TTSEngine initialization.""" from vibe_bot.tts import TTSEngine @@ -91,7 +95,7 @@ def test_generate_audio_chunk_failure(mock_kokoro_tts: MagicMock) -> None: lang: str, ) -> tuple[np.ndarray, int]: if chunk == "bad chunk": - raise Exception("processing error") + raise ChunkProcessingError("processing error") return np.array([0.1, 0.2], dtype=np.float32), 24000 mock_kokoro_tts["chunk_text"].return_value = [