37 Commits
Author SHA1 Message Date
LogalDeveloper 6d7f26f7be Addressed Ruff checks for Unix socket startup path handling.
Audit / Dependencies (push) Successful in 6s
CD / Publish (push) Successful in 5s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 7s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
2026-06-11 15:11:51 -04:00
LogalDeveloper 446c0cd9ca Added Unix socket support for the Prometheus metrics server.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Failing after 5s
CI / Tests (push) Successful in 8s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
2026-06-11 15:07:44 -04:00
LogalDeveloper 7b395e1834 Added systemd notify support with ready, stopping, and watchdog notifications.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 7s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
2026-06-11 13:55:04 -04:00
LogalDeveloper b1625bfab5 Updated dependencies.
Audit / Dependencies (push) Successful in 8s
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 7s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
2026-06-11 13:34:45 -04:00
LogalDeveloper b22b1ffab5 Aligned pyproject.toml with standard tooling conventions.
CI / Formatting (push) Successful in 29s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 7s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
2026-06-11 13:28:18 -04:00
LogalDeveloper de19074fc0 Switched Syft SBOM generation to runtime-only venv scan.
Audit / Dependencies (push) Successful in 7s
CD / Publish (push) Successful in 5s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 7s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 4s
2026-05-15 20:33:39 -04:00
LogalDeveloper ef0d1ce144 Updated dependencies.
Audit / Dependencies (push) Successful in 8s
CD / Publish (push) Successful in 6s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 9s
CI / Tests (push) Successful in 12s
CI / Type Checking (push) Successful in 12s
CI / Spelling (push) Successful in 8s
2026-05-15 12:44:52 -04:00
LogalDeveloper 85c71faf2d Added Syft SBOM generation to CD workflow.
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 8s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
2026-05-15 12:35:51 -04:00
LogalDeveloper 362541e13b Updated Gitea Actions workflows to use new CI image.
CI / Formatting (push) Successful in 36s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 7s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
2026-05-15 12:13:43 -04:00
LogalDeveloper 920f9f9f2d Updated dependencies.
Audit / Dependencies (push) Successful in 7s
CD / Publish (push) Successful in 5s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 11s
2026-04-29 09:47:41 -04:00
LogalDeveloper 23172111e9 Hardened Gitea Actions workflows and updated action pins.
CI / Formatting (push) Successful in 26s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 7s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
2026-04-29 09:37:17 -04:00
LogalDeveloper b680f52126 Scoped GITEA_TOKEN to least privilege in Gitea Actions workflows.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 7s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
2026-04-22 12:22:10 -04:00
LogalDeveloper eb81fa478d Updated dependencies.
Audit / Dependencies (push) Successful in 23s
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 7s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 7s
2026-04-21 08:49:52 -04:00
LogalDeveloper 68975fcadd Switched test database fixture to in-memory SQLite.
CD / Publish (push) Successful in 6s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 7s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 4s
Audit / Dependencies (push) Failing after 6s
2026-04-05 09:49:35 -04:00
LogalDeveloper 0b6e292cbd Updated dependencies.
Audit / Dependencies (push) Successful in 7s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 29s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
2026-04-05 09:46:59 -04:00
LogalDeveloper 68e0bc190c Switched event loop to uvloop for improved async performance.
Audit / Dependencies (push) Successful in 7s
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 28s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 4s
2026-04-05 09:44:02 -04:00
LogalDeveloper c577dc5267 Updated dependencies.
Audit / Dependencies (push) Successful in 6s
CD / Publish (push) Successful in 5s
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 27s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 5s
2026-03-29 20:17:09 -04:00
LogalDeveloper abe1444e4c Enabled all Ruff linter rules and fixed resulting violations.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 28s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 5s
2026-03-29 20:08:30 -04:00
LogalDeveloper 603a927f12 Replaced raw string literals with Flag and EntityType enums in database tests.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 29s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 5s
Audit / Dependencies (push) Failing after 7s
2026-03-24 19:18:41 -04:00
LogalDeveloper 2dc8206f6f Added scoping and isolation tests for database operations and uningest.
Audit / Dependencies (push) Successful in 16s
CD / Publish (push) Successful in 7s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 34s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
2026-03-23 20:26:25 -04:00
LogalDeveloper 2b65dfe076 Pre-initialized labels for slash command metrics for immediate visibility.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 23s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
2026-03-23 17:44:54 -04:00
LogalDeveloper 0013726b7f Fixed Ruff formatting issue.
Audit / Dependencies (push) Successful in 6s
CD / Publish (push) Successful in 5s
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 23s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 5s
2026-03-23 16:20:45 -04:00
LogalDeveloper b8f509ac63 Updated dependencies.
Audit / Dependencies (push) Successful in 7s
CI / Formatting (push) Failing after 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 24s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 4s
2026-03-23 16:17:07 -04:00
LogalDeveloper b0fdf12808 Standardized user-facing messages.
CI / Formatting (push) Failing after 4s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 23s
CI / Type Checking (push) Successful in 14s
CI / Spelling (push) Successful in 9s
2026-03-23 16:02:54 -04:00
LogalDeveloper cf46b71883 Added integration tests for uningest_message.
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 24s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 5s
2026-03-23 15:45:31 -04:00
LogalDeveloper bc9f7fa539 Removed redundant metrics, fixed description accuracy, and excluded no-data fallback from generation timing.
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 23s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
2026-03-23 15:31:43 -04:00
LogalDeveloper 70095f3184 Replaced regex with native str methods for whitespace normalization and mention extraction.
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 23s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
2026-03-23 14:39:38 -04:00
LogalDeveloper 0fa51d6199 Added /forgetme slash command with atomic user data deletion, confirmation UI, and Prometheus metrics.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 23s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 5s
2026-03-23 10:06:55 -04:00
LogalDeveloper ca3011011a Added async lock to database transactions to prevent concurrent transaction conflicts.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 22s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 5s
2026-03-23 09:49:44 -04:00
LogalDeveloper 53b8bd3fae Switched to explicit SQLite transactions with rollback safety and batched delete operations.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 23s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 5s
2026-03-23 09:16:46 -04:00
LogalDeveloper 093426ac61 Refactored ingestion to semaphore-bounded tasks, centralized error handling, and consolidated metrics.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 22s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 5s
2026-03-22 22:45:31 -04:00
LogalDeveloper c1f967ece9 Fixed cache shutdown to properly await task cancellation and added names to background tasks.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 22s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
2026-03-22 20:17:06 -04:00
LogalDeveloper 23cc721612 Modernized codebase with NamedTuples, StrEnum, override decorators, slots, and other idiomatic improvements.
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 21s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
2026-03-22 20:02:31 -04:00
LogalDeveloper a23d252f27 Fixed mypy override compatibility error in bot start method signature.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 21s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 4s
Audit / Dependencies (push) Successful in 6s
2026-03-22 14:54:12 -04:00
LogalDeveloper 2cf00fde4f Improved code quality with more idiomatic Python patterns and safer initialization.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 21s
CI / Type Checking (push) Failing after 10s
CI / Spelling (push) Successful in 5s
2026-03-22 14:41:38 -04:00
LogalDeveloper 371f7d2ca3 Added message uningest to reverse Markov data when recently ingested messages are deleted.
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 20s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 5s
2026-03-22 14:05:58 -04:00
LogalDeveloper 12e5e92fe4 Updated dependencies.
Audit / Dependencies (push) Successful in 7s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 16s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 5s
2026-03-19 09:01:33 -04:00
28 changed files with 3188 additions and 881 deletions
+9 -3
View File
@@ -8,22 +8,28 @@ on:
pull_request: pull_request:
paths: [uv.lock] paths: [uv.lock]
permissions:
contents: read
env:
UV_PYTHON_DOWNLOADS: never
jobs: jobs:
audit: audit:
name: Dependencies name: Dependencies
runs-on: logaldeveloper-archlinux runs-on: logaldeveloper-archlinux-ci
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages - name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.cache/uv path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }} key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --locked
- name: Audit dependencies with pip-audit - name: Audit dependencies with pip-audit
run: uv run pip-audit --skip-editable run: uv run pip-audit --skip-editable
+39 -8
View File
@@ -4,29 +4,60 @@ on:
push: push:
tags: ["v*"] tags: ["v*"]
permissions:
contents: read
# packages: write # not yet supported by Gitea
env:
UV_PYTHON_DOWNLOADS: never
jobs: jobs:
publish: publish:
name: Publish name: Publish
runs-on: logaldeveloper-archlinux runs-on: logaldeveloper-archlinux-ci
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --locked --no-dev
- name: Generate package metadata
id: metadata
run: |
version="${{ gitea.ref_name }}"
version="${version#v}"
printf 'version=%s\n' "$version" | tee -a "$GITEA_OUTPUT"
- name: Build package - name: Build package
run: uv build run: uv build
- name: Generate SBOM
env:
SYFT_CHECK_FOR_APP_UPDATE: "false"
run: |
syft scan dir:.venv \
--override-default-catalogers python-installed-package-cataloger \
--select-catalogers=-file \
--source-name git.logal.dev/LogalDeveloper/Crabstero \
--source-version "${{ steps.metadata.outputs.version }}" \
--output syft-table \
--output cyclonedx-json=crabstero-${{ steps.metadata.outputs.version }}.cyclonedx.json
sha256sum crabstero-${{ steps.metadata.outputs.version }}.cyclonedx.json
zstd -T0 --ultra -22 \
crabstero-${{ steps.metadata.outputs.version }}.cyclonedx.json
- name: Publish package - name: Publish package
run: uv publish run: uv publish
env: env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
- name: Upload SBOM artifact
uses: christopherhx/gitea-upload-artifact@8818363695ca2d5782c64f6453273341374767b7 # v7
with:
name: crabstero-cyclonedx-${{ steps.metadata.outputs.version }}
path: crabstero-${{ steps.metadata.outputs.version }}.cyclonedx.json.zst
if-no-files-found: error
archive: "false"
+21 -15
View File
@@ -4,60 +4,66 @@ on:
push: push:
pull_request: pull_request:
permissions:
contents: read
env:
UV_PYTHON_DOWNLOADS: never
jobs: jobs:
formatting: formatting:
name: Formatting name: Formatting
runs-on: logaldeveloper-archlinux runs-on: logaldeveloper-archlinux-ci
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages - name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.cache/uv path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }} key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --locked
- name: Check formatting with Ruff - name: Check formatting with Ruff
run: uv run ruff format --check --diff . run: uv run ruff format --check --diff .
linting: linting:
name: Linting name: Linting
runs-on: logaldeveloper-archlinux runs-on: logaldeveloper-archlinux-ci
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages - name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.cache/uv path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }} key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --locked
- name: Check linting with Ruff - name: Check linting with Ruff
run: uv run ruff check . run: uv run ruff check .
tests: tests:
name: Tests name: Tests
runs-on: logaldeveloper-archlinux runs-on: logaldeveloper-archlinux-ci
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages - name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.cache/uv path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }} key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --locked
- name: Run unit tests with Pytest - name: Run unit tests with Pytest
run: uv run pytest -v --cov --cov-report= run: uv run pytest -v --cov --cov-report=
@@ -67,38 +73,38 @@ jobs:
type-checking: type-checking:
name: Type Checking name: Type Checking
runs-on: logaldeveloper-archlinux runs-on: logaldeveloper-archlinux-ci
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages - name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.cache/uv path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }} key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --locked
- name: Check types with Mypy - name: Check types with Mypy
run: uv run mypy . run: uv run mypy .
spelling: spelling:
name: Spelling name: Spelling
runs-on: logaldeveloper-archlinux runs-on: logaldeveloper-archlinux-ci
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages - name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.cache/uv path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }} key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies - name: Install dependencies
run: uv sync --frozen run: uv sync --locked
- name: Check spelling with codespell - name: Check spelling with codespell
run: uv run codespell run: uv run codespell
+2
View File
@@ -11,6 +11,8 @@ dist/
build/ build/
*.egg-info/ *.egg-info/
*.egg *.egg
/*.cyclonedx.json
/*.cyclonedx.json.zst
# Virtual environments # Virtual environments
venv/ venv/
+137 -24
View File
@@ -15,22 +15,74 @@
"""Entry point for the Crabstero Discord bot. """Entry point for the Crabstero Discord bot.
Provides an argparse CLI with environment variable and systemd credential Provides an argparse CLI with environment variable and systemd credential
fallbacks for --token and --database-path. fallbacks, plus systemd notify/watchdog integration when run as a service.
""" """
import argparse import argparse
import asyncio import asyncio
import contextlib
import logging import logging
import os import os
import signal import signal
import socket
from pathlib import Path from pathlib import Path
import uvloop
from crabstero import __version__ as crabstero_version from crabstero import __version__ as crabstero_version
from crabstero.bot import Crabstero from crabstero.bot import Crabstero
from crabstero.metrics import TcpMetricsAddress, UnixMetricsAddress
logger = logging.getLogger("crabstero") logger = logging.getLogger("crabstero")
def _sd_notify(state: str) -> None:
"""Send a notification to systemd, if running under a systemd service.
:param state: The systemd notification payload.
"""
addr = os.environ.get("NOTIFY_SOCKET")
if not addr:
return
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock:
if addr[0] == "@":
addr = "\0" + addr[1:]
sock.sendto(state.encode(), addr)
except OSError as exc:
logger.debug("Could not send systemd notification: %s", exc)
def _watchdog_interval() -> float | None:
"""Return the systemd watchdog ping interval in seconds, if enabled."""
usec = os.environ.get("WATCHDOG_USEC")
if not usec:
return None
try:
watchdog_usec = int(usec)
except ValueError:
return None
if watchdog_usec <= 0:
return None
return watchdog_usec / 1_000_000 / 2
async def _watchdog_loop(interval: float, stop: asyncio.Event) -> None:
"""Send systemd watchdog pings until *stop* is set.
:param interval: Seconds to wait between watchdog pings.
:param stop: Event that stops the watchdog loop.
"""
while not stop.is_set():
_sd_notify("WATCHDOG=1")
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(stop.wait(), timeout=interval)
def _read_credential(name: str) -> str | None: def _read_credential(name: str) -> str | None:
"""Read a value from a systemd credential file. """Read a value from a systemd credential file.
@@ -80,16 +132,6 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
' variable or "crabstero.db").' ' variable or "crabstero.db").'
), ),
) )
parser.add_argument(
"--ingestion-workers",
type=int,
default=int(os.environ.get("INGESTION_WORKERS", "4")),
help=(
"Number of concurrent ingestion workers"
" (default: INGESTION_WORKERS environment"
" variable or 4)."
),
)
parser.add_argument( parser.add_argument(
"--ingest-only", "--ingest-only",
action="store_true", action="store_true",
@@ -104,8 +146,9 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"--listen-metrics", "--listen-metrics",
default=os.environ.get("LISTEN_METRICS"), default=os.environ.get("LISTEN_METRICS"),
help=( help=(
"Enable Prometheus metrics endpoint on HOST:PORT" "Enable Prometheus metrics endpoint on HOST:PORT or unix:/path.sock"
" (e.g. 127.0.0.1:9090). Disabled by default." " (e.g. 127.0.0.1:9090 or unix:/run/crabstero.sock)."
" Disabled by default."
" (default: LISTEN_METRICS environment variable)." " (default: LISTEN_METRICS environment variable)."
), ),
) )
@@ -113,21 +156,30 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.listen_metrics is not None: if args.listen_metrics is not None:
if args.listen_metrics.startswith("unix:"):
path = args.listen_metrics.removeprefix("unix:")
if not path:
parser.error("--listen-metrics Unix socket path must not be empty")
args.listen_metrics = UnixMetricsAddress(path)
else:
host, sep, port_str = args.listen_metrics.rpartition(":") host, sep, port_str = args.listen_metrics.rpartition(":")
if not sep or not host: if not sep or not host:
parser.error( parser.error(
"--listen-metrics must be in HOST:PORT format (e.g. 127.0.0.1:9090)" "--listen-metrics must be in HOST:PORT or unix:/path.sock format "
"(e.g. 127.0.0.1:9090)",
) )
try: try:
args.listen_metrics = (host, int(port_str)) args.listen_metrics = TcpMetricsAddress(host, int(port_str))
except ValueError: except ValueError:
parser.error(f"--listen-metrics port must be an integer, got '{port_str}'") parser.error(
f"--listen-metrics port must be an integer, got '{port_str}'",
)
if args.token is None: if args.token is None:
parser.error( parser.error(
"a Discord bot token is required via --token," "a Discord bot token is required via --token,"
" the TOKEN environment variable," " the TOKEN environment variable,"
" or a systemd credential named 'token'" " or a systemd credential named 'token'",
) )
return args return args
@@ -147,21 +199,82 @@ def main() -> None:
bot = Crabstero( bot = Crabstero(
token=args.token, token=args.token,
database_path=args.database_path, database_path=args.database_path,
ingestion_workers=args.ingestion_workers,
ingest_only=args.ingest_only, ingest_only=args.ingest_only,
metrics_address=args.listen_metrics, metrics_address=args.listen_metrics,
) )
async def _run() -> None: async def _run() -> None:
async with bot: loop = asyncio.get_running_loop()
await bot.start() watchdog_stop = asyncio.Event()
watchdog_task: asyncio.Task[None] | None = None
shutdown_task: asyncio.Task[None] | None = None
ready_notified = False
stopping_notified = False
# Make SIGTERM behave like SIGINT so systemd stop triggers the same def notify_stopping() -> None:
# clean shutdown path (context manager __aexit__ -> bot.close()). nonlocal stopping_notified
signal.signal(signal.SIGTERM, signal.default_int_handler) if stopping_notified:
return
stopping_notified = True
_sd_notify("STOPPING=1")
async def shutdown() -> None:
logger.info("Received shutdown signal.")
notify_stopping()
await bot.close()
def request_shutdown() -> None:
nonlocal shutdown_task
if shutdown_task is None or shutdown_task.done():
shutdown_task = asyncio.create_task(
shutdown(),
name="crabstero-shutdown",
)
handled_signals = (signal.SIGINT, signal.SIGTERM)
for sig in handled_signals:
loop.add_signal_handler(sig, request_shutdown)
try: try:
asyncio.run(_run()) async with bot:
try:
await bot.login(args.token)
if bot.is_closed() or stopping_notified:
return
_sd_notify("READY=1")
ready_notified = True
wd_interval = _watchdog_interval()
if wd_interval is not None:
logger.info(
"systemd watchdog enabled (pinging every %.1fs).",
wd_interval,
)
watchdog_task = asyncio.create_task(
_watchdog_loop(wd_interval, watchdog_stop),
name="systemd-watchdog",
)
await bot.connect(reconnect=True)
finally:
if ready_notified:
notify_stopping()
watchdog_stop.set()
if watchdog_task is not None:
watchdog_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await watchdog_task
if shutdown_task is not None:
await shutdown_task
finally:
for sig in handled_signals:
loop.remove_signal_handler(sig)
try:
uvloop.run(_run())
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("Interrupted.") logger.info("Interrupted.")
+201 -54
View File
@@ -15,53 +15,60 @@
"""The simple nonversation Discord bot. """The simple nonversation Discord bot.
Provides the Crabstero subclass that owns the full bot lifecycle: database connection, Provides the Crabstero subclass that owns the full bot lifecycle: database connection,
cog loading, ingestion worker pool, and graceful shutdown. cog loading, background ingestion, and graceful shutdown.
""" """
import asyncio import asyncio
import logging import logging
from typing import override
import discord import discord
from discord import app_commands from discord import app_commands
from discord.app_commands import AppCommandError, CommandInvokeError
from discord.ext import commands from discord.ext import commands
from crabstero import __version__ as crabstero_version from crabstero import __version__ as crabstero_version
from crabstero.cache import IngestCache
from crabstero.database import Database from crabstero.database import Database
from crabstero.listeners import interaction, message, server_events
from crabstero.metrics import ( from crabstero.metrics import (
DISCORD_EVENTS, DISCORD_EVENTS,
DISCORD_LATENCY, DISCORD_LATENCY,
ERRORS,
GUILD_COUNT, GUILD_COUNT,
INGESTION_BACKLOG, INGESTION_ACTIVE,
MetricsAddress,
MetricsServer, MetricsServer,
) )
from crabstero.tasks.ingestion import ingest_channel from crabstero.tasks.ingestion import ingest_channel
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
type IngestableChannel = discord.TextChannel | discord.VoiceChannel
_MAX_CONCURRENT_INGESTIONS = 4
class Crabstero(commands.Bot): class Crabstero(commands.Bot):
"""Central bot subclass that owns all lifecycle state. """Central bot subclass that owns all lifecycle state.
The database is opened in setup_hook and closed in close(). Background The database is opened in setup_hook and closed in close(). Background
ingestion is handled by a bounded queue and a fixed worker pool. ingestion is handled by semaphore-bounded dynamic tasks.
""" """
def __init__( def __init__(
self, self,
token: str, token: str,
database_path: str, database_path: str,
ingestion_workers: int = 4, *,
ingest_only: bool = False, ingest_only: bool = False,
metrics_address: tuple[str, int] | None = None, metrics_address: MetricsAddress | None = None,
) -> None: ) -> None:
"""Configure intents, store configuration, and prepare ingestion queue state. """Configure intents, store configuration, and prepare ingestion state.
:param token: The Discord bot token. :param token: The Discord bot token.
:param database_path: The file path to the SQLite database. :param database_path: The file path to the SQLite database.
:param ingestion_workers: The number of concurrent ingestion workers.
:param ingest_only: When True, the bot only ingests data and never responds. :param ingest_only: When True, the bot only ingests data and never responds.
:param metrics_address: Optional (host, port) for the Prometheus metrics server. :param metrics_address: Optional address for the Prometheus metrics server.
""" """
intents = discord.Intents.default() intents = discord.Intents.default()
intents.guilds = True intents.guilds = True
@@ -76,31 +83,54 @@ class Crabstero(commands.Bot):
self._token = token self._token = token
self._database_path = database_path self._database_path = database_path
self._ingestion_worker_count = ingestion_workers
self._ingest_only = ingest_only self._ingest_only = ingest_only
self._ingestion_queue: asyncio.Queue[ self._ingestion_semaphore = asyncio.Semaphore(_MAX_CONCURRENT_INGESTIONS)
discord.TextChannel | discord.VoiceChannel self._ingestion_tasks: dict[int, asyncio.Task[None]] = {}
] = asyncio.Queue() self._db: Database | None = None
self._ingestion_workers: list[asyncio.Task[None]] = [] self.ingest_cache = IngestCache()
self.db: Database
self._metrics_address = metrics_address self._metrics_address = metrics_address
self._metrics_server: MetricsServer | None = None
DISCORD_LATENCY.set_function(lambda: self.latency) DISCORD_LATENCY.set_function(lambda: self.latency)
GUILD_COUNT.set_function(lambda: len(self.guilds)) GUILD_COUNT.set_function(lambda: len(self.guilds))
INGESTION_BACKLOG.set_function(lambda: self._ingestion_queue.qsize()) INGESTION_ACTIVE.set_function(lambda: len(self._ingestion_tasks))
repo_url = "https://git.logal.dev/LogalDeveloper/Crabstero" repo_url = "https://git.logal.dev/LogalDeveloper/Crabstero"
self.http.user_agent = f"DiscordBot ({repo_url}, {crabstero_version})" self.http.user_agent = f"DiscordBot ({repo_url}, {crabstero_version})"
@property
def db(self) -> Database:
"""The active database connection.
:raises RuntimeError: If accessed before :meth:`setup_hook` has run.
"""
if self._db is None:
msg = "Database is not initialized"
raise RuntimeError(msg)
return self._db
@property
def ingest_only(self) -> bool:
"""Whether the bot is running in ingest-only mode."""
return self._ingest_only
@override
async def setup_hook(self) -> None: async def setup_hook(self) -> None:
"""Open the database, start ingestion workers, and load all cogs.""" """Open the database, start the ingest cache, and load all cogs."""
self.db = await Database.connect(self._database_path) self._db = await Database.connect(self._database_path)
self._start_ingestion_workers() self.ingest_cache.start()
if self._metrics_address is not None: if self._metrics_address is not None:
self._metrics_server = MetricsServer(*self._metrics_address) server = MetricsServer(self._metrics_address)
await self._metrics_server.start() await server.start()
self._metrics_server = server
from crabstero.listeners import ( # noqa: PLC0415
interaction,
message,
server_events,
)
if not self._ingest_only: if not self._ingest_only:
await interaction.setup(self) await interaction.setup(self)
@@ -108,6 +138,48 @@ class Crabstero(commands.Bot):
await message.setup(self) await message.setup(self)
await server_events.setup(self) await server_events.setup(self)
@self.tree.error
async def on_app_command_error(
interaction: discord.Interaction,
error: AppCommandError,
) -> None:
original = (
error.original if isinstance(error, CommandInvokeError) else error
)
command_name = (
interaction.command.name if interaction.command else "unknown"
)
ERRORS.labels(source="command").inc()
logger.error(
"Unhandled exception in app command '%s'.",
command_name,
exc_info=original,
)
if self._metrics_server is not None:
reply = (
"I encountered an error while processing this command."
" The developer has been notified,"
" please try again later."
)
else:
reply = (
"I encountered an error while processing this command."
" Please try again later."
)
try:
if interaction.response.is_done():
await interaction.followup.send(reply, ephemeral=True)
else:
await interaction.response.send_message(reply, ephemeral=True)
except discord.HTTPException:
logger.debug(
"Failed to send error response for command '%s'.",
command_name,
)
if not self._ingest_only: if not self._ingest_only:
# Only sync slash commands if the registered commands # Only sync slash commands if the registered commands
# differ from local definitions. # differ from local definitions.
@@ -128,6 +200,7 @@ class Crabstero(commands.Bot):
logger.info("Slash command tree has changed, syncing with Discord.") logger.info("Slash command tree has changed, syncing with Discord.")
await self.tree.sync() await self.tree.sync()
@override
def dispatch(self, event: str, /, *args: object, **kwargs: object) -> None: def dispatch(self, event: str, /, *args: object, **kwargs: object) -> None:
"""Dispatch an event, incrementing the events counter. """Dispatch an event, incrementing the events counter.
@@ -136,53 +209,127 @@ class Crabstero(commands.Bot):
DISCORD_EVENTS.labels(event=event).inc() DISCORD_EVENTS.labels(event=event).inc()
super().dispatch(event, *args, **kwargs) super().dispatch(event, *args, **kwargs)
@override
async def on_error(
self,
event_method: str,
/,
*args: object,
**kwargs: object,
) -> None:
"""Increment the global error counter for event listener exceptions.
:param event_method: The name of the event that raised the exception.
"""
ERRORS.labels(source=event_method).inc()
logger.error("Unhandled exception in %s.", event_method, exc_info=True) # noqa: LOG014
async def on_ready(self) -> None: async def on_ready(self) -> None:
"""Log that the bot has started successfully.""" """Log that the bot has started successfully."""
logger.info("Crabstero started!") logger.info("Crabstero started!")
@override
async def start(self, token: str = "", *, reconnect: bool = True) -> None: async def start(self, token: str = "", *, reconnect: bool = True) -> None:
"""Start the bot using the stored token by default. """Start the bot using the token provided at initialization."""
await super().start(self._token, reconnect=reconnect)
:param token: Optional token override. Falls back to the stored token if empty.
:param reconnect: Whether to automatically reconnect on disconnect.
"""
await super().start(token or self._token, reconnect=reconnect)
@override
async def close(self) -> None: async def close(self) -> None:
"""Cancel ingestion workers, close the database, and then the bot connection.""" """Cancel ingestion tasks, close the database, and then the bot connection."""
if self.is_closed(): if self.is_closed():
return return
logger.info("Shutting down Crabstero...") logger.info("Shutting down Crabstero...")
for worker in self._ingestion_workers: tasks = list(self._ingestion_tasks.values())
worker.cancel() self._ingestion_tasks.clear()
await asyncio.gather(*self._ingestion_workers, return_exceptions=True) for task in tasks:
self._ingestion_workers.clear() task.cancel()
if hasattr(self, "_metrics_server"): await asyncio.gather(*tasks, return_exceptions=True)
await self.ingest_cache.stop()
if self._metrics_server is not None:
await self._metrics_server.stop() await self._metrics_server.stop()
if hasattr(self, "db"): if self._db is not None:
await self.db.close() await self._db.close()
await super().close() await super().close()
def queue_channel_for_ingestion( def queue_channel_for_ingestion(self, channel: IngestableChannel) -> None:
self, channel: discord.TextChannel | discord.VoiceChannel """Create a background task to ingest a single channel.
) -> None:
"""Enqueue a single channel for background message history ingestion.
:param channel: The channel to enqueue. Duplicate requests for a channel that is already in-flight are ignored.
Concurrency is bounded by the ingestion semaphore.
:param channel: The channel to ingest.
""" """
self._ingestion_queue.put_nowait(channel) if channel.id in self._ingestion_tasks:
return
task = asyncio.create_task(
self._ingest_one(channel),
name=f"ingest-{channel.id}",
)
self._ingestion_tasks[channel.id] = task
task.add_done_callback(lambda t: self._on_ingestion_done(channel.id, t))
def _start_ingestion_workers(self) -> None: async def _ingest_one(self, channel: IngestableChannel) -> None:
"""Spawn the fixed pool of ingestion worker tasks.""" """Acquire the semaphore and ingest one channel."""
for _ in range(self._ingestion_worker_count): async with self._ingestion_semaphore:
task = asyncio.create_task(self._ingestion_worker())
self._ingestion_workers.append(task)
async def _ingestion_worker(self) -> None:
"""Loop forever pulling channels from the ingestion queue and ingesting them."""
while True:
channel = await self._ingestion_queue.get()
try:
await ingest_channel(channel, self.db) await ingest_channel(channel, self.db)
finally:
self._ingestion_queue.task_done() def _on_ingestion_done(self, channel_id: int, task: asyncio.Task[None]) -> None:
"""Clean up a finished ingestion task and log any errors."""
self._ingestion_tasks.pop(channel_id, None)
if task.cancelled():
return
exc = task.exception()
if exc is not None:
ERRORS.labels(source="ingestion").inc()
logger.error("Ingestion task failed.", exc_info=exc)
class TrackedView(discord.ui.View):
"""Base View that increments the global error counter on failures."""
@override
async def on_error(
self,
interaction: discord.Interaction,
error: Exception,
item: discord.ui.Item[TrackedView],
/,
) -> None:
"""Increment the error counter and log the exception.
:param interaction: The interaction that led to the failure.
:param error: The exception that was raised.
:param item: The item that failed the dispatch.
"""
ERRORS.labels(source="view").inc()
logger.error(
"Unhandled exception in view %r for item %r.",
self,
item,
exc_info=error,
)
class TrackedModal(discord.ui.Modal):
"""Base Modal that increments the global error counter on failures."""
@override
async def on_error(
self,
interaction: discord.Interaction,
error: Exception,
item: discord.ui.Item[TrackedModal] | None = None,
/,
) -> None:
"""Increment the error counter and log the exception.
:param interaction: The interaction that led to the failure.
:param error: The exception that was raised.
:param item: Unused. Present for BaseView signature compatibility.
"""
ERRORS.labels(source="modal").inc()
logger.error(
"Unhandled exception in modal %r.",
self,
exc_info=error,
)
+122
View File
@@ -0,0 +1,122 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""TTL cache for recently ingested messages, enabling uningest on delete."""
import asyncio
import contextlib
import time
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class CachedMessage:
"""Data stored for a recently ingested message.
:param channel_id: The Discord channel ID the message was ingested into.
:param user_id: The Discord user ID of the message author.
:param content: The text content of the message, if any.
:param embed_texts: Titles and descriptions extracted from embeds.
:param image_urls: Image URLs extracted from embeds.
"""
channel_id: int
user_id: int
content: str | None
embed_texts: list[str]
image_urls: list[str]
class IngestCache:
"""In-memory TTL cache mapping message IDs to their ingested data.
Entries expire after ``ttl_seconds`` and are evicted by a periodic
background task.
:param ttl_seconds: How long entries remain valid, in seconds.
:param cleanup_interval_seconds: How often the background task runs.
"""
__slots__ = ("_cleanup_interval", "_entries", "_task", "_ttl")
def __init__(
self,
ttl_seconds: float = 120,
cleanup_interval_seconds: float = 30,
) -> None:
"""Create a new cache with the given TTL and cleanup interval.
:param ttl_seconds: How long entries remain valid, in seconds.
:param cleanup_interval_seconds: How often the background task runs.
"""
self._ttl = ttl_seconds
self._cleanup_interval = cleanup_interval_seconds
self._entries: dict[int, tuple[float, CachedMessage]] = {}
self._task: asyncio.Task[None] | None = None
def put(self, message_id: int, entry: CachedMessage) -> None:
"""Store a cache entry for a message.
:param message_id: The Discord message ID.
:param entry: The cached message data.
"""
self._entries[message_id] = (time.monotonic(), entry)
def pop(self, message_id: int) -> CachedMessage | None:
"""Remove and return a cache entry if it exists and has not expired.
:param message_id: The Discord message ID.
:return: The cached message data, or None.
"""
pair = self._entries.pop(message_id, None)
if pair is None:
return None
stored_at, entry = pair
if time.monotonic() - stored_at > self._ttl:
return None
return entry
def _cleanup(self) -> None:
"""Remove all expired entries from the cache."""
now = time.monotonic()
expired = [
mid
for mid, (stored_at, _) in self._entries.items()
if now - stored_at > self._ttl
]
for mid in expired:
del self._entries[mid]
def start(self) -> None:
"""Start the periodic background cleanup task."""
if self._task is not None:
return
self._task = asyncio.create_task(
self._cleanup_loop(),
name="ingest-cache-cleanup",
)
async def stop(self) -> None:
"""Stop the periodic background cleanup task and wait for it to finish."""
if self._task is not None:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
async def _cleanup_loop(self) -> None:
"""Run cleanup on a fixed interval until cancelled."""
while True:
await asyncio.sleep(self._cleanup_interval)
self._cleanup()
+180 -22
View File
@@ -14,10 +14,41 @@
"""Async SQLite database access for Crabstero's persistent storage.""" """Async SQLite database access for Crabstero's persistent storage."""
from typing import Self import asyncio
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, NamedTuple, Self
import aiosqlite import aiosqlite
if TYPE_CHECKING:
from collections.abc import AsyncIterator
class StartWord(NamedTuple):
"""A Markov chain starting word row."""
channel_id: int
user_id: int
word: str
class Transition(NamedTuple):
"""A Markov chain word transition row."""
channel_id: int
user_id: int
word: str
next_word: str
class ChannelImage(NamedTuple):
"""An image URL associated with a channel."""
channel_id: int
user_id: int
url: str
# SQL statements for creating the database schema. # SQL statements for creating the database schema.
_SCHEMA = """ _SCHEMA = """
-- Markov chain starting words. -- Markov chain starting words.
@@ -79,6 +110,24 @@ class Database:
:param connection: An open aiosqlite connection. :param connection: An open aiosqlite connection.
""" """
self._connection = connection self._connection = connection
self._tx_lock = asyncio.Lock()
@asynccontextmanager
async def _transaction(self) -> AsyncIterator[None]:
"""Begin an immediate write transaction.
Acquires an async lock so that only one transaction runs at a
time, then commits on success or rolls back on error.
"""
async with self._tx_lock:
await self._connection.execute("BEGIN IMMEDIATE")
try:
yield
except BaseException:
await self._connection.rollback()
raise
else:
await self._connection.commit()
@classmethod @classmethod
async def connect(cls, path: str) -> Self: async def connect(cls, path: str) -> Self:
@@ -90,7 +139,7 @@ class Database:
:param path: The file path to the SQLite database. :param path: The file path to the SQLite database.
:return: A new Database instance ready for use. :return: A new Database instance ready for use.
""" """
connection = await aiosqlite.connect(path) connection = await aiosqlite.connect(path, isolation_level=None)
# Set synchronous to NORMAL for a balance between safety and speed. # Set synchronous to NORMAL for a balance between safety and speed.
await connection.execute("PRAGMA synchronous=NORMAL") await connection.execute("PRAGMA synchronous=NORMAL")
@@ -107,16 +156,18 @@ class Database:
async def add_markov_data( async def add_markov_data(
self, self,
start_words: list[tuple[int, int, str]], start_words: list[StartWord],
transitions: list[tuple[int, int, str, str]], transitions: list[Transition],
) -> None: ) -> None:
"""Insert Markov start words and transitions, then commit. """Insert Markov start words and transitions, then commit.
Both inserts happen in a single transaction. Both inserts happen in a single transaction.
:param start_words: A list of (channel_id, user_id, word) tuples. :param start_words: Starting word entries to insert.
:param transitions: A list of (channel_id, user_id, word, next_word) tuples. :param transitions: Transition entries to insert.
""" """
if start_words or transitions:
async with self._transaction():
if start_words: if start_words:
await self._connection.executemany( await self._connection.executemany(
"INSERT INTO markov_start_words" "INSERT INTO markov_start_words"
@@ -131,8 +182,48 @@ class Database:
" VALUES (?, ?, ?, ?)", " VALUES (?, ?, ?, ?)",
transitions, transitions,
) )
async def remove_markov_data(
self,
start_words: list[StartWord],
transitions: list[Transition],
) -> None:
"""Remove one matching row per entry from the Markov tables.
Each entry removes at most one duplicate row, preserving remaining
frequency weight.
:param start_words: Starting word entries to remove.
:param transitions: Transition entries to remove.
"""
if start_words or transitions: if start_words or transitions:
await self._connection.commit() async with self._transaction():
if start_words:
await self._connection.executemany(
"DELETE FROM markov_start_words"
" WHERE rowid = ("
" SELECT rowid FROM markov_start_words"
" WHERE channel_id = ?"
" AND user_id = ?"
" AND word = ?"
" LIMIT 1"
" )",
start_words,
)
if transitions:
await self._connection.executemany(
"DELETE FROM markov_transitions"
" WHERE rowid = ("
" SELECT rowid"
" FROM markov_transitions"
" WHERE channel_id = ?"
" AND user_id = ?"
" AND word = ?"
" AND next_word = ?"
" LIMIT 1"
" )",
transitions,
)
async def get_random_start_word(self, channel_id: int) -> str | None: async def get_random_start_word(self, channel_id: int) -> str | None:
"""Return a random starting word for a channel. """Return a random starting word for a channel.
@@ -170,13 +261,17 @@ class Database:
return row[0] if row else None return row[0] if row else None
async def get_random_completing_next_word( async def get_random_completing_next_word(
self, channel_id: int, word: str self,
channel_id: int,
word: str,
) -> str | None: ) -> str | None:
"""Return a random sentence-ending next word for a given word in a channel. """Return a random sentence-ending next word for a given word in a channel.
Weighted by occurrence frequency. A completing word is one whose last Weighted by occurrence frequency. A completing word is one whose last
character is '.', '!', '?', or '§'. character is '.', '!', '?', or '§'.
The set of terminators must match ``markov._TERMINATORS``.
:param channel_id: The Discord channel ID. :param channel_id: The Discord channel ID.
:param word: The current word to find a completing transition for. :param word: The current word to find a completing transition for.
:return: A random completing next word, or None if none exist. :return: A random completing next word, or None if none exist.
@@ -192,18 +287,41 @@ class Database:
row = await cursor.fetchone() row = await cursor.fetchone()
return row[0] if row else None return row[0] if row else None
async def add_image(self, channel_id: int, user_id: int, url: str) -> None: async def add_images(self, images: list[ChannelImage]) -> None:
"""Store an image URL for a given channel. """Store image URLs for a given channel.
:param channel_id: The Discord channel ID. :param images: Image entries to insert.
:param user_id: The Discord user ID of the contributor.
:param url: The image URL to store.
""" """
await self._connection.execute( if images:
"INSERT INTO channel_images (channel_id, user_id, url) VALUES (?, ?, ?)", async with self._transaction():
(channel_id, user_id, url), await self._connection.executemany(
"INSERT INTO channel_images"
" (channel_id, user_id, url)"
" VALUES (?, ?, ?)",
images,
)
async def remove_images(self, images: list[ChannelImage]) -> None:
"""Remove one matching row per entry from the images table.
Each entry removes at most one duplicate row, preserving remaining
frequency weight.
:param images: Image entries to remove.
"""
if images:
async with self._transaction():
await self._connection.executemany(
"DELETE FROM channel_images"
" WHERE rowid = ("
" SELECT rowid FROM channel_images"
" WHERE channel_id = ?"
" AND user_id = ?"
" AND url = ?"
" LIMIT 1"
" )",
images,
) )
await self._connection.commit()
async def get_random_image(self, channel_id: int) -> str | None: async def get_random_image(self, channel_id: int) -> str | None:
"""Return a random image URL for a given channel. """Return a random image URL for a given channel.
@@ -227,16 +345,19 @@ class Database:
:param entity_id: The Discord ID of the entity. :param entity_id: The Discord ID of the entity.
:param flag_name: The name of the flag to set. :param flag_name: The name of the flag to set.
""" """
async with self._transaction():
await self._connection.execute( await self._connection.execute(
"INSERT OR IGNORE INTO flags" "INSERT OR IGNORE INTO flags"
" (entity_type, entity_id, flag_name)" " (entity_type, entity_id, flag_name)"
" VALUES (?, ?, ?)", " VALUES (?, ?, ?)",
(entity_type, entity_id, flag_name), (entity_type, entity_id, flag_name),
) )
await self._connection.commit()
async def clear_flag( async def clear_flag(
self, entity_type: str, entity_id: str, flag_name: str self,
entity_type: str,
entity_id: str,
flag_name: str,
) -> None: ) -> None:
"""Clear a flag on a given entity. If the flag is not set, this is a no-op. """Clear a flag on a given entity. If the flag is not set, this is a no-op.
@@ -244,6 +365,7 @@ class Database:
:param entity_id: The Discord ID of the entity. :param entity_id: The Discord ID of the entity.
:param flag_name: The name of the flag to clear. :param flag_name: The name of the flag to clear.
""" """
async with self._transaction():
await self._connection.execute( await self._connection.execute(
"DELETE FROM flags" "DELETE FROM flags"
" WHERE entity_type = ?" " WHERE entity_type = ?"
@@ -251,10 +373,12 @@ class Database:
" AND flag_name = ?", " AND flag_name = ?",
(entity_type, entity_id, flag_name), (entity_type, entity_id, flag_name),
) )
await self._connection.commit()
async def is_flag_set( async def is_flag_set(
self, entity_type: str, entity_id: str, flag_name: str self,
entity_type: str,
entity_id: str,
flag_name: str,
) -> bool: ) -> bool:
"""Check whether a flag is set on a given entity. """Check whether a flag is set on a given entity.
@@ -289,8 +413,42 @@ class Database:
:param channel_id: The Discord channel ID. :param channel_id: The Discord channel ID.
""" """
async with self._transaction():
await self._connection.execute( await self._connection.execute(
"INSERT OR IGNORE INTO ingested_channels (channel_id) VALUES (?)", "INSERT OR IGNORE INTO ingested_channels (channel_id) VALUES (?)",
(channel_id,), (channel_id,),
) )
await self._connection.commit()
async def forget_user(self, user_id: int, no_ingest_flag: str) -> None:
"""Delete all user data, clear flags, and set noIngest atomically.
Sets the noIngest flag, clears all other user flags, and deletes
all Markov and image data in a single transaction.
:param user_id: The Discord user ID to forget.
:param no_ingest_flag: The flag name for noIngest.
"""
entity_id = str(user_id)
async with self._transaction():
await self._connection.execute(
"INSERT OR IGNORE INTO flags (entity_type, entity_id, flag_name)"
" VALUES ('user', ?, ?)",
(entity_id, no_ingest_flag),
)
await self._connection.execute(
"DELETE FROM flags WHERE entity_type = 'user'"
" AND entity_id = ? AND flag_name != ?",
(entity_id, no_ingest_flag),
)
await self._connection.execute(
"DELETE FROM markov_start_words WHERE user_id = ?",
(user_id,),
)
await self._connection.execute(
"DELETE FROM markov_transitions WHERE user_id = ?",
(user_id,),
)
await self._connection.execute(
"DELETE FROM channel_images WHERE user_id = ?",
(user_id,),
)
+5 -5
View File
@@ -27,7 +27,7 @@ if TYPE_CHECKING:
from crabstero.database import Database from crabstero.database import Database
class Flag(enum.Enum): class Flag(enum.StrEnum):
"""Enum of all supported flag names.""" """Enum of all supported flag names."""
NO_REPLY = "noReply" NO_REPLY = "noReply"
@@ -35,7 +35,7 @@ class Flag(enum.Enum):
ALLOW_PINGS = "allowPings" ALLOW_PINGS = "allowPings"
class EntityType(enum.Enum): class EntityType(enum.StrEnum):
"""Enum of entity types that can have flags.""" """Enum of entity types that can have flags."""
CHANNEL = "channel" CHANNEL = "channel"
@@ -65,7 +65,7 @@ async def set_flag(
:param entity_type: The type of the entity. :param entity_type: The type of the entity.
:param flag: The flag to set. :param flag: The flag to set.
""" """
await db.set_flag(entity_type.value, _entity_id(entity), flag.value) await db.set_flag(entity_type, _entity_id(entity), flag)
async def clear_flag( async def clear_flag(
@@ -81,7 +81,7 @@ async def clear_flag(
:param entity_type: The type of the entity. :param entity_type: The type of the entity.
:param flag: The flag to clear. :param flag: The flag to clear.
""" """
await db.clear_flag(entity_type.value, _entity_id(entity), flag.value) await db.clear_flag(entity_type, _entity_id(entity), flag)
async def is_flag_set( async def is_flag_set(
@@ -98,4 +98,4 @@ async def is_flag_set(
:param flag: The flag to check for. :param flag: The flag to check for.
:return: True if the flag is set, False otherwise. :return: True if the flag is set, False otherwise.
""" """
return await db.is_flag_set(entity_type.value, _entity_id(entity), flag.value) return await db.is_flag_set(entity_type, _entity_id(entity), flag)
+14
View File
@@ -1 +1,15 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Listener cogs for Discord events.""" """Listener cogs for Discord events."""
+140 -18
View File
@@ -14,25 +14,122 @@
"""Handles responding to interactions. """Handles responding to interactions.
Provides the /pingme slash command as a Cog with an app command. Provides the /pingme and /forgetme slash commands as a Cog with app commands.
""" """
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, override
import discord import discord
from discord import app_commands from discord import app_commands
from discord.ext import commands from discord.ext import commands
from crabstero import flags, metrics from crabstero import flags, metrics
from crabstero.bot import TrackedView
from crabstero.flags import EntityType, Flag from crabstero.flags import EntityType, Flag
if TYPE_CHECKING: if TYPE_CHECKING:
from crabstero.bot import Crabstero from crabstero.bot import Crabstero
from crabstero.database import Database
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ForgetMeView(TrackedView):
"""Confirmation view with Confirm/Cancel buttons for /forgetme."""
def __init__(
self,
user_id: int,
db: Database,
interaction: discord.Interaction,
) -> None:
"""Create a new ForgetMeView.
:param user_id: The Discord user ID who invoked the command.
:param db: The database instance.
:param interaction: The original interaction for editing on timeout.
"""
super().__init__()
self._user_id = user_id
self._db = db
self._responded = False
self._interaction = interaction
@override
async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Only allow the invoking user to interact with the buttons.
:param interaction: The interaction event.
:return: True if the user matches, False otherwise.
"""
return interaction.user.id == self._user_id
@discord.ui.button(label="Confirm", style=discord.ButtonStyle.danger)
async def confirm(
self,
interaction: discord.Interaction,
_button: discord.ui.Button[ForgetMeView],
) -> None:
"""Delete all user data, clear flags, and set noIngest.
:param interaction: The interaction event.
:param _button: The button that was pressed.
"""
self._responded = True
await self._db.forget_user(self._user_id, Flag.NO_INGEST)
metrics.FORGETME.labels(outcome="completed").inc()
await interaction.response.edit_message(
content=(
"I have deleted your data and I will not use"
" your messages going forward."
),
view=None,
)
self.stop()
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary)
async def cancel(
self,
interaction: discord.Interaction,
_button: discord.ui.Button[ForgetMeView],
) -> None:
"""Cancel the /forgetme action.
:param interaction: The interaction event.
:param _button: The button that was pressed.
"""
self._responded = True
metrics.FORGETME.labels(outcome="cancelled").inc()
await interaction.response.edit_message(
content="Action cancelled. I have not modified your data.",
view=None,
)
self.stop()
@override
async def on_timeout(self) -> None:
"""Remove buttons and increment timeout metric."""
if self._responded:
return
metrics.FORGETME.labels(outcome="timeout").inc()
try:
await self._interaction.edit_original_response(
content="This timed out. Run `/forgetme` again if you still want to.",
view=None,
)
except discord.NotFound:
logger.debug(
"Original /forgetme response for user %s was already deleted.",
self._user_id,
)
except discord.HTTPException:
logger.debug(
"Failed to edit timed-out /forgetme response for user %s.",
self._user_id,
)
class InteractionCog(commands.Cog): class InteractionCog(commands.Cog):
"""Cog for handling slash command interactions.""" """Cog for handling slash command interactions."""
@@ -46,8 +143,7 @@ class InteractionCog(commands.Cog):
@app_commands.command( @app_commands.command(
name="pingme", name="pingme",
description=( description=(
"Opts into (or back out of) receiving pings" "Toggle whether you receive pings for generated messages that mention you."
" for generated message which mention you."
), ),
) )
async def pingme(self, interaction: discord.Interaction) -> None: async def pingme(self, interaction: discord.Interaction) -> None:
@@ -55,15 +151,19 @@ class InteractionCog(commands.Cog):
:param interaction: The interaction event. :param interaction: The interaction event.
""" """
# Wrap in try/except to always send a response even if the database fails.
try:
if await flags.is_flag_set( if await flags.is_flag_set(
self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS self.bot.db,
interaction.user,
EntityType.USER,
Flag.ALLOW_PINGS,
): ):
await flags.clear_flag( await flags.clear_flag(
self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS self.bot.db,
interaction.user,
EntityType.USER,
Flag.ALLOW_PINGS,
) )
metrics.PINGME_OPTED_OUT.inc() metrics.PINGME.labels(outcome="opted_out").inc()
await interaction.response.send_message( await interaction.response.send_message(
"I will no longer ping you for messages which" "I will no longer ping you for messages which"
" mention you. If you decide to opt back in," " mention you. If you decide to opt back in,"
@@ -72,9 +172,12 @@ class InteractionCog(commands.Cog):
) )
else: else:
await flags.set_flag( await flags.set_flag(
self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS self.bot.db,
interaction.user,
EntityType.USER,
Flag.ALLOW_PINGS,
) )
metrics.PINGME_OPTED_IN.inc() metrics.PINGME.labels(outcome="opted_in").inc()
await interaction.response.send_message( await interaction.response.send_message(
"I will now ping you for messages which mention" "I will now ping you for messages which mention"
" you. If you change your mind, run" " you. If you change your mind, run"
@@ -82,15 +185,34 @@ class InteractionCog(commands.Cog):
ephemeral=True, ephemeral=True,
) )
except Exception: @app_commands.command(
metrics.PINGME_ERRORS.inc() name="forgetme",
logger.exception( description="Delete all your data and stop your messages from being used.",
"An exception occurred while attempting to execute"
" slash command responder for command 'pingme'."
) )
async def forgetme(self, interaction: discord.Interaction) -> None:
"""Delete all user data and set the noIngest flag.
:param interaction: The interaction event.
"""
if await flags.is_flag_set(
self.bot.db,
interaction.user,
EntityType.USER,
Flag.NO_INGEST,
):
metrics.FORGETME.labels(outcome="already_forgotten").inc()
await interaction.response.send_message( await interaction.response.send_message(
"An error occurred while executing your command." "I have already removed your data and I am not using your messages.",
" Please try again later.", ephemeral=True,
)
return
view = ForgetMeView(interaction.user.id, self.bot.db, interaction)
await interaction.response.send_message(
"I will delete everything I have learned from your"
" messages and I will not use them going forward."
" Would you like to proceed?",
view=view,
ephemeral=True, ephemeral=True,
) )
+36 -10
View File
@@ -12,10 +12,10 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""Handles created messages. """Handles message creation and deletion events.
Responds to mentions and ingests normal text messages as a Cog with an Responds to mentions, ingests normal text messages, and reverses
on_message listener. ingestion when messages are deleted.
""" """
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -24,7 +24,7 @@ import discord
from discord.ext import commands from discord.ext import commands
from crabstero import metrics from crabstero import metrics
from crabstero.messages import ingest_message, reply_to_message from crabstero.messages import ingest_message, reply_to_message, uningest_message
if TYPE_CHECKING: if TYPE_CHECKING:
from crabstero.bot import Crabstero from crabstero.bot import Crabstero
@@ -49,7 +49,8 @@ class MessageCog(commands.Cog):
channel = message.channel channel = message.channel
if not isinstance( if not isinstance(
channel, (discord.TextChannel, discord.Thread, discord.VoiceChannel) channel,
(discord.TextChannel, discord.Thread, discord.VoiceChannel),
): ):
return return
@@ -58,12 +59,13 @@ class MessageCog(commands.Cog):
metrics.MESSAGES_PROCESSED.inc() metrics.MESSAGES_PROCESSED.inc()
if self.bot._ingest_only: if self.bot.ingest_only:
# In ingest-only mode, never reply — only ingest eligible messages. # In ingest-only mode, never reply — only ingest eligible messages.
if message.type == discord.MessageType.default and not isinstance( if message.type == discord.MessageType.default and not isinstance(
channel, discord.Thread channel,
discord.Thread,
): ):
await ingest_message(self.bot.db, message) await ingest_message(self.bot.db, message, self.bot.ingest_cache)
return return
if self.bot.user in message.mentions: if self.bot.user in message.mentions:
@@ -72,9 +74,33 @@ class MessageCog(commands.Cog):
# Only ingest non-thread messages; threads share their parent channel's chain. # Only ingest non-thread messages; threads share their parent channel's chain.
if message.type == discord.MessageType.default and not isinstance( if message.type == discord.MessageType.default and not isinstance(
channel, discord.Thread channel,
discord.Thread,
): ):
await ingest_message(self.bot.db, message) await ingest_message(self.bot.db, message, self.bot.ingest_cache)
@commands.Cog.listener()
async def on_raw_message_delete(
self,
payload: discord.RawMessageDeleteEvent,
) -> None:
"""Uningest a recently ingested message when it is deleted.
:param payload: The raw message delete event.
"""
await uningest_message(self.bot.db, self.bot.ingest_cache, payload.message_id)
@commands.Cog.listener()
async def on_raw_bulk_message_delete(
self,
payload: discord.RawBulkMessageDeleteEvent,
) -> None:
"""Uningest recently ingested messages when they are bulk-deleted.
:param payload: The raw bulk message delete event.
"""
for message_id in payload.message_ids:
await uningest_message(self.bot.db, self.bot.ingest_cache, message_id)
async def setup(bot: Crabstero) -> None: async def setup(bot: Crabstero) -> None:
+10 -5
View File
@@ -55,20 +55,21 @@ class ServerEventsCog(commands.Cog):
logger.info('Joined new server! (Name: "%s" | ID: %s)', guild.name, guild.id) logger.info('Joined new server! (Name: "%s" | ID: %s)', guild.name, guild.id)
embed = discord.Embed( embed = discord.Embed(
title="Joined New Server", color=discord.Color.from_rgb(255, 165, 0) title="Joined New Server",
color=discord.Color.from_rgb(255, 165, 0),
) )
embed.add_field( embed.add_field(
name=f"{guild.name} ({guild.id})", name=f"{guild.name} ({guild.id})",
value=f"{guild.member_count} members", value=f"{guild.member_count} members",
) )
if guild.icon: if guild.icon is not None:
embed.set_image(url=guild.icon.url) embed.set_image(url=guild.icon.url)
embed.set_footer(text=f"{len(self.bot.guilds)} total servers") embed.set_footer(text=f"{len(self.bot.guilds)} total servers")
app_info = await self.bot.application_info() app_info = await self.bot.application_info()
if app_info.owner: if app_info.owner is not None:
with contextlib.suppress(discord.HTTPException): with contextlib.suppress(discord.HTTPException):
await app_info.owner.send(embed=embed) await app_info.owner.send(embed=embed)
@@ -84,7 +85,9 @@ class ServerEventsCog(commands.Cog):
@commands.Cog.listener() @commands.Cog.listener()
async def on_guild_role_update( async def on_guild_role_update(
self, before: discord.Role, after: discord.Role self,
before: discord.Role,
after: discord.Role,
) -> None: ) -> None:
"""Queue all text channels for ingestion when role permissions change. """Queue all text channels for ingestion when role permissions change.
@@ -101,7 +104,9 @@ class ServerEventsCog(commands.Cog):
@commands.Cog.listener() @commands.Cog.listener()
async def on_guild_channel_update( async def on_guild_channel_update(
self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel self,
before: discord.abc.GuildChannel,
after: discord.abc.GuildChannel,
) -> None: ) -> None:
"""Queue all text channels for ingestion on permission change. """Queue all text channels for ingestion on permission change.
+101 -42
View File
@@ -19,10 +19,12 @@ to represent frequency weight. Random selection via ORDER BY RANDOM()
LIMIT 1 naturally preserves this weighting. LIMIT 1 naturally preserves this weighting.
""" """
import itertools
import re import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from crabstero import metrics from crabstero import metrics
from crabstero.database import StartWord, Transition
if TYPE_CHECKING: if TYPE_CHECKING:
from crabstero.database import Database from crabstero.database import Database
@@ -31,6 +33,18 @@ if TYPE_CHECKING:
# as is common on Discord. # as is common on Discord.
DEFAULT_SENTENCE_END = "\u00a7" DEFAULT_SENTENCE_END = "\u00a7"
_TERMINATORS = frozenset({DEFAULT_SENTENCE_END, ".", "!", "?"})
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?]) ")
def _normalize_whitespace(text: str) -> str:
"""Collapse runs of whitespace into single spaces and strip edges.
:param text: The raw text to normalize.
:return: The normalized text.
"""
return " ".join(text.split())
def is_complete_sentence(sentence: str) -> bool: def is_complete_sentence(sentence: str) -> bool:
"""Check whether a sentence ends with a valid terminator. """Check whether a sentence ends with a valid terminator.
@@ -44,7 +58,39 @@ def is_complete_sentence(sentence: str) -> bool:
if not sentence: if not sentence:
return False return False
return sentence[-1] in (DEFAULT_SENTENCE_END, ".", "!", "?") return sentence[-1] in _TERMINATORS
def _split_sentences(paragraph: str) -> list[str]:
"""Normalize whitespace and split a paragraph into sentences.
:param paragraph: The raw paragraph text.
:return: A list of individual sentences.
"""
if not is_complete_sentence(paragraph):
paragraph += DEFAULT_SENTENCE_END
normalized = _normalize_whitespace(paragraph)
return _SENTENCE_SPLIT.split(normalized)
_MIN_WORDS_FOR_START = 2
def _tokenize_sentence(
sentence: str,
) -> tuple[list[str], list[tuple[str, str]]]:
"""Tokenize a sentence into start words and transition pairs.
:param sentence: A single sentence.
:return: A tuple of (start_words, transitions) using bare word strings.
"""
if not is_complete_sentence(sentence):
sentence += DEFAULT_SENTENCE_END
words = sentence.split()
start_words: list[str] = [words[0]] if len(words) >= _MIN_WORDS_FOR_START else []
transitions: list[tuple[str, str]] = list(itertools.pairwise(words))
return start_words, transitions
async def ingest(db: Database, channel_id: int, user_id: int, paragraph: str) -> None: async def ingest(db: Database, channel_id: int, user_id: int, paragraph: str) -> None:
@@ -58,20 +104,15 @@ async def ingest(db: Database, channel_id: int, user_id: int, paragraph: str) ->
:param user_id: The Discord user ID of the contributor. :param user_id: The Discord user ID of the contributor.
:param paragraph: The paragraph of sentences to ingest. :param paragraph: The paragraph of sentences to ingest.
""" """
if not is_complete_sentence(paragraph): for sentence in _split_sentences(paragraph):
paragraph += DEFAULT_SENTENCE_END
# Normalize whitespace, then split on sentence-ending punctuation
# followed by a space.
normalized = re.sub(r" +", " ", paragraph.strip().replace("\n", " "))
sentences = re.split(r"(?<=[.!?]) ", normalized)
for sentence in sentences:
await _ingest_sentence(db, channel_id, user_id, sentence) await _ingest_sentence(db, channel_id, user_id, sentence)
async def _ingest_sentence( async def _ingest_sentence(
db: Database, channel_id: int, user_id: int, sentence: str db: Database,
channel_id: int,
user_id: int,
sentence: str,
) -> None: ) -> None:
"""Ingest a single sentence into the Markov chain for a given channel. """Ingest a single sentence into the Markov chain for a given channel.
@@ -80,25 +121,51 @@ async def _ingest_sentence(
:param user_id: The Discord user ID of the contributor. :param user_id: The Discord user ID of the contributor.
:param sentence: The sentence to ingest. :param sentence: The sentence to ingest.
""" """
if not is_complete_sentence(sentence): raw_starts, raw_transitions = _tokenize_sentence(sentence)
sentence += DEFAULT_SENTENCE_END start_words = [StartWord(channel_id, user_id, w) for w in raw_starts]
transitions = [Transition(channel_id, user_id, w, nw) for w, nw in raw_transitions]
# Normalize whitespace and split into individual words.
words = re.sub(r" +", " ", sentence.strip()).split(" ")
start_words: list[tuple[int, int, str]] = []
transitions: list[tuple[int, int, str, str]] = []
for i in range(len(words) - 1):
if i == 0:
start_words.append((channel_id, user_id, words[i]))
transitions.append((channel_id, user_id, words[i], words[i + 1]))
await db.add_markov_data(start_words, transitions) await db.add_markov_data(start_words, transitions)
async def uningest(db: Database, channel_id: int, user_id: int, paragraph: str) -> None:
"""Remove a paragraph's Markov data from the chain for a given channel.
Mirrors :func:`ingest` but deletes one matching row per entry instead of
inserting.
:param db: The database instance.
:param channel_id: The Discord channel ID.
:param user_id: The Discord user ID of the contributor.
:param paragraph: The paragraph of sentences to uningest.
"""
for sentence in _split_sentences(paragraph):
await _uningest_sentence(db, channel_id, user_id, sentence)
async def _uningest_sentence(
db: Database,
channel_id: int,
user_id: int,
sentence: str,
) -> None:
"""Remove a single sentence's Markov data from the chain.
:param db: The database instance.
:param channel_id: The Discord channel ID.
:param user_id: The Discord user ID of the contributor.
:param sentence: The sentence to uningest.
"""
raw_starts, raw_transitions = _tokenize_sentence(sentence)
start_words = [StartWord(channel_id, user_id, w) for w in raw_starts]
transitions = [Transition(channel_id, user_id, w, nw) for w, nw in raw_transitions]
await db.remove_markov_data(start_words, transitions)
async def generate( async def generate(
db: Database, channel_id: int, soft_limit: int = 750, hard_limit: int = 1000 db: Database,
channel_id: int,
soft_limit: int = 750,
hard_limit: int = 1000,
) -> str: ) -> str:
"""Generate a new sentence from previously ingested words for a channel. """Generate a new sentence from previously ingested words for a channel.
@@ -108,17 +175,16 @@ async def generate(
:param hard_limit: Character count to hard-cut the sentence at. :param hard_limit: Character count to hard-cut the sentence at.
:return: A new generated sentence. :return: A new generated sentence.
""" """
with metrics.GENERATION_DURATION.time():
word = await db.get_random_start_word(channel_id) word = await db.get_random_start_word(channel_id)
if word is None: if word is None:
return ( return (
"There's not enough data to generate a message yet." "I do not have enough data to generate a message yet."
" Chat a bit more so I can learn how this channel talks!" " Chat a bit more so I can learn how this channel talks."
) )
parts: list[str] = [] with metrics.GENERATION_DURATION.time():
parts.append(word) parts: list[str] = [word]
current_length = len(word) current_length = len(word)
# The loop is skipped if the start word already ends a sentence (e.g. "Yes."). # The loop is skipped if the start word already ends a sentence (e.g. "Yes.").
@@ -140,18 +206,11 @@ async def generate(
current_length += 1 + len(word) # +1 for the joining space. current_length += 1 + len(word) # +1 for the joining space.
if current_length >= hard_limit: if current_length >= hard_limit:
result = " ".join(parts)[:hard_limit] break
# Strip the internal sentence-end marker if it ended up at the boundary.
if result and result[-1] == DEFAULT_SENTENCE_END:
result = result[:-1]
metrics.GENERATED_MESSAGE_LENGTH.observe(len(result))
return result
result = " ".join(parts) result = " ".join(parts)
if current_length >= hard_limit:
result = result[:hard_limit]
# Strip the internal sentence-end marker so it never appears in output. # Strip the internal sentence-end marker so it never appears in output.
if result and result[-1] == DEFAULT_SENTENCE_END: return result.removesuffix(DEFAULT_SENTENCE_END)
result = result[:-1]
metrics.GENERATED_MESSAGE_LENGTH.observe(len(result))
return result
+87 -25
View File
@@ -26,14 +26,16 @@ from typing import TYPE_CHECKING
import discord import discord
from crabstero import flags, markov, metrics from crabstero import flags, markov, metrics
from crabstero.cache import CachedMessage, IngestCache
from crabstero.database import ChannelImage
from crabstero.flags import EntityType, Flag from crabstero.flags import EntityType, Flag
if TYPE_CHECKING: if TYPE_CHECKING:
from crabstero.database import Database from crabstero.database import Database
# Copied from discordjs/discord-api-types: # Copied from discordjs/discord-api-types:
# https://github.com/discordjs/discord-api-types/blob/7fe434114e91c80ed79f0204ae6c73047672d55d/globals.ts#L30 # https://github.com/discordjs/discord-api-types/blob/662cb0cb0ac9c6f9ad93e180849476714bfceb0c/globals.ts#L39
MENTION_PATTERN = re.compile(r"<@!?(?P<id>\d{17,20})>") _MENTION_PATTERN = re.compile(r"<@!?(\d{17,20})>")
_EMBED_CHANCE_THRESHOLD = 95 # Out of 100; sends an embed ~5% of the time. _EMBED_CHANCE_THRESHOLD = 95 # Out of 100; sends an embed ~5% of the time.
@@ -65,7 +67,7 @@ async def reply_to_message(db: Database, message: discord.Message) -> None:
else: else:
channel_id = channel.id channel_id = channel.id
body = await markov.generate(db, channel_id, 750, 1000) body = await markov.generate(db, channel_id)
embed = None embed = None
@@ -75,8 +77,13 @@ async def reply_to_message(db: Database, message: discord.Message) -> None:
and channel.permissions_for(guild.me).embed_links and channel.permissions_for(guild.me).embed_links
): ):
embed = discord.Embed( embed = discord.Embed(
title=await markov.generate(db, channel_id, 200, 300), title=await markov.generate(db, channel_id, soft_limit=200, hard_limit=300),
description=await markov.generate(db, channel_id, 300, 500), description=await markov.generate(
db,
channel_id,
soft_limit=300,
hard_limit=500,
),
) )
random_image = await db.get_random_image(channel_id) random_image = await db.get_random_image(channel_id)
@@ -86,9 +93,7 @@ async def reply_to_message(db: Database, message: discord.Message) -> None:
# Suppress all mentions by default; only ping users who opted in. # Suppress all mentions by default; only ping users who opted in.
allowed_user_ids: set[int] = set() allowed_user_ids: set[int] = set()
for match in MENTION_PATTERN.finditer(body): for user_id in {int(uid) for uid in _MENTION_PATTERN.findall(body)}:
user_id = int(match.group("id"))
if await flags.is_flag_set(db, user_id, EntityType.USER, Flag.ALLOW_PINGS): if await flags.is_flag_set(db, user_id, EntityType.USER, Flag.ALLOW_PINGS):
allowed_user_ids.add(user_id) allowed_user_ids.add(user_id)
@@ -115,11 +120,40 @@ async def reply_to_message(db: Database, message: discord.Message) -> None:
metrics.REPLIES_SENT.inc() metrics.REPLIES_SENT.inc()
async def ingest_message(db: Database, message: discord.Message) -> None: def _extract_embed_data(
embeds: list[discord.Embed],
) -> tuple[list[str], list[str]]:
"""Extract text and image URLs from message embeds.
:param embeds: The embeds to process.
:return: A tuple of (texts, image_urls).
"""
texts: list[str] = []
image_urls: list[str] = []
for embed in embeds:
if embed.title:
texts.append(embed.title)
if embed.description:
texts.append(embed.description)
if embed.image and embed.image.url:
image_urls.append(embed.image.url)
return texts, image_urls
async def ingest_message(
db: Database,
message: discord.Message,
cache: IngestCache | None = None,
) -> None:
"""Ingest a given message into its channel's Markov chain. """Ingest a given message into its channel's Markov chain.
If a cache is provided, the ingested data is recorded so it can be
reversed by :func:`uningest_message` if the message is deleted shortly
after.
:param db: The database instance. :param db: The database instance.
:param message: The message to ingest. :param message: The message to ingest.
:param cache: Optional ingest cache for tracking recently ingested data.
""" """
guild = message.guild guild = message.guild
if guild is None: if guild is None:
@@ -141,31 +175,59 @@ async def ingest_message(db: Database, message: discord.Message) -> None:
if not message.content and not message.embeds: if not message.content and not message.embeds:
return return
embed_texts, image_urls = _extract_embed_data(message.embeds)
with metrics.MESSAGE_INGESTION_DURATION.time(): with metrics.MESSAGE_INGESTION_DURATION.time():
if message.content: if message.content:
await markov.ingest(db, channel_id, user_id, message.content) await markov.ingest(db, channel_id, user_id, message.content)
for embed in message.embeds: for text in embed_texts:
await _ingest_embed(db, channel_id, user_id, embed) await markov.ingest(db, channel_id, user_id, text)
metrics.MESSAGES_INGESTED.inc() if image_urls:
await db.add_images(
[ChannelImage(channel_id, user_id, url) for url in image_urls],
)
if cache is not None:
cache.put(
message.id,
CachedMessage(
channel_id=channel_id,
user_id=user_id,
content=message.content or None,
embed_texts=embed_texts,
image_urls=image_urls,
),
)
async def _ingest_embed( async def uningest_message(db: Database, cache: IngestCache, message_id: int) -> None:
db: Database, channel_id: int, user_id: int, embed: discord.Embed """Reverse ingestion for a recently deleted message.
) -> None:
"""Ingest a given embed into a given channel's Markov chain. Looks up the message in the cache. If found, removes all Markov data
and images that were added during ingestion.
:param db: The database instance. :param db: The database instance.
:param channel_id: The ID of the channel to use for the Markov chain. :param cache: The ingest cache.
:param user_id: The Discord user ID of the contributor. :param message_id: The Discord message ID that was deleted.
:param embed: The embed to ingest.
""" """
if embed.title: entry = cache.pop(message_id)
await markov.ingest(db, channel_id, user_id, embed.title) if entry is None:
return
if embed.description: if entry.content:
await markov.ingest(db, channel_id, user_id, embed.description) await markov.uningest(db, entry.channel_id, entry.user_id, entry.content)
if embed.image and embed.image.url: for text in entry.embed_texts:
await db.add_image(channel_id, user_id, embed.image.url) await markov.uningest(db, entry.channel_id, entry.user_id, text)
if entry.image_urls:
await db.remove_images(
[
ChannelImage(entry.channel_id, entry.user_id, url)
for url in entry.image_urls
],
)
metrics.MESSAGES_UNINGESTED.inc()
+123 -40
View File
@@ -19,7 +19,13 @@ registry. The MetricsServer class wraps an aiohttp application that serves
the ``/metrics`` scrape endpoint. the ``/metrics`` scrape endpoint.
""" """
import contextlib
import errno
import logging import logging
import socket
import stat
from dataclasses import dataclass
from pathlib import Path
from aiohttp import web from aiohttp import web
from prometheus_client import Counter, Gauge, Histogram, Info from prometheus_client import Counter, Gauge, Histogram, Info
@@ -31,15 +37,33 @@ logger = logging.getLogger(__name__)
_FAST_BUCKETS = (0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0) _FAST_BUCKETS = (0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
_SLOW_BUCKETS = (0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0) _SLOW_BUCKETS = (0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0)
_LENGTH_BUCKETS = (10, 50, 100, 200, 500, 750, 1000, 1500, 2000)
_MESSAGES_BUCKETS = (100, 500, 1000, 5000, 10000, 25000, 50000) _MESSAGES_BUCKETS = (100, 500, 1000, 5000, 10000, 25000, 50000)
@dataclass(frozen=True, slots=True)
class TcpMetricsAddress:
"""TCP listen address for the metrics server."""
host: str
port: int
@dataclass(frozen=True, slots=True)
class UnixMetricsAddress:
"""Unix socket listen address for the metrics server."""
path: str
type MetricsAddress = TcpMetricsAddress | UnixMetricsAddress
BUILD_INFO = Info("crabstero_build", "Build information") BUILD_INFO = Info("crabstero_build", "Build information")
BUILD_INFO.info({"version": __version__}) BUILD_INFO.info({"version": __version__})
MESSAGES_INGESTED = Counter( MESSAGES_UNINGESTED = Counter(
"crabstero_messages_ingested_total", "crabstero_messages_uningested_total",
"Messages ingested into a Markov chain", "Messages uningested from a Markov chain after deletion",
) )
REPLIES_SENT = Counter( REPLIES_SENT = Counter(
"crabstero_replies_sent_total", "crabstero_replies_sent_total",
@@ -53,25 +77,28 @@ MESSAGES_PROCESSED = Counter(
"crabstero_messages_processed_total", "crabstero_messages_processed_total",
"Messages seen by the listener", "Messages seen by the listener",
) )
CHANNELS_INGESTED = Counter( PINGME = Counter(
"crabstero_channels_ingested_total", "crabstero_pingme_total",
"Channels fully ingested successfully", "/pingme command outcomes",
["outcome"],
) )
CHANNEL_INGESTION_ERRORS = Counter( PINGME.labels(outcome="opted_in")
"crabstero_channel_ingestion_errors_total", PINGME.labels(outcome="opted_out")
"Channel ingestions that failed",
FORGETME = Counter(
"crabstero_forgetme_total",
"/forgetme command outcomes",
["outcome"],
) )
PINGME_OPTED_IN = Counter( FORGETME.labels(outcome="completed")
"crabstero_pingme_opted_in_total", FORGETME.labels(outcome="cancelled")
"/pingme opt-ins", FORGETME.labels(outcome="timeout")
) FORGETME.labels(outcome="already_forgotten")
PINGME_OPTED_OUT = Counter(
"crabstero_pingme_opted_out_total", ERRORS = Counter(
"/pingme opt-outs", "crabstero_errors_total",
) "Unhandled exceptions by source",
PINGME_ERRORS = Counter( ["source"],
"crabstero_pingme_errors_total",
"/pingme exceptions",
) )
DISCORD_EVENTS = Counter( DISCORD_EVENTS = Counter(
"crabstero_discord_events_total", "crabstero_discord_events_total",
@@ -87,9 +114,9 @@ GUILD_COUNT = Gauge(
"crabstero_guild_count", "crabstero_guild_count",
"Guilds the bot is currently in", "Guilds the bot is currently in",
) )
INGESTION_BACKLOG = Gauge( INGESTION_ACTIVE = Gauge(
"crabstero_ingestion_backlog_channels", "crabstero_ingestion_active_channels",
"Channels currently queued for ingestion", "Channels pending or in-progress for ingestion",
) )
MESSAGE_INGESTION_DURATION = Histogram( MESSAGE_INGESTION_DURATION = Histogram(
@@ -107,14 +134,9 @@ GENERATION_DURATION = Histogram(
"Time to generate a Markov response", "Time to generate a Markov response",
buckets=_FAST_BUCKETS, buckets=_FAST_BUCKETS,
) )
GENERATED_MESSAGE_LENGTH = Histogram(
"crabstero_generated_message_length_characters",
"Length of generated reply bodies in characters",
buckets=_LENGTH_BUCKETS,
)
CHANNEL_INGESTION_MESSAGES = Histogram( CHANNEL_INGESTION_MESSAGES = Histogram(
"crabstero_channel_ingestion_messages", "crabstero_channel_ingestion_messages",
"Messages ingested per channel run", "Messages iterated per channel ingestion run",
buckets=_MESSAGES_BUCKETS, buckets=_MESSAGES_BUCKETS,
) )
@@ -122,19 +144,21 @@ CHANNEL_INGESTION_MESSAGES = Histogram(
class MetricsServer: class MetricsServer:
"""HTTP server that exposes a Prometheus ``/metrics`` scrape endpoint. """HTTP server that exposes a Prometheus ``/metrics`` scrape endpoint.
Uses ``aiohttp.web.AppRunner`` and ``TCPSite`` for async-native serving. Uses ``aiohttp.web.AppRunner`` with TCP or Unix socket sites for
async-native serving.
The handler is provided by ``prometheus_client.aiohttp.make_aiohttp_handler``, The handler is provided by ``prometheus_client.aiohttp.make_aiohttp_handler``,
which handles compression and content negotiation automatically. which handles compression and content negotiation automatically.
""" """
def __init__(self, host: str, port: int) -> None: __slots__ = ("_address", "_port", "_runner")
def __init__(self, address: MetricsAddress) -> None:
"""Store the listen address. """Store the listen address.
:param host: The hostname or IP to bind to. :param address: TCP or Unix socket address to bind to.
:param port: The TCP port to bind to. Use 0 for OS-assigned.
""" """
self._host = host self._address = address
self._port = port self._port = address.port if isinstance(address, TcpMetricsAddress) else None
self._runner: web.AppRunner | None = None self._runner: web.AppRunner | None = None
@property @property
@@ -144,24 +168,83 @@ class MetricsServer:
After ``start()``, this reflects the actual port (useful when After ``start()``, this reflects the actual port (useful when
the constructor received port 0 for OS assignment). the constructor received port 0 for OS assignment).
""" """
if self._port is None:
msg = "Unix socket metrics server does not have a TCP port"
raise RuntimeError(msg)
return self._port return self._port
async def start(self) -> None: async def start(self) -> None:
"""Create the aiohttp application and start listening.""" """Create the aiohttp application and start listening."""
if self._runner is not None: if self._runner is not None:
raise RuntimeError("Metrics server is already running") msg = "Metrics server is already running"
raise RuntimeError(msg)
app = web.Application() app = web.Application()
app.router.add_get("/metrics", make_aiohttp_handler()) app.router.add_get("/metrics", make_aiohttp_handler())
self._runner = web.AppRunner(app) self._runner = web.AppRunner(app)
await self._runner.setup() await self._runner.setup()
site = web.TCPSite(self._runner, self._host, self._port) try:
if isinstance(self._address, TcpMetricsAddress):
site: web.BaseSite = web.TCPSite(
self._runner,
self._address.host,
self._address.port,
)
await site.start() await site.start()
# Resolve the actual bound port when the OS assigned one. # Resolve the actual bound port when the OS assigned one.
if self._port == 0: if self._address.port == 0:
self._port = self._runner.addresses[0][1] self._port = self._runner.addresses[0][1]
logger.info("Metrics server listening on %s:%d.", self._host, self._port) logger.info(
"Metrics server listening on %s:%d.",
self._address.host,
self.port,
)
else:
socket_path = Path(self._address.path)
# This runs once before binding the Unix site, so synchronous
# path checks are simpler than offloading startup setup.
try:
mode = socket_path.stat( # noqa: ASYNC240
follow_symlinks=False,
).st_mode
except FileNotFoundError:
pass
else:
if not stat.S_ISSOCK(mode):
msg = (
"Metrics Unix socket path already exists and is not a "
f"socket: {self._address.path}"
)
raise FileExistsError(msg)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
probe.settimeout(0.1)
try:
probe.connect(self._address.path)
except OSError as exc:
if exc.errno not in {errno.ECONNREFUSED, errno.ENOENT}:
raise
else:
msg = (
"Metrics Unix socket path is already in use: "
f"{self._address.path}"
)
raise OSError(errno.EADDRINUSE, msg)
with contextlib.suppress(FileNotFoundError):
socket_path.unlink() # noqa: ASYNC240
site = web.UnixSite(self._runner, self._address.path)
await site.start()
logger.info(
"Metrics server listening on Unix socket %s.",
self._address.path,
)
except Exception:
await self._runner.cleanup()
self._runner = None
raise
async def stop(self) -> None: async def stop(self) -> None:
"""Shut down the HTTP server and release resources.""" """Shut down the HTTP server and release resources."""
+14
View File
@@ -1 +1,15 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Background tasks for Crabstero.""" """Background tasks for Crabstero."""
+4 -13
View File
@@ -14,7 +14,7 @@
"""Bulk-ingests the message history of channels. """Bulk-ingests the message history of channels.
Channels are enqueued for processing by the bot's fixed ingestion worker pool. Channels are processed by the bot's semaphore-bounded dynamic tasks.
""" """
import logging import logging
@@ -26,7 +26,7 @@ from crabstero import metrics
from crabstero.messages import ingest_message from crabstero.messages import ingest_message
if TYPE_CHECKING: if TYPE_CHECKING:
from crabstero.bot import Crabstero from crabstero.bot import Crabstero, IngestableChannel
from crabstero.database import Database from crabstero.database import Database
MAXIMUM_MESSAGES_PER_CHANNEL = ( MAXIMUM_MESSAGES_PER_CHANNEL = (
@@ -48,7 +48,7 @@ def queue_channels_for_ingestion(guild: discord.Guild, bot: Crabstero) -> None:
async def ingest_channel( async def ingest_channel(
channel: discord.TextChannel | discord.VoiceChannel, channel: IngestableChannel,
db: Database, db: Database,
) -> None: ) -> None:
"""Bulk-ingest the message history of a given channel. """Bulk-ingest the message history of a given channel.
@@ -59,7 +59,6 @@ async def ingest_channel(
:param channel: The channel to ingest. :param channel: The channel to ingest.
:param db: The database instance. :param db: The database instance.
""" """
try:
if not channel.permissions_for(channel.guild.me).read_message_history: if not channel.permissions_for(channel.guild.me).read_message_history:
logger.warning( logger.warning(
"[%s] Unable to ingest channel history" "[%s] Unable to ingest channel history"
@@ -84,15 +83,7 @@ async def ingest_channel(
metrics.CHANNEL_INGESTION_MESSAGES.observe(count) metrics.CHANNEL_INGESTION_MESSAGES.observe(count)
logger.info( logger.info(
"[%s] Ingestion of channel history complete. %d messages ingested.", "[%s] Ingestion of channel history complete. %d messages iterated.",
channel.id, channel.id,
count, count,
) )
metrics.CHANNELS_INGESTED.inc()
except Exception:
metrics.CHANNEL_INGESTION_ERRORS.inc()
logger.exception(
"[%s] An error occurred while ingesting textable channel history!",
channel.id,
)
+45 -70
View File
@@ -2,16 +2,17 @@
name = "crabstero" name = "crabstero"
dynamic = ["version"] dynamic = ["version"]
description = "The simple nonversation Discord bot." description = "The simple nonversation Discord bot."
requires-python = ">=3.14"
license = "Apache-2.0"
authors = [ authors = [
{ name = "Logan Fick" }, { name = "Logan Fick" },
] ]
license = "Apache-2.0"
requires-python = ">=3.14"
dependencies = [ dependencies = [
"discord.py>=2.6.4", "discord.py>=2.7.1",
"aiosqlite>=0.22.1", "aiosqlite>=0.22.1",
"aiohttp>=3.13.3", "aiohttp>=3.14.1",
"prometheus-client>=0.24.1", "prometheus-client>=0.25.0",
"uvloop>=0.22.1",
] ]
[project.scripts] [project.scripts]
@@ -20,25 +21,25 @@ crabstero = "crabstero.__main__:main"
[project.urls] [project.urls]
Repository = "https://git.logal.dev/LogalDeveloper/Crabstero" Repository = "https://git.logal.dev/LogalDeveloper/Crabstero"
[build-system]
requires = ["hatchling>=1.28.0", "hatch-vcs>=0.5.0"]
build-backend = "hatchling.build"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"codespell>=2.4.2", "codespell>=2.4.2",
"mypy>=1.19.1", "mypy>=2.1.0",
"pip-audit>=2.10.0", "pip-audit>=2.10.1",
"pytest>=9.0.2", "pytest>=9.0.3",
"pytest-asyncio>=1.3.0", "pytest-asyncio>=1.4.0",
"pytest-cov>=7.0.0", "pytest-cov>=7.1.0",
"ruff>=0.15.5", "ruff>=0.15.16",
] ]
[tool.uv] [tool.uv]
extra-index-url = ["https://git.logal.dev/api/packages/LogalDeveloper/pypi/simple/"] extra-index-url = ["https://git.logal.dev/api/packages/LogalDeveloper/pypi/simple/"]
publish-url = "https://git.logal.dev/api/packages/LogalDeveloper/pypi" publish-url = "https://git.logal.dev/api/packages/LogalDeveloper/pypi"
[build-system]
requires = ["hatchling>=1.30.1", "hatch-vcs>=0.5.0"]
build-backend = "hatchling.build"
[tool.hatch.version] [tool.hatch.version]
source = "vcs" source = "vcs"
@@ -54,76 +55,50 @@ strict = true
warn_unreachable = true warn_unreachable = true
explicit_package_bases = true explicit_package_bases = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
[tool.ruff] [tool.ruff]
target-version = "py314" target-version = "py314"
extend-exclude = ["crabstero/_version.py"] # auto-generated by hatch-vcs extend-exclude = ["crabstero/_version.py"] # auto-generated by hatch-vcs
[tool.ruff.lint] [tool.ruff.lint]
select = [ select = ["ALL"]
# Core
"F", # Pyflakes
"E", # pycodestyle errors
"W", # pycodestyle warnings
"N", # pep8-naming
"D", # pydocstyle
"I", # isort
"ICN", # flake8-import-conventions
# Correctness & bugs
"B", # flake8-bugbear
"ASYNC", # flake8-async
"DTZ", # flake8-datetimez
"RSE", # flake8-raise
"RET", # flake8-return
"A", # flake8-builtins
"PIE", # flake8-pie
# Modernization & simplification
"UP", # pyupgrade
"SIM", # flake8-simplify
"C4", # flake8-comprehensions
"FLY", # flynt (f-string conversion)
"PTH", # flake8-use-pathlib
# Performance
"PERF", # Perflint
# Security
"S", # flake8-bandit
# Code hygiene
"T10", # flake8-debugger
"T20", # flake8-print
"ERA", # eradicate
"PGH", # pygrep-hooks
"TC", # flake8-type-checking
# Testing
"PT", # flake8-pytest-style
# Ruff-specific
"RUF", # Ruff-specific rules
]
ignore = [ ignore = [
"ANN401", # Any is valid at system boundaries; mypy strict handles real issues
"C901", # McCabe complexity: noisy and not actionable
"COM812", # handled by the formatter
"D203", # incompatible with D211 (no blank line before class docstring) "D203", # incompatible with D211 (no blank line before class docstring)
"D213", # incompatible with D212 (summary on first line) "D213", # incompatible with D212 (summary on first line)
"EM", # exception message style: inline literals are fine
"PLR0911", # too many return statements: flat early-returns are clear
"PLR0912", # too many branches: inherent in parsers, validators, CLI
"PLR0913", # too many arguments: API surfaces and constructors need them
"PLR0915", # too many statements: inherent in parsers, validators, CLI
"TRY003", # inline exception messages are fine (complements EM ignore)
"TRY301", # raise inside try: guard clauses don't need helper functions
] ]
[tool.pytest.ini_options]
asyncio_mode = "auto"
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"crabstero/__init__.py" = ["E402"] # constants defined before imports intentionally "crabstero/__init__.py" = ["E402"] # version fallback is resolved before public imports
"crabstero/__main__.py" = ["T201"] # CLI entry point uses print() for user output "tests/**" = [
"tests/**" = ["S101"] # assert is standard for pytest "S101", # assert is standard for pytest
"S311", # pseudo-random generators are fine in tests
"SLF001", # tests legitimately access private members for verification
"ARG001", # unused args are normal for fixtures and handler stubs
"PLR2004", # magic values are clear in test assertions
]
[tool.codespell]
skip = "crabstero/_version.py,uv.lock"
[tool.coverage.run] [tool.coverage.run]
source = ["crabstero"] source = ["crabstero"]
omit = ["crabstero/_version.py"] omit = [
"crabstero/_version.py",
]
[tool.coverage.report] [tool.coverage.report]
show_missing = true show_missing = true
skip_empty = true skip_empty = true
[tool.codespell]
skip = "uv.lock"
+14
View File
@@ -1 +1,15 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Test suite for Crabstero.""" """Test suite for Crabstero."""
+4 -5
View File
@@ -21,13 +21,12 @@ import pytest
from crabstero.database import Database from crabstero.database import Database
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncGenerator
from pathlib import Path
@pytest.fixture @pytest.fixture
async def db(tmp_path: Path) -> AsyncIterator[Database]: async def db() -> AsyncGenerator[Database]:
"""Yield a Database backed by a temporary SQLite file.""" """Yield a Database backed by an in-memory SQLite database."""
database = await Database.connect(str(tmp_path / "test.db")) database = await Database.connect(":memory:")
yield database yield database
await database.close() await database.close()
+118
View File
@@ -0,0 +1,118 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the IngestCache TTL cache."""
import asyncio
import time
from crabstero.cache import CachedMessage, IngestCache
class TestIngestCache:
"""IngestCache put, pop, and expiry behavior."""
def test_put_and_pop(self) -> None:
"""A cached message can be retrieved by message ID."""
cache = IngestCache()
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello world",
embed_texts=["embed title"],
image_urls=["https://example.com/cat.png"],
)
cache.put(12345, entry)
assert cache.pop(12345) is entry
def test_pop_removes_entry(self) -> None:
"""Popping an entry removes it from the cache."""
cache = IngestCache()
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(12345, entry)
cache.pop(12345)
assert cache.pop(12345) is None
def test_pop_returns_none_for_missing(self) -> None:
"""Popping a nonexistent key returns None."""
cache = IngestCache()
assert cache.pop(99999) is None
def test_expired_entry_not_returned(self) -> None:
"""An expired entry is not returned by pop."""
cache = IngestCache(ttl_seconds=0.01)
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(12345, entry)
time.sleep(0.02)
assert cache.pop(12345) is None
def test_cleanup_removes_expired(self) -> None:
"""Cleanup evicts all expired entries."""
cache = IngestCache(ttl_seconds=0.01)
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(1, entry)
cache.put(2, entry)
time.sleep(0.02)
cache._cleanup()
assert cache.pop(1) is None
assert cache.pop(2) is None
def test_cleanup_keeps_unexpired(self) -> None:
"""Cleanup does not evict entries that are still valid."""
cache = IngestCache(ttl_seconds=300)
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(1, entry)
cache._cleanup()
assert cache.pop(1) is entry
async def test_start_and_stop(self) -> None:
"""The background cleanup task can be started and stopped."""
cache = IngestCache(ttl_seconds=0, cleanup_interval_seconds=0.01)
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(1, entry)
cache.start()
await asyncio.sleep(0.05)
await cache.stop()
# Entry should have been cleaned up by the background task.
assert cache.pop(1) is None
+287 -20
View File
@@ -12,19 +12,107 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""Unit tests for CLI argument parsing and credential reading. """Unit tests for CLI argument parsing, credentials, and systemd integration.
Tests cover _parse_args and _read_credential from crabstero.__main__. Tests cover the CLI helpers and runtime orchestration from crabstero.__main__.
""" """
from typing import TYPE_CHECKING import asyncio
import signal
import socket
import sys
from typing import TYPE_CHECKING, Any, ClassVar, Self
import pytest import pytest
from crabstero.__main__ import _parse_args, _read_credential from crabstero import __main__ as cli
from crabstero.__main__ import (
_parse_args,
_read_credential,
_sd_notify,
_watchdog_interval,
_watchdog_loop,
)
from crabstero.metrics import TcpMetricsAddress, UnixMetricsAddress
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Coroutine
from pathlib import Path from pathlib import Path
from types import TracebackType
class _FakeCrabstero:
events: ClassVar[list[object]] = []
login_error: ClassVar[BaseException | None] = None
close_error: ClassVar[BaseException | None] = None
connect_callback: ClassVar[Callable[[], None] | None] = None
def __init__(self, **kwargs: object) -> None:
self._closed = False
self.events.append(("init", kwargs))
async def __aenter__(self) -> Self:
self.events.append("enter")
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
traceback: TracebackType | None,
) -> None:
self.events.append("exit")
await self.close()
async def login(self, token: str) -> None:
self.events.append(("login", token))
if self.login_error is not None:
raise self.login_error
async def connect(self, **kwargs: object) -> None:
self.events.append(("connect", kwargs))
callback = type(self).connect_callback
if callback is not None:
callback()
await asyncio.sleep(0)
async def close(self) -> None:
if self._closed:
return
self.events.append("close")
self._closed = True
if self.close_error is not None:
raise self.close_error
def is_closed(self) -> bool:
return self._closed
def _run_coroutine(coro: Coroutine[Any, Any, None]) -> None:
asyncio.run(coro)
def _prepare_main_test(
monkeypatch: pytest.MonkeyPatch,
*,
login_error: BaseException | None = None,
close_error: BaseException | None = None,
) -> list[object]:
_FakeCrabstero.events = []
_FakeCrabstero.login_error = login_error
_FakeCrabstero.close_error = close_error
_FakeCrabstero.connect_callback = None
def sd_notify(state: str) -> None:
_FakeCrabstero.events.append(("notify", state))
monkeypatch.setattr(sys, "argv", ["crabstero", "--token", "cli-token"])
monkeypatch.setattr(cli, "Crabstero", _FakeCrabstero)
monkeypatch.setattr("crabstero.__main__.uvloop.run", _run_coroutine)
monkeypatch.setattr(cli, "_sd_notify", sd_notify)
monkeypatch.setattr(cli, "_watchdog_interval", lambda: None)
return _FakeCrabstero.events
class TestParseArgs: class TestParseArgs:
@@ -46,17 +134,6 @@ class TestParseArgs:
args = _parse_args(["--token", "test", "--database-path", "/custom.db"]) args = _parse_args(["--token", "test", "--database-path", "/custom.db"])
assert args.database_path == "/custom.db" assert args.database_path == "/custom.db"
def test_ingestion_workers_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Omitting --ingestion-workers defaults to 4."""
monkeypatch.delenv("INGESTION_WORKERS", raising=False)
args = _parse_args(["--token", "test"])
assert args.ingestion_workers == 4
def test_ingestion_workers_override(self) -> None:
"""--ingestion-workers overrides the default."""
args = _parse_args(["--token", "test", "--ingestion-workers", "8"])
assert args.ingestion_workers == 8
def test_ingest_only_flag(self) -> None: def test_ingest_only_flag(self) -> None:
"""--ingest-only sets ingest_only to True.""" """--ingest-only sets ingest_only to True."""
args = _parse_args(["--token", "test", "--ingest-only"]) args = _parse_args(["--token", "test", "--ingest-only"])
@@ -65,7 +142,7 @@ class TestParseArgs:
def test_listen_metrics_parses_address(self) -> None: def test_listen_metrics_parses_address(self) -> None:
"""--listen-metrics HOST:PORT sets listen_metrics to (host, port).""" """--listen-metrics HOST:PORT sets listen_metrics to (host, port)."""
args = _parse_args(["--token", "test", "--listen-metrics", "127.0.0.1:9090"]) args = _parse_args(["--token", "test", "--listen-metrics", "127.0.0.1:9090"])
assert args.listen_metrics == ("127.0.0.1", 9090) assert args.listen_metrics == TcpMetricsAddress("127.0.0.1", 9090)
def test_listen_metrics_default_none(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_listen_metrics_default_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Omitting --listen-metrics defaults to None.""" """Omitting --listen-metrics defaults to None."""
@@ -76,13 +153,29 @@ class TestParseArgs:
def test_listen_metrics_ipv6(self) -> None: def test_listen_metrics_ipv6(self) -> None:
"""--listen-metrics [::1]:PORT parses IPv6 address correctly.""" """--listen-metrics [::1]:PORT parses IPv6 address correctly."""
args = _parse_args(["--token", "test", "--listen-metrics", "[::1]:9090"]) args = _parse_args(["--token", "test", "--listen-metrics", "[::1]:9090"])
assert args.listen_metrics == ("[::1]", 9090) assert args.listen_metrics == TcpMetricsAddress("[::1]", 9090)
def test_listen_metrics_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_listen_metrics_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""LISTEN_METRICS env var is used when --listen-metrics is omitted.""" """LISTEN_METRICS env var is used when --listen-metrics is omitted."""
monkeypatch.setenv("LISTEN_METRICS", "127.0.0.1:8080") monkeypatch.setenv("LISTEN_METRICS", "127.0.0.1:8080")
args = _parse_args(["--token", "test"]) args = _parse_args(["--token", "test"])
assert args.listen_metrics == ("127.0.0.1", 8080) assert args.listen_metrics == TcpMetricsAddress("127.0.0.1", 8080)
def test_listen_metrics_unix_socket(self) -> None:
"""--listen-metrics unix:/path sets listen_metrics to a Unix socket."""
args = _parse_args(
["--token", "test", "--listen-metrics", "unix:/run/crabstero.sock"],
)
assert args.listen_metrics == UnixMetricsAddress("/run/crabstero.sock")
def test_listen_metrics_unix_socket_from_env(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""LISTEN_METRICS can enable metrics on a Unix socket."""
monkeypatch.setenv("LISTEN_METRICS", "unix:/run/crabstero.sock")
args = _parse_args(["--token", "test"])
assert args.listen_metrics == UnixMetricsAddress("/run/crabstero.sock")
def test_listen_metrics_invalid_format(self) -> None: def test_listen_metrics_invalid_format(self) -> None:
"""--listen-metrics with no colon raises SystemExit.""" """--listen-metrics with no colon raises SystemExit."""
@@ -94,6 +187,11 @@ class TestParseArgs:
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
_parse_args(["--token", "test", "--listen-metrics", "127.0.0.1:abc"]) _parse_args(["--token", "test", "--listen-metrics", "127.0.0.1:abc"])
def test_listen_metrics_empty_unix_socket_path(self) -> None:
"""--listen-metrics unix: with no path raises SystemExit."""
with pytest.raises(SystemExit):
_parse_args(["--token", "test", "--listen-metrics", "unix:"])
def test_missing_token_exits(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_missing_token_exits(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Missing token causes SystemExit.""" """Missing token causes SystemExit."""
monkeypatch.delenv("TOKEN", raising=False) monkeypatch.delenv("TOKEN", raising=False)
@@ -106,7 +204,9 @@ class TestReadCredential:
"""Systemd credential file reading.""" """Systemd credential file reading."""
def test_reads_credential_file( def test_reads_credential_file(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""Reads and strips the credential value from the file.""" """Reads and strips the credential value from the file."""
(tmp_path / "mytoken").write_text(" secret123 \n") (tmp_path / "mytoken").write_text(" secret123 \n")
@@ -119,8 +219,175 @@ class TestReadCredential:
assert _read_credential("anything") is None assert _read_credential("anything") is None
def test_returns_none_for_missing_file( def test_returns_none_for_missing_file(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""Returns None when the credential file does not exist.""" """Returns None when the credential file does not exist."""
monkeypatch.setenv("CREDENTIALS_DIRECTORY", str(tmp_path)) monkeypatch.setenv("CREDENTIALS_DIRECTORY", str(tmp_path))
assert _read_credential("nonexistent") is None assert _read_credential("nonexistent") is None
class TestSystemdNotify:
"""Systemd notification helper behavior."""
def test_sd_notify_noop_without_socket(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Missing NOTIFY_SOCKET is a no-op."""
monkeypatch.delenv("NOTIFY_SOCKET", raising=False)
_sd_notify("READY=1")
def test_sd_notify_sends_datagram(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""_sd_notify sends the payload to NOTIFY_SOCKET."""
socket_path = tmp_path / "notify.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as server:
server.bind(str(socket_path))
server.settimeout(1)
monkeypatch.setenv("NOTIFY_SOCKET", str(socket_path))
_sd_notify("READY=1")
assert server.recv(1024) == b"READY=1"
class TestWatchdog:
"""Systemd watchdog helper behavior."""
def test_watchdog_interval_missing(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Missing WATCHDOG_USEC disables watchdog pings."""
monkeypatch.delenv("WATCHDOG_USEC", raising=False)
assert _watchdog_interval() is None
def test_watchdog_interval_valid(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""WATCHDOG_USEC is converted to half the interval in seconds."""
monkeypatch.setenv("WATCHDOG_USEC", "4000000")
assert _watchdog_interval() == 2
@pytest.mark.parametrize(
"value",
[
pytest.param("invalid", id="not-integer"),
pytest.param("0", id="zero"),
pytest.param("-1", id="negative"),
],
)
def test_watchdog_interval_invalid(
self,
value: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Invalid WATCHDOG_USEC values disable watchdog pings."""
monkeypatch.setenv("WATCHDOG_USEC", value)
assert _watchdog_interval() is None
async def test_watchdog_loop_sends_until_stopped(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""_watchdog_loop sends WATCHDOG=1 and exits when stopped."""
notifications: list[str] = []
stop = asyncio.Event()
monkeypatch.setattr(cli, "_sd_notify", notifications.append)
task = asyncio.create_task(_watchdog_loop(60, stop))
await asyncio.sleep(0)
stop.set()
await asyncio.wait_for(task, timeout=1)
assert notifications == ["WATCHDOG=1"]
class TestMainLifecycle:
"""CLI runtime orchestration."""
def test_ready_sent_after_login_before_connect(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""READY=1 is sent after login succeeds and before connect starts."""
events = _prepare_main_test(monkeypatch)
cli.main()
assert events.index(("login", "cli-token")) < events.index(
("notify", "READY=1"),
)
assert events.index(("notify", "READY=1")) < events.index(
("connect", {"reconnect": True}),
)
def test_stopping_sent_after_clean_connect_return(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""STOPPING=1 is sent when the CLI run exits cleanly after readiness."""
events = _prepare_main_test(monkeypatch)
cli.main()
assert events.index(("connect", {"reconnect": True})) < events.index(
("notify", "STOPPING=1"),
)
def test_ready_not_sent_when_login_fails(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A login failure exits without reporting readiness."""
events = _prepare_main_test(
monkeypatch,
login_error=RuntimeError("login failed"),
)
with pytest.raises(RuntimeError, match="login failed"):
cli.main()
assert ("notify", "READY=1") not in events
assert ("notify", "STOPPING=1") not in events
def test_shutdown_task_exception_is_propagated(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A completed shutdown task exception is not silently dropped."""
signal_handlers: dict[int, Callable[[], None]] = {}
class SignalLoop:
def add_signal_handler(
self,
sig: int,
callback: Callable[[], None],
) -> None:
signal_handlers[sig] = callback
def remove_signal_handler(self, sig: int) -> None:
signal_handlers.pop(sig, None)
events = _prepare_main_test(
monkeypatch,
close_error=RuntimeError("close failed"),
)
def request_shutdown() -> None:
signal_handlers[signal.SIGTERM]()
monkeypatch.setattr(asyncio, "get_running_loop", SignalLoop)
_FakeCrabstero.connect_callback = request_shutdown
with pytest.raises(RuntimeError, match="close failed"):
cli.main()
assert events.index(("notify", "STOPPING=1")) < events.index("close")
+472 -28
View File
@@ -22,7 +22,8 @@ from typing import TYPE_CHECKING
import pytest import pytest
from crabstero.database import Database from crabstero.database import ChannelImage, Database, StartWord, Transition
from crabstero.flags import EntityType, Flag
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@@ -41,7 +42,7 @@ class TestConnect:
async def test_schema_creates_tables(self, db: Database) -> None: async def test_schema_creates_tables(self, db: Database) -> None:
"""All expected tables exist after connect.""" """All expected tables exist after connect."""
async with db._connection.execute( async with db._connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name" "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
) as cursor: ) as cursor:
tables = [row[0] for row in await cursor.fetchall()] tables = [row[0] for row in await cursor.fetchall()]
assert tables == [ assert tables == [
@@ -58,30 +59,65 @@ class TestAddMarkovData:
async def test_stores_start_word(self, db: Database) -> None: async def test_stores_start_word(self, db: Database) -> None:
"""Inserted start word can be retrieved by channel.""" """Inserted start word can be retrieved by channel."""
await db.add_markov_data([(1, 100, "Hello")], []) await db.add_markov_data([StartWord(1, 100, "Hello")], [])
result = await db.get_random_start_word(1) result = await db.get_random_start_word(1)
assert result == "Hello" assert result == "Hello"
async def test_stores_transition(self, db: Database) -> None: async def test_stores_transition(self, db: Database) -> None:
"""Inserted transition can be retrieved by channel and word.""" """Inserted transition can be retrieved by channel and word."""
await db.add_markov_data([], [(1, 100, "Hello", "world.")]) await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
result = await db.get_random_next_word(1, "Hello") result = await db.get_random_next_word(1, "Hello")
assert result == "world." assert result == "world."
async def test_stores_both_in_single_call(self, db: Database) -> None: async def test_stores_both_in_single_call(self, db: Database) -> None:
"""Start words and transitions are stored in a single call.""" """Start words and transitions are stored in a single call."""
await db.add_markov_data( await db.add_markov_data(
[(1, 100, "Hello")], [StartWord(1, 100, "Hello")],
[(1, 100, "Hello", "world.")], [Transition(1, 100, "Hello", "world.")],
) )
assert await db.get_random_start_word(1) == "Hello" assert await db.get_random_start_word(1) == "Hello"
assert await db.get_random_next_word(1, "Hello") == "world." assert await db.get_random_next_word(1, "Hello") == "world."
async def test_empty_lists_store_nothing(self, db: Database) -> None: async def test_empty_lists_is_noop(self, db: Database) -> None:
"""Empty lists do not store any data.""" """Empty lists do not store any data."""
await db.add_markov_data([], []) await db.add_markov_data([], [])
assert await db.get_random_start_word(1) is None assert await db.get_random_start_word(1) is None
class TestMarkovReadMethods:
"""Markov read methods return None for out-of-scope or missing data."""
async def test_returns_none_when_empty(self, db: Database) -> None:
"""Returns None when no data has been stored."""
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "nonexistent") is None
assert await db.get_random_completing_next_word(1, "nonexistent") is None
async def test_start_word_scoped_to_channel(self, db: Database) -> None:
"""A start word in one channel is not returned for another channel."""
await db.add_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(2) is None
async def test_next_word_scoped_to_channel(self, db: Database) -> None:
"""A transition in one channel is not returned for another channel."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(2, "Hello") is None
async def test_completing_next_word_scoped_to_channel(self, db: Database) -> None:
"""Completing transition is not returned for another channel."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_completing_next_word(2, "Hello") is None
async def test_next_word_scoped_to_word(self, db: Database) -> None:
"""A transition for one word is not returned when querying a different word."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Goodbye") is None
async def test_completing_next_word_scoped_to_word(self, db: Database) -> None:
"""Completing transition is not returned for a different word."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_completing_next_word(1, "Goodbye") is None
@pytest.mark.parametrize( @pytest.mark.parametrize(
"completing_word", "completing_word",
[ [
@@ -92,14 +128,16 @@ class TestAddMarkovData:
], ],
) )
async def test_completing_word_filters_punctuation( async def test_completing_word_filters_punctuation(
self, db: Database, completing_word: str self,
db: Database,
completing_word: str,
) -> None: ) -> None:
"""get_random_completing_next_word only returns sentence-ending words.""" """get_random_completing_next_word only returns sentence-ending words."""
await db.add_markov_data( await db.add_markov_data(
[], [],
[ [
(1, 100, "Hello", "beautiful"), Transition(1, 100, "Hello", "beautiful"),
(1, 100, "Hello", completing_word), Transition(1, 100, "Hello", completing_word),
], ],
) )
for _ in range(100): for _ in range(100):
@@ -108,18 +146,169 @@ class TestAddMarkovData:
async def test_completing_returns_none_without_match(self, db: Database) -> None: async def test_completing_returns_none_without_match(self, db: Database) -> None:
"""Returns None when no transitions end with sentence punctuation.""" """Returns None when no transitions end with sentence punctuation."""
await db.add_markov_data([], [(1, 100, "Hello", "beautiful")]) await db.add_markov_data([], [Transition(1, 100, "Hello", "beautiful")])
result = await db.get_random_completing_next_word(1, "Hello") result = await db.get_random_completing_next_word(1, "Hello")
assert result is None assert result is None
async def test_start_word_pooled_across_users(self, db: Database) -> None:
"""Start words from different users are visible in the same channel query."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(1, 200, "Goodbye")],
[],
)
assert await db.get_random_start_word(1) in {"Hello", "Goodbye"}
class TestMarkovReadMethods: async def test_next_word_pooled_across_users(self, db: Database) -> None:
"""Read behavior when no data has been stored.""" """Transitions from different users are visible in the same channel query."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 200, "Hello", "friend."),
],
)
assert await db.get_random_next_word(1, "Hello") in {"world.", "friend."}
async def test_returns_none_when_empty(self, db: Database) -> None: async def test_completing_next_word_pooled_across_users(self, db: Database) -> None:
"""Returns None for channels with no data.""" """Completing transitions from different users are visible."""
assert await db.get_random_start_word(999) is None await db.add_markov_data(
assert await db.get_random_next_word(1, "nonexistent") is None [],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 200, "Hello", "friend."),
],
)
assert await db.get_random_completing_next_word(1, "Hello") in {
"world.",
"friend.",
}
class TestRemoveMarkovData:
"""Markov data removal via remove_markov_data."""
async def test_removes_one_start_word(self, db: Database) -> None:
"""Removes exactly one matching start word row."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(1, 100, "Hello")],
[],
)
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
# One copy should remain.
assert await db.get_random_start_word(1) == "Hello"
async def test_removes_one_transition(self, db: Database) -> None:
"""Removes exactly one matching transition row."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 100, "Hello", "world."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") == "world."
async def test_removes_last_start_word(self, db: Database) -> None:
"""Removing the only start word leaves the table empty for that channel."""
await db.add_markov_data([StartWord(1, 100, "Hello")], [])
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(1) is None
async def test_removes_last_transition(self, db: Database) -> None:
"""Removing the only transition leaves no next word."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") is None
async def test_no_match_is_noop(self, db: Database) -> None:
"""Removing a non-existent row does not raise."""
await db.remove_markov_data(
[StartWord(1, 100, "nope")],
[Transition(1, 100, "nope", "nah")],
)
async def test_start_word_removal_scoped_to_channel(self, db: Database) -> None:
"""Removing a start word in one channel leaves another channel intact."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(2, 200, "Hello")],
[],
)
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(1) is None
assert await db.get_random_start_word(2) == "Hello"
async def test_transition_removal_scoped_to_channel(self, db: Database) -> None:
"""Removing a transition in one channel leaves another channel intact."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(2, 200, "Hello", "world."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") is None
assert await db.get_random_next_word(2, "Hello") == "world."
async def test_start_word_removal_scoped_to_user(self, db: Database) -> None:
"""Removing a start word for one user leaves another user."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(1, 200, "Hello")],
[],
)
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(1) == "Hello"
async def test_transition_removal_scoped_to_user(self, db: Database) -> None:
"""Removing a transition for one user leaves another user."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 200, "Hello", "world."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") == "world."
async def test_start_word_removal_scoped_to_word(self, db: Database) -> None:
"""Removing one start word leaves a different start word."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(1, 100, "World")],
[],
)
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(1) == "World"
async def test_transition_removal_scoped_to_word(self, db: Database) -> None:
"""Removing one word's transition leaves another word's."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 100, "Goodbye", "world."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") is None
assert await db.get_random_next_word(1, "Goodbye") == "world."
async def test_transition_removal_scoped_to_next_word(self, db: Database) -> None:
"""Removing one next_word leaves a different next_word."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 100, "Hello", "friend."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") == "friend."
async def test_empty_lists_is_noop(self, db: Database) -> None:
"""Empty lists do not error."""
await db.remove_markov_data([], [])
class TestImages: class TestImages:
@@ -127,7 +316,7 @@ class TestImages:
async def test_add_and_retrieve(self, db: Database) -> None: async def test_add_and_retrieve(self, db: Database) -> None:
"""Inserted image URL can be retrieved by channel.""" """Inserted image URL can be retrieved by channel."""
await db.add_image(1, 100, "https://example.com/cat.png") await db.add_images([ChannelImage(1, 100, "https://example.com/cat.png")])
result = await db.get_random_image(1) result = await db.get_random_image(1)
assert result == "https://example.com/cat.png" assert result == "https://example.com/cat.png"
@@ -136,30 +325,159 @@ class TestImages:
result = await db.get_random_image(999) result = await db.get_random_image(999)
assert result is None assert result is None
async def test_image_scoped_to_channel(self, db: Database) -> None:
"""An image in one channel is not returned for another channel."""
await db.add_images([ChannelImage(1, 100, "https://example.com/cat.png")])
assert await db.get_random_image(2) is None
async def test_empty_list_is_noop(self, db: Database) -> None:
"""Empty list does not store any data."""
await db.add_images([])
assert await db.get_random_image(1) is None
async def test_image_pooled_across_users(self, db: Database) -> None:
"""Images from different users are visible in the same channel query."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(1, 200, "https://example.com/b.png"),
],
)
assert await db.get_random_image(1) in {
"https://example.com/a.png",
"https://example.com/b.png",
}
class TestRemoveImages:
"""Image removal via remove_images."""
async def test_removes_one_image(self, db: Database) -> None:
"""Removes exactly one matching image row."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(1, 100, "https://example.com/a.png"),
],
)
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
# One copy should remain.
assert await db.get_random_image(1) == "https://example.com/a.png"
async def test_removes_last_image(self, db: Database) -> None:
"""Removing the only image leaves none for that channel."""
await db.add_images([ChannelImage(1, 100, "https://example.com/a.png")])
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
assert await db.get_random_image(1) is None
async def test_image_removal_scoped_to_channel(self, db: Database) -> None:
"""Removing an image in one channel leaves another channel intact."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(2, 200, "https://example.com/a.png"),
],
)
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
assert await db.get_random_image(1) is None
assert await db.get_random_image(2) == "https://example.com/a.png"
async def test_image_removal_scoped_to_user(self, db: Database) -> None:
"""Removing an image for one user leaves another user."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(1, 200, "https://example.com/a.png"),
],
)
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
assert await db.get_random_image(1) == "https://example.com/a.png"
async def test_image_removal_scoped_to_url(self, db: Database) -> None:
"""Removing one URL leaves a different URL for the same user."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(1, 100, "https://example.com/b.png"),
],
)
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
assert await db.get_random_image(1) == "https://example.com/b.png"
async def test_no_match_is_noop(self, db: Database) -> None:
"""Removing a non-existent image does not raise."""
await db.remove_images([ChannelImage(1, 100, "https://example.com/nope.png")])
async def test_empty_list_is_noop(self, db: Database) -> None:
"""Empty list does not error."""
await db.remove_images([])
class TestFlags: class TestFlags:
"""Flag CRUD operations on entities.""" """Flag CRUD operations on entities."""
async def test_set_and_check(self, db: Database) -> None: async def test_set_and_check(self, db: Database) -> None:
"""A set flag is reported as set.""" """A set flag is reported as set."""
await db.set_flag("channel", "123", "noReply") await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set("channel", "123", "noReply") is True assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is True
async def test_unset_flag_is_false(self, db: Database) -> None: async def test_unset_flag_is_false(self, db: Database) -> None:
"""An unset flag is reported as not set.""" """An unset flag is reported as not set."""
assert await db.is_flag_set("channel", "123", "noReply") is False assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is False
async def test_clear_flag(self, db: Database) -> None: async def test_clear_flag(self, db: Database) -> None:
"""A cleared flag is no longer reported as set.""" """A cleared flag is no longer reported as set."""
await db.set_flag("channel", "123", "noReply") await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.clear_flag("channel", "123", "noReply") await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set("channel", "123", "noReply") is False assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is False
async def test_set_idempotent(self, db: Database) -> None: async def test_set_idempotent(self, db: Database) -> None:
"""Setting the same flag twice does not raise.""" """Setting the same flag twice does not raise."""
await db.set_flag("channel", "123", "noReply") await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.set_flag("channel", "123", "noReply") await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set("channel", "123", "noReply") is True assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is True
async def test_scoped_to_entity_id(self, db: Database) -> None:
"""A flag set on one entity is not visible on another entity."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "456", Flag.NO_REPLY) is False
async def test_scoped_to_entity_type(self, db: Database) -> None:
"""A flag set on one entity type is not visible on another."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.USER, "123", Flag.NO_REPLY) is False
async def test_scoped_to_flag_name(self, db: Database) -> None:
"""A flag set under one name is not visible under a different name."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_INGEST) is False
async def test_clear_scoped_to_flag_name(self, db: Database) -> None:
"""Clearing one flag leaves other flags on the same entity intact."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_INGEST)
await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_INGEST) is True
async def test_clear_scoped_to_entity_id(self, db: Database) -> None:
"""Clearing a flag on one entity leaves another entity."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.set_flag(EntityType.CHANNEL, "456", Flag.NO_REPLY)
await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "456", Flag.NO_REPLY) is True
async def test_clear_scoped_to_entity_type(self, db: Database) -> None:
"""Clearing a flag on one entity type leaves another type."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.set_flag(EntityType.USER, "123", Flag.NO_REPLY)
await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.USER, "123", Flag.NO_REPLY) is True
async def test_clear_unset_flag_is_noop(self, db: Database) -> None:
"""Clearing a flag that was never set does not raise or affect other flags."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_INGEST)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is True
class TestChannelIngestion: class TestChannelIngestion:
@@ -180,6 +498,129 @@ class TestChannelIngestion:
await db.mark_channel_ingested(42) await db.mark_channel_ingested(42)
assert await db.is_channel_ingested(42) is True assert await db.is_channel_ingested(42) is True
async def test_scoped_to_channel(self, db: Database) -> None:
"""Marking one channel as ingested does not affect another channel."""
await db.mark_channel_ingested(42)
assert await db.is_channel_ingested(99) is False
class TestTransactionRollback:
"""Transaction rolls back all changes on error."""
async def test_error_rolls_back_insert(self, db: Database) -> None:
"""An error during a transaction prevents partial data from persisting."""
with pytest.raises(RuntimeError, match="simulated"):
await self._insert_and_fail(db)
assert await db.get_random_start_word(1) is None
@staticmethod
async def _insert_and_fail(db: Database) -> None:
async with db._transaction():
await db._connection.execute(
"INSERT INTO markov_start_words"
" (channel_id, user_id, word)"
" VALUES (?, ?, ?)",
(1, 100, "should_not_persist"),
)
msg = "simulated failure"
raise RuntimeError(msg)
class TestForgetUser:
"""Atomic forget-user transaction across all tables."""
async def test_deletes_data_and_sets_no_ingest(self, db: Database) -> None:
"""All user data is removed and noIngest flag is set."""
await db.add_markov_data(
[StartWord(1, 100, "Hello")],
[Transition(1, 100, "Hello", "world.")],
)
await db.add_images([ChannelImage(1, 100, "https://example.com/a.png")])
await db.set_flag(EntityType.USER, "100", Flag.ALLOW_PINGS)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "Hello") is None
assert await db.get_random_image(1) is None
assert await db.is_flag_set(EntityType.USER, "100", Flag.ALLOW_PINGS) is False
assert await db.is_flag_set(EntityType.USER, "100", Flag.NO_INGEST) is True
async def test_preserves_other_users(self, db: Database) -> None:
"""Data belonging to other users is not affected."""
await db.add_markov_data(
[StartWord(1, 100, "Gone"), StartWord(1, 200, "Keep")],
[
Transition(1, 100, "Gone", "away."),
Transition(1, 200, "Keep", "this."),
],
)
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/gone.png"),
ChannelImage(1, 200, "https://example.com/stay.png"),
],
)
await db.set_flag(EntityType.USER, "200", Flag.ALLOW_PINGS)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.get_random_start_word(1) == "Keep"
assert await db.get_random_next_word(1, "Keep") == "this."
assert await db.get_random_image(1) == "https://example.com/stay.png"
assert await db.is_flag_set(EntityType.USER, "200", Flag.ALLOW_PINGS) is True
async def test_preserves_other_entity_type_flags(self, db: Database) -> None:
"""Flags on channels with the same entity ID are not affected."""
await db.set_flag(EntityType.CHANNEL, "100", Flag.NO_REPLY)
await db.set_flag(EntityType.USER, "100", Flag.NO_REPLY)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.is_flag_set(EntityType.CHANNEL, "100", Flag.NO_REPLY) is True
assert await db.is_flag_set(EntityType.USER, "100", Flag.NO_REPLY) is False
async def test_clears_existing_flags_except_no_ingest(self, db: Database) -> None:
"""Existing user flags are cleared but noIngest remains."""
await db.set_flag(EntityType.USER, "100", Flag.NO_REPLY)
await db.set_flag(EntityType.USER, "100", Flag.ALLOW_PINGS)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.is_flag_set(EntityType.USER, "100", Flag.NO_REPLY) is False
assert await db.is_flag_set(EntityType.USER, "100", Flag.ALLOW_PINGS) is False
assert await db.is_flag_set(EntityType.USER, "100", Flag.NO_INGEST) is True
async def test_deletes_across_channels(self, db: Database) -> None:
"""All user data is removed from every channel."""
await db.add_markov_data(
[StartWord(1, 100, "One"), StartWord(2, 100, "Two")],
[
Transition(1, 100, "One", "fish."),
Transition(2, 100, "Two", "fish."),
],
)
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(2, 100, "https://example.com/b.png"),
],
)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.get_random_start_word(1) is None
assert await db.get_random_start_word(2) is None
assert await db.get_random_next_word(1, "One") is None
assert await db.get_random_next_word(2, "Two") is None
assert await db.get_random_image(1) is None
assert await db.get_random_image(2) is None
async def test_noop_for_nonexistent_user(self, db: Database) -> None:
"""Forgetting a user with no data does not raise."""
await db.forget_user(999, Flag.NO_INGEST)
assert await db.is_flag_set(EntityType.USER, "999", Flag.NO_INGEST) is True
class TestWriteDurability: class TestWriteDurability:
"""Writes persist across close and reopen.""" """Writes persist across close and reopen."""
@@ -188,7 +629,10 @@ class TestWriteDurability:
"""Data written via add_markov_data is durable after close/reopen.""" """Data written via add_markov_data is durable after close/reopen."""
db_path = str(tmp_path / "durability.db") db_path = str(tmp_path / "durability.db")
db = await Database.connect(db_path) db = await Database.connect(db_path)
await db.add_markov_data([(1, 100, "Hello")], [(1, 100, "Hello", "world.")]) await db.add_markov_data(
[StartWord(1, 100, "Hello")],
[Transition(1, 100, "Hello", "world.")],
)
await db.close() await db.close()
db2 = await Database.connect(db_path) db2 = await Database.connect(db_path)
+83 -24
View File
@@ -22,12 +22,15 @@ from typing import TYPE_CHECKING
import pytest import pytest
from crabstero.database import StartWord, Transition
from crabstero.markov import ( from crabstero.markov import (
DEFAULT_SENTENCE_END, DEFAULT_SENTENCE_END,
_ingest_sentence, _ingest_sentence,
_uningest_sentence,
generate, generate,
ingest, ingest,
is_complete_sentence, is_complete_sentence,
uningest,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -50,7 +53,7 @@ class TestIsCompleteSentence:
pytest.param("Hello,", False, id="comma"), pytest.param("Hello,", False, id="comma"),
], ],
) )
def test_detection(self, sentence: str, expected: bool) -> None: def test_detection(self, sentence: str, expected: bool) -> None: # noqa: FBT001
"""Correctly identifies sentence completeness.""" """Correctly identifies sentence completeness."""
assert is_complete_sentence(sentence) is expected assert is_complete_sentence(sentence) is expected
@@ -101,7 +104,7 @@ class TestIngestSentence:
assert await db.get_random_next_word(1, "B") == "C." assert await db.get_random_next_word(1, "B") == "C."
class TestIngest: class TestIngestParagraph:
"""Paragraph ingestion splits into sentences.""" """Paragraph ingestion splits into sentences."""
async def test_single_sentence(self, db: Database) -> None: async def test_single_sentence(self, db: Database) -> None:
@@ -144,7 +147,9 @@ class TestIngest:
], ],
) )
async def test_empty_input_stores_nothing( async def test_empty_input_stores_nothing(
self, db: Database, paragraph: str self,
db: Database,
paragraph: str,
) -> None: ) -> None:
"""Empty or whitespace-only input does not store any data.""" """Empty or whitespace-only input does not store any data."""
await ingest(db, 1, 100, paragraph) await ingest(db, 1, 100, paragraph)
@@ -173,8 +178,8 @@ class TestGenerate:
"""Returns an informational message when the channel has no data.""" """Returns an informational message when the channel has no data."""
result = await generate(db, 1) result = await generate(db, 1)
expected = ( expected = (
"There's not enough data to generate a message yet." "I do not have enough data to generate a message yet."
" Chat a bit more so I can learn how this channel talks!" " Chat a bit more so I can learn how this channel talks."
) )
assert result == expected assert result == expected
@@ -209,16 +214,16 @@ class TestGenerate:
# continuing ("E") and completing ("end.") transition, so # continuing ("E") and completing ("end.") transition, so
# get_random_completing_next_word deterministically picks "end.". # get_random_completing_next_word deterministically picks "end.".
await db.add_markov_data( await db.add_markov_data(
[(1, 100, "A")], [StartWord(1, 100, "A")],
[ [
(1, 100, "A", "B"), Transition(1, 100, "A", "B"),
(1, 100, "B", "C"), Transition(1, 100, "B", "C"),
(1, 100, "C", "D"), Transition(1, 100, "C", "D"),
(1, 100, "D", "E"), Transition(1, 100, "D", "E"),
(1, 100, "D", "F"), Transition(1, 100, "D", "F"),
(1, 100, "D", "G"), Transition(1, 100, "D", "G"),
(1, 100, "D", "H"), Transition(1, 100, "D", "H"),
(1, 100, "D", "end."), Transition(1, 100, "D", "end."),
], ],
) )
@@ -228,14 +233,14 @@ class TestGenerate:
async def test_start_word_already_ends_sentence(self, db: Database) -> None: async def test_start_word_already_ends_sentence(self, db: Database) -> None:
"""Generation stops immediately when the start word is sentence-ending.""" """Generation stops immediately when the start word is sentence-ending."""
await db.add_markov_data([(1, 100, "Yes.")], []) await db.add_markov_data([StartWord(1, 100, "Yes.")], [])
result = await generate(db, 1) result = await generate(db, 1)
assert result == "Yes." assert result == "Yes."
async def test_chain_dead_end(self, db: Database) -> None: async def test_chain_dead_end(self, db: Database) -> None:
"""Generation stops when no next word exists (dead-end chain).""" """Generation stops when no next word exists (dead-end chain)."""
await db.add_markov_data([(1, 100, "Hello")], []) await db.add_markov_data([StartWord(1, 100, "Hello")], [])
# "Hello" has no transitions, so the loop breaks immediately. # "Hello" has no transitions, so the loop breaks immediately.
result = await generate(db, 1) result = await generate(db, 1)
@@ -245,10 +250,10 @@ class TestGenerate:
"""Section sign at the truncation boundary is stripped.""" """Section sign at the truncation boundary is stripped."""
# Build a chain: "A" -> "B" -> "C§". # Build a chain: "A" -> "B" -> "C§".
await db.add_markov_data( await db.add_markov_data(
[(1, 100, "A")], [StartWord(1, 100, "A")],
[ [
(1, 100, "A", "B"), Transition(1, 100, "A", "B"),
(1, 100, "B", f"C{DEFAULT_SENTENCE_END}"), Transition(1, 100, "B", f"C{DEFAULT_SENTENCE_END}"),
], ],
) )
@@ -257,19 +262,26 @@ class TestGenerate:
assert result == "A B C" assert result == "A B C"
async def test_soft_limit_falls_back_to_regular_next_word( async def test_soft_limit_falls_back_to_regular_next_word(
self, db: Database self,
db: Database,
) -> None: ) -> None:
"""After soft_limit, falls back when no completing word exists.""" """After soft_limit, falls back when no completing word exists."""
# "A" -> "B" (no completing transition). Past soft_limit, # "A" -> "B" (no completing transition). Past soft_limit,
# get_random_completing_next_word returns None, falls back to "B". # get_random_completing_next_word returns None, falls back to "B".
await db.add_markov_data([(1, 100, "A")], [(1, 100, "A", "B")]) await db.add_markov_data(
[StartWord(1, 100, "A")],
[Transition(1, 100, "A", "B")],
)
result = await generate(db, 1, soft_limit=1, hard_limit=1000) result = await generate(db, 1, soft_limit=1, hard_limit=1000)
assert result == "A B" assert result == "A B"
async def test_hard_limit_truncates_mid_word(self, db: Database) -> None: async def test_hard_limit_truncates_mid_word(self, db: Database) -> None:
"""Hard limit slices output even when it falls inside a word.""" """Hard limit slices output even when it falls inside a word."""
await db.add_markov_data([(1, 100, "AB")], [(1, 100, "AB", "CDEF")]) await db.add_markov_data(
[StartWord(1, 100, "AB")],
[Transition(1, 100, "AB", "CDEF")],
)
# "AB CDEF" is 7 chars; hard_limit=5 truncates to "AB CD". # "AB CDEF" is 7 chars; hard_limit=5 truncates to "AB CD".
result = await generate(db, 1, soft_limit=100, hard_limit=5) result = await generate(db, 1, soft_limit=100, hard_limit=5)
@@ -283,7 +295,54 @@ class TestGenerate:
# Channel 3 has no data; should get the fallback. # Channel 3 has no data; should get the fallback.
result = await generate(db, 3) result = await generate(db, 3)
expected = ( expected = (
"There's not enough data to generate a message yet." "I do not have enough data to generate a message yet."
" Chat a bit more so I can learn how this channel talks!" " Chat a bit more so I can learn how this channel talks."
) )
assert result == expected assert result == expected
class TestUningestSentence:
"""Single sentence uningest from the Markov chain."""
async def test_removes_start_word_and_transition(self, db: Database) -> None:
"""Uningest removes the start word and transition added by ingest."""
await _ingest_sentence(db, 1, 100, "Hello world.")
await _uningest_sentence(db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "Hello") is None
async def test_preserves_other_data(self, db: Database) -> None:
"""Uningest only removes data for the specified sentence."""
await _ingest_sentence(db, 1, 100, "Hello world.")
await _ingest_sentence(db, 1, 100, "Goodbye world.")
await _uningest_sentence(db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) == "Goodbye"
async def test_handles_missing_punctuation(self, db: Database) -> None:
"""Uningest appends the default sentence end, matching ingest behavior."""
await _ingest_sentence(db, 1, 100, "Hello world")
await _uningest_sentence(db, 1, 100, "Hello world")
assert await db.get_random_start_word(1) is None
async def test_single_word_is_noop(self, db: Database) -> None:
"""Uningesting a single-word sentence does not error."""
await _ingest_sentence(db, 1, 100, "Hello.")
await _uningest_sentence(db, 1, 100, "Hello.")
class TestUningestParagraph:
"""Paragraph-level uningest."""
async def test_uningest_multiple_sentences(self, db: Database) -> None:
"""Uningest reverses a multi-sentence paragraph."""
await ingest(db, 1, 100, "Hello world. Goodbye world!")
await uningest(db, 1, 100, "Hello world. Goodbye world!")
assert await db.get_random_start_word(1) is None
async def test_uningest_preserves_duplicate_data(self, db: Database) -> None:
"""Uningesting one copy leaves the other intact."""
await ingest(db, 1, 100, "Hello world.")
await ingest(db, 1, 100, "Hello world.")
await uningest(db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) == "Hello"
assert await db.get_random_next_word(1, "Hello") == "world."
+126 -33
View File
@@ -17,16 +17,41 @@
Tests cover metric object registration and the MetricsServer HTTP endpoint. Tests cover metric object registration and the MetricsServer HTTP endpoint.
""" """
from typing import TYPE_CHECKING import errno
import socket
from dataclasses import dataclass
from http import HTTPStatus
from typing import TYPE_CHECKING, Literal, cast
import aiohttp import aiohttp
import pytest import pytest
from prometheus_client import generate_latest from prometheus_client import generate_latest
from crabstero.metrics import MetricsServer from crabstero.metrics import MetricsServer, TcpMetricsAddress, UnixMetricsAddress
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from pathlib import Path
type MetricsTransport = Literal["tcp", "unix-socket"]
@dataclass(frozen=True, slots=True)
class MetricsEndpoint:
"""Client details for one running metrics transport."""
base_url: str
unix_socket_path: str | None = None
def client_session(self) -> aiohttp.ClientSession:
"""Create an aiohttp client session for this metrics transport."""
connector = (
aiohttp.UnixConnector(path=self.unix_socket_path)
if self.unix_socket_path is not None
else None
)
return aiohttp.ClientSession(connector=connector)
class TestMetricObjects: class TestMetricObjects:
@@ -36,23 +61,15 @@ class TestMetricObjects:
"name", "name",
[ [
pytest.param("crabstero_build_info", id="build-info"), pytest.param("crabstero_build_info", id="build-info"),
pytest.param("crabstero_messages_ingested_total", id="messages-ingested"),
pytest.param("crabstero_replies_sent_total", id="replies-sent"), pytest.param("crabstero_replies_sent_total", id="replies-sent"),
pytest.param("crabstero_embeds_generated_total", id="embeds-generated"), pytest.param("crabstero_embeds_generated_total", id="embeds-generated"),
pytest.param("crabstero_messages_processed_total", id="messages-processed"), pytest.param("crabstero_messages_processed_total", id="messages-processed"),
pytest.param("crabstero_channels_ingested_total", id="channels-ingested"), pytest.param("crabstero_pingme_total", id="pingme"),
pytest.param( pytest.param("crabstero_forgetme_total", id="forgetme"),
"crabstero_channel_ingestion_errors_total", pytest.param("crabstero_errors_total", id="errors"),
id="channel-ingestion-errors",
),
pytest.param("crabstero_pingme_opted_in_total", id="pingme-opted-in"),
pytest.param("crabstero_pingme_opted_out_total", id="pingme-opted-out"),
pytest.param("crabstero_pingme_errors_total", id="pingme-errors"),
pytest.param("crabstero_discord_latency_seconds", id="discord-latency"), pytest.param("crabstero_discord_latency_seconds", id="discord-latency"),
pytest.param("crabstero_guild_count", id="guild-count"), pytest.param("crabstero_guild_count", id="guild-count"),
pytest.param( pytest.param("crabstero_ingestion_active_channels", id="ingestion-active"),
"crabstero_ingestion_backlog_channels", id="ingestion-backlog"
),
pytest.param( pytest.param(
"crabstero_message_ingestion_duration_seconds", "crabstero_message_ingestion_duration_seconds",
id="message-ingestion-duration", id="message-ingestion-duration",
@@ -62,16 +79,18 @@ class TestMetricObjects:
id="channel-ingestion-duration", id="channel-ingestion-duration",
), ),
pytest.param( pytest.param(
"crabstero_generation_duration_seconds", id="generation-duration" "crabstero_generation_duration_seconds",
id="generation-duration",
), ),
pytest.param( pytest.param(
"crabstero_generated_message_length_characters", "crabstero_channel_ingestion_messages",
id="generated-message-length", id="channel-ingestion-messages",
),
pytest.param(
"crabstero_channel_ingestion_messages", id="channel-ingestion-messages"
), ),
pytest.param("crabstero_discord_events_total", id="discord-events"), pytest.param("crabstero_discord_events_total", id="discord-events"),
pytest.param(
"crabstero_messages_uningested_total",
id="messages-uningested",
),
], ],
) )
def test_metric_in_output(self, name: str) -> None: def test_metric_in_output(self, name: str) -> None:
@@ -80,34 +99,108 @@ class TestMetricObjects:
assert name in output, f"{name} not found in Prometheus output" assert name in output, f"{name} not found in Prometheus output"
@pytest.fixture @pytest.fixture(
async def metrics_server() -> AsyncGenerator[MetricsServer]: params=[
"""Start a MetricsServer on an OS-assigned port and stop it after the test.""" pytest.param("tcp", id="tcp"),
server = MetricsServer("127.0.0.1", 0) pytest.param("unix-socket", id="unix-socket"),
],
)
async def metrics_endpoint(
request: pytest.FixtureRequest,
tmp_path: Path,
) -> AsyncGenerator[MetricsEndpoint]:
"""Start a MetricsServer on each supported transport."""
transport = cast("MetricsTransport", request.param)
match transport:
case "tcp":
server = MetricsServer(TcpMetricsAddress("127.0.0.1", 0))
await server.start() await server.start()
yield server endpoint = MetricsEndpoint(f"http://127.0.0.1:{server.port}")
case "unix-socket":
socket_path = tmp_path / "metrics.sock"
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
await server.start()
endpoint = MetricsEndpoint(
"http://crabstero",
unix_socket_path=str(socket_path),
)
try:
yield endpoint
finally:
await server.stop() await server.stop()
class TestMetricsServer: class TestMetricsServer:
"""HTTP server serves Prometheus metrics on /metrics.""" """HTTP server serves Prometheus metrics on /metrics."""
async def test_serves_metrics_endpoint(self, metrics_server: MetricsServer) -> None: async def test_serves_metrics_endpoint(
self,
metrics_endpoint: MetricsEndpoint,
) -> None:
"""GET /metrics returns 200 with metric output containing our metrics.""" """GET /metrics returns 200 with metric output containing our metrics."""
async with ( async with (
aiohttp.ClientSession() as session, metrics_endpoint.client_session() as session,
session.get(f"http://127.0.0.1:{metrics_server.port}/metrics") as resp, session.get(f"{metrics_endpoint.base_url}/metrics") as resp,
): ):
assert resp.status == 200 assert resp.status == HTTPStatus.OK
body = await resp.text() body = await resp.text()
assert "crabstero_build_info" in body assert "crabstero_build_info" in body
async def test_non_metrics_path_returns_404( async def test_non_metrics_path_returns_404(
self, metrics_server: MetricsServer self,
metrics_endpoint: MetricsEndpoint,
) -> None: ) -> None:
"""GET on an unknown path returns 404.""" """GET on an unknown path returns 404."""
async with ( async with (
aiohttp.ClientSession() as session, metrics_endpoint.client_session() as session,
session.get(f"http://127.0.0.1:{metrics_server.port}/notfound") as resp, session.get(f"{metrics_endpoint.base_url}/notfound") as resp,
): ):
assert resp.status == 404 assert resp.status == HTTPStatus.NOT_FOUND
async def test_stale_unix_socket_path_is_recovered(
self,
tmp_path: Path,
) -> None:
"""Startup removes an abandoned Unix socket file left by a crash."""
socket_path = tmp_path / "metrics.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as stale_socket:
stale_socket.bind(str(socket_path))
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
await server.start()
try:
connector = aiohttp.UnixConnector(path=str(socket_path))
async with (
aiohttp.ClientSession(connector=connector) as session,
session.get("http://crabstero/metrics") as resp,
):
assert resp.status == HTTPStatus.OK
body = await resp.text()
assert "crabstero_build_info" in body
finally:
await server.stop()
async def test_active_unix_socket_path_fails(
self,
tmp_path: Path,
) -> None:
"""Startup refuses to replace a Unix socket path that is still in use."""
socket_path = tmp_path / "metrics.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as active_socket:
active_socket.bind(str(socket_path))
active_socket.listen(1)
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
with pytest.raises(OSError, match="already in use") as exc_info:
await server.start()
assert exc_info.value.errno == errno.EADDRINUSE
async def test_non_socket_unix_path_fails(self, tmp_path: Path) -> None:
"""Startup refuses to replace a non-socket path."""
socket_path = tmp_path / "metrics.sock"
socket_path.write_text("")
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
with pytest.raises(FileExistsError):
await server.start()
+265
View File
@@ -0,0 +1,265 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Integration tests for the full ingest → uningest cycle."""
from typing import TYPE_CHECKING
import pytest
from crabstero.cache import CachedMessage, IngestCache
from crabstero.database import ChannelImage
from crabstero.markov import ingest, uningest
from crabstero.messages import uningest_message
if TYPE_CHECKING:
import aiosqlite
from crabstero.database import Database
async def _snapshot(db: Database) -> dict[str, list[aiosqlite.Row]]:
"""Return sorted rows from all Markov-related tables."""
tables: dict[str, list[aiosqlite.Row]] = {}
for table in ("markov_start_words", "markov_transitions", "channel_images"):
async with db._connection.execute(f"SELECT * FROM {table}") as cursor: # noqa: S608
tables[table] = sorted(await cursor.fetchall())
return tables
class TestIngestUningestCycle:
"""Full round-trip: ingest data, then uningest it completely."""
async def test_content_round_trip(self, db: Database) -> None:
"""Ingest and uningest content leaves the database clean."""
await ingest(db, 1, 100, "Hello beautiful world.")
await uningest(db, 1, 100, "Hello beautiful world.")
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "Hello") is None
assert await db.get_random_next_word(1, "beautiful") is None
async def test_image_round_trip(self, db: Database) -> None:
"""Ingest and uningest an image leaves the database clean."""
await db.add_images([ChannelImage(1, 100, "https://example.com/cat.png")])
await db.remove_images([ChannelImage(1, 100, "https://example.com/cat.png")])
assert await db.get_random_image(1) is None
async def test_multi_sentence_round_trip(self, db: Database) -> None:
"""Multi-sentence ingest and uningest leaves the database clean."""
text = "Hello world. Goodbye world! How are you?"
await ingest(db, 1, 100, text)
await uningest(db, 1, 100, text)
assert await db.get_random_start_word(1) is None
async def test_uningest_only_removes_one_copy(self, db: Database) -> None:
"""Uningesting once preserves data from a second identical ingest."""
await ingest(db, 1, 100, "Hello world.")
await ingest(db, 1, 100, "Hello world.")
await uningest(db, 1, 100, "Hello world.")
# One copy of each row should remain.
assert await db.get_random_start_word(1) == "Hello"
assert await db.get_random_next_word(1, "Hello") == "world."
async def test_uningest_does_not_affect_other_channels(self, db: Database) -> None:
"""Uningesting from one channel leaves another channel's data intact."""
await ingest(db, 1, 100, "Hello world.")
await ingest(db, 2, 100, "Hello world.")
await uningest(db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) is None
assert await db.get_random_start_word(2) == "Hello"
assert await db.get_random_next_word(2, "Hello") == "world."
async def test_uningest_does_not_affect_other_users(self, db: Database) -> None:
"""Uningesting for one user leaves another user's data."""
await ingest(db, 1, 100, "Hello world.")
await ingest(db, 1, 200, "Hello world.")
await uningest(db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) == "Hello"
assert await db.get_random_next_word(1, "Hello") == "world."
class TestUningestRestoresState:
"""Uningest restores the database to its prior state."""
@pytest.mark.parametrize(
"text",
[
pytest.param("Hello world.", id="simple-sentence"),
pytest.param("Hello world", id="missing-punctuation"),
pytest.param(
"Hello world. Goodbye world! How are you?",
id="multi-sentence",
),
pytest.param("One.", id="single-word"),
pytest.param(
"Lots of extra spaces\nand\nnewlines here.",
id="whitespace-normalization",
),
],
)
async def test_uningest_restores_empty_db(self, db: Database, text: str) -> None:
"""Ingest then uningest on an empty database leaves all tables empty."""
before = await _snapshot(db)
await ingest(db, 1, 100, text)
await uningest(db, 1, 100, text)
after = await _snapshot(db)
assert after == before
@pytest.mark.parametrize(
"text",
[
pytest.param("Hello world.", id="simple-sentence"),
pytest.param(
"Hello world. Goodbye world! How are you?",
id="multi-sentence",
),
],
)
async def test_uningest_restores_preexisting_data(
self,
db: Database,
text: str,
) -> None:
"""Ingest then uningest preserves unrelated pre-existing data exactly."""
await ingest(db, 99, 200, "Pre-existing data stays safe.")
await db.add_images([ChannelImage(99, 200, "https://example.com/existing.png")])
before = await _snapshot(db)
await ingest(db, 1, 100, text)
await uningest(db, 1, 100, text)
after = await _snapshot(db)
assert after == before
class TestUningestMessage:
"""Orchestrated uningest via cache lookup and database reversal."""
async def test_content_only(self, db: Database) -> None:
"""Uningest reverses a content-only message via the cache."""
await ingest(db, 1, 100, "Hello beautiful world.")
cache = IngestCache()
cache.put(
555,
CachedMessage(
channel_id=1,
user_id=100,
content="Hello beautiful world.",
embed_texts=[],
image_urls=[],
),
)
await uningest_message(db, cache, 555)
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "Hello") is None
async def test_embeds_only(self, db: Database) -> None:
"""Uningest reverses embed text ingestion."""
await ingest(db, 1, 100, "Embed title here.")
await ingest(db, 1, 100, "Embed description here.")
cache = IngestCache()
cache.put(
556,
CachedMessage(
channel_id=1,
user_id=100,
content=None,
embed_texts=["Embed title here.", "Embed description here."],
image_urls=[],
),
)
await uningest_message(db, cache, 556)
assert await db.get_random_start_word(1) is None
async def test_content_with_embeds_and_images(self, db: Database) -> None:
"""Uningest reverses content, embed text, and image data together."""
await ingest(db, 1, 100, "Body text here.")
await ingest(db, 1, 100, "Embed title.")
await db.add_images([ChannelImage(1, 100, "https://example.com/img.png")])
cache = IngestCache()
cache.put(
557,
CachedMessage(
channel_id=1,
user_id=100,
content="Body text here.",
embed_texts=["Embed title."],
image_urls=["https://example.com/img.png"],
),
)
await uningest_message(db, cache, 557)
assert await db.get_random_start_word(1) is None
assert await db.get_random_image(1) is None
async def test_cache_miss_is_noop(self, db: Database) -> None:
"""A message not in the cache leaves the database unchanged."""
await ingest(db, 1, 100, "Keep this data.")
cache = IngestCache()
before = await _snapshot(db)
await uningest_message(db, cache, 999)
after = await _snapshot(db)
assert after == before
async def test_preserves_other_messages(self, db: Database) -> None:
"""Uningesting one message leaves another message's data intact."""
await ingest(db, 1, 100, "First message.")
await ingest(db, 1, 100, "Second message.")
cache = IngestCache()
cache.put(
601,
CachedMessage(
channel_id=1,
user_id=100,
content="First message.",
embed_texts=[],
image_urls=[],
),
)
await uningest_message(db, cache, 601)
assert await db.get_random_start_word(1) == "Second"
assert await db.get_random_next_word(1, "Second") == "message."
async def test_pops_entry_from_cache(self, db: Database) -> None:
"""The cache entry is consumed after uningest."""
await ingest(db, 1, 100, "Hello world.")
cache = IngestCache()
cache.put(
602,
CachedMessage(
channel_id=1,
user_id=100,
content="Hello world.",
embed_texts=[],
image_urls=[],
),
)
await uningest_message(db, cache, 602)
assert cache.pop(602) is None
Generated
+434 -322
View File
File diff suppressed because it is too large Load Diff