337 lines
13 KiB
Python
337 lines
13 KiB
Python
# 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.
|
|
|
|
"""
|
|
Provides async SQLite database access for all of Crabstero's persistent storage needs.
|
|
|
|
Uses aiosqlite for native async access. All methods are async def.
|
|
"""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
from typing import Self
|
|
|
|
import aiosqlite
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
AUTO_COMMIT_WRITE_THRESHOLD = 100 # Commit after this many DB write operations.
|
|
AUTO_COMMIT_TIMEOUT_SECONDS = (
|
|
60.0 # Commit after this many seconds since first uncommitted write.
|
|
)
|
|
|
|
# SQL statements for creating the database schema.
|
|
_SCHEMA = """
|
|
-- Markov chain starting words.
|
|
-- Each row represents one occurrence. Duplicates represent frequency weight.
|
|
CREATE TABLE IF NOT EXISTS markov_start_words (
|
|
channel_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
word TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_start_channel ON markov_start_words(channel_id, word);
|
|
CREATE INDEX IF NOT EXISTS idx_start_user ON markov_start_words(user_id);
|
|
|
|
-- Markov chain word transitions.
|
|
-- Each row represents one occurrence. Duplicates represent frequency weight.
|
|
CREATE TABLE IF NOT EXISTS markov_transitions (
|
|
channel_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
word TEXT NOT NULL,
|
|
next_word TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_transitions_channel_word ON markov_transitions(channel_id, word);
|
|
CREATE INDEX IF NOT EXISTS idx_transitions_user ON markov_transitions(user_id);
|
|
|
|
-- Image URLs per channel.
|
|
CREATE TABLE IF NOT EXISTS channel_images (
|
|
channel_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
url TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_images_channel ON channel_images(channel_id);
|
|
CREATE INDEX IF NOT EXISTS idx_images_user ON channel_images(user_id);
|
|
|
|
-- Flags for channels, servers, and users.
|
|
CREATE TABLE IF NOT EXISTS flags (
|
|
entity_type TEXT NOT NULL,
|
|
entity_id TEXT NOT NULL,
|
|
flag_name TEXT NOT NULL,
|
|
PRIMARY KEY (entity_type, entity_id, flag_name)
|
|
);
|
|
|
|
-- Tracks which channels have been bulk-ingested.
|
|
CREATE TABLE IF NOT EXISTS ingested_channels (
|
|
channel_id INTEGER NOT NULL PRIMARY KEY
|
|
);
|
|
"""
|
|
|
|
|
|
class Database:
|
|
"""
|
|
Manages all SQLite database operations for Crabstero.
|
|
|
|
Uses aiosqlite for native async access. A single connection is held open for the lifetime of
|
|
the bot process with WAL mode enabled for concurrent read performance.
|
|
"""
|
|
|
|
def __init__(self, connection: aiosqlite.Connection) -> None:
|
|
"""
|
|
Initializes the Database wrapper with an already-opened aiosqlite connection.
|
|
|
|
:param connection: An open aiosqlite connection.
|
|
"""
|
|
self._connection = connection
|
|
self._pending_writes = 0
|
|
self._flush_task: asyncio.Task[None] | None = None
|
|
|
|
@classmethod
|
|
async def connect(cls, path: str) -> Self:
|
|
"""
|
|
Opens a new SQLite database at the given path, configures it for performance, and creates
|
|
the schema if it does not already exist.
|
|
|
|
:param path: The file path to the SQLite database.
|
|
:return: A new Database instance ready for use.
|
|
"""
|
|
connection = await aiosqlite.connect(path)
|
|
|
|
# Enable WAL mode for better concurrent read performance.
|
|
await connection.execute("PRAGMA journal_mode=WAL")
|
|
|
|
# Set synchronous to NORMAL for a balance between safety and speed.
|
|
await connection.execute("PRAGMA synchronous=NORMAL")
|
|
|
|
# Create the schema tables and indexes if they do not already exist.
|
|
# executescript auto-commits, so no explicit commit is needed.
|
|
await connection.executescript(_SCHEMA)
|
|
|
|
return cls(connection)
|
|
|
|
async def commit(self) -> None:
|
|
"""Commits pending writes and resets the flush timer."""
|
|
await self._connection.commit()
|
|
self._pending_writes = 0
|
|
if self._flush_task is not None:
|
|
self._flush_task.cancel()
|
|
self._flush_task = None
|
|
|
|
async def close(self) -> None:
|
|
"""Cancels the flush timer, commits any pending writes, then closes the database connection."""
|
|
if self._flush_task is not None:
|
|
self._flush_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await self._flush_task
|
|
self._flush_task = None
|
|
if self._pending_writes > 0:
|
|
await self._connection.commit()
|
|
self._pending_writes = 0
|
|
await self._connection.close()
|
|
|
|
async def add_start_words_batch(self, rows: list[tuple[int, int, str]]) -> None:
|
|
"""
|
|
Inserts a batch of starting words into the markov_start_words table.
|
|
|
|
:param rows: A list of (channel_id, user_id, word) tuples to insert.
|
|
"""
|
|
await self._connection.executemany(
|
|
"INSERT INTO markov_start_words (channel_id, user_id, word) VALUES (?, ?, ?)",
|
|
rows,
|
|
)
|
|
await self._maybe_commit()
|
|
|
|
async def get_random_start_word(self, channel_id: int) -> str | None:
|
|
"""
|
|
Returns a random starting word for a given channel, weighted by occurrence frequency.
|
|
|
|
:param channel_id: The Discord channel ID.
|
|
:return: A random starting word, or None if no starting words exist for this channel.
|
|
"""
|
|
async with self._connection.execute(
|
|
"SELECT word FROM markov_start_words WHERE channel_id = ? ORDER BY RANDOM() LIMIT 1",
|
|
(channel_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
return row[0] if row else None
|
|
|
|
async def add_transitions_batch(
|
|
self, rows: list[tuple[int, int, str, str]]
|
|
) -> None:
|
|
"""
|
|
Inserts a batch of word transitions into the markov_transitions table.
|
|
|
|
:param rows: A list of (channel_id, user_id, word, next_word) tuples to insert.
|
|
"""
|
|
await self._connection.executemany(
|
|
"INSERT INTO markov_transitions (channel_id, user_id, word, next_word) VALUES (?, ?, ?, ?)",
|
|
rows,
|
|
)
|
|
await self._maybe_commit()
|
|
|
|
async def get_random_next_word(self, channel_id: int, word: str) -> str | None:
|
|
"""
|
|
Returns a random next word for a given word in a given channel, weighted by occurrence
|
|
frequency.
|
|
|
|
:param channel_id: The Discord channel ID.
|
|
:param word: The current word to find a transition for.
|
|
:return: A random next word, or None if no transitions exist.
|
|
"""
|
|
async with self._connection.execute(
|
|
"SELECT next_word FROM markov_transitions WHERE channel_id = ? AND word = ? ORDER BY RANDOM() LIMIT 1",
|
|
(channel_id, word),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
return row[0] if row else None
|
|
|
|
async def get_random_completing_next_word(
|
|
self, channel_id: int, word: str
|
|
) -> str | None:
|
|
"""
|
|
Returns a random next word that ends a sentence for a given word in a given channel,
|
|
weighted by occurrence frequency.
|
|
|
|
A completing word is one whose last character is '.', '!', '?', or '§'.
|
|
|
|
:param channel_id: The Discord channel ID.
|
|
:param word: The current word to find a completing transition for.
|
|
:return: A random completing next word, or None if no completing transitions exist.
|
|
"""
|
|
async with self._connection.execute(
|
|
"SELECT next_word FROM markov_transitions WHERE channel_id = ? AND word = ? AND SUBSTR(next_word, -1, 1) IN ('.', '!', '?', '§') ORDER BY RANDOM() LIMIT 1",
|
|
(channel_id, word),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
return row[0] if row else None
|
|
|
|
async def add_image(self, channel_id: int, user_id: int, url: str) -> None:
|
|
"""
|
|
Stores an image URL for a given channel.
|
|
|
|
:param channel_id: The Discord channel ID.
|
|
:param user_id: The Discord user ID of the contributor.
|
|
:param url: The image URL to store.
|
|
"""
|
|
await self._connection.execute(
|
|
"INSERT INTO channel_images (channel_id, user_id, url) VALUES (?, ?, ?)",
|
|
(channel_id, user_id, url),
|
|
)
|
|
await self._maybe_commit()
|
|
|
|
async def get_random_image(self, channel_id: int) -> str | None:
|
|
"""
|
|
Returns a random image URL for a given channel.
|
|
|
|
:param channel_id: The Discord channel ID.
|
|
:return: A random image URL, or None if no images exist for this channel.
|
|
"""
|
|
async with self._connection.execute(
|
|
"SELECT url FROM channel_images WHERE channel_id = ? ORDER BY RANDOM() LIMIT 1",
|
|
(channel_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
return row[0] if row else None
|
|
|
|
async def set_flag(self, entity_type: str, entity_id: str, flag_name: str) -> None:
|
|
"""
|
|
Sets a flag on a given entity. If the flag is already set, this is a no-op.
|
|
|
|
:param entity_type: The type of entity ("channel", "server", or "user").
|
|
:param entity_id: The Discord ID of the entity.
|
|
:param flag_name: The name of the flag to set.
|
|
"""
|
|
await self._connection.execute(
|
|
"INSERT OR IGNORE INTO flags (entity_type, entity_id, flag_name) VALUES (?, ?, ?)",
|
|
(entity_type, entity_id, flag_name),
|
|
)
|
|
await self._maybe_commit()
|
|
|
|
async def clear_flag(
|
|
self, entity_type: str, entity_id: str, flag_name: str
|
|
) -> None:
|
|
"""
|
|
Clears a flag on a given entity. If the flag is not set, this is a no-op.
|
|
|
|
:param entity_type: The type of entity ("channel", "server", or "user").
|
|
:param entity_id: The Discord ID of the entity.
|
|
:param flag_name: The name of the flag to clear.
|
|
"""
|
|
await self._connection.execute(
|
|
"DELETE FROM flags WHERE entity_type = ? AND entity_id = ? AND flag_name = ?",
|
|
(entity_type, entity_id, flag_name),
|
|
)
|
|
await self._maybe_commit()
|
|
|
|
async def is_flag_set(
|
|
self, entity_type: str, entity_id: str, flag_name: str
|
|
) -> bool:
|
|
"""
|
|
Checks whether a flag is set on a given entity.
|
|
|
|
:param entity_type: The type of entity ("channel", "server", or "user").
|
|
:param entity_id: The Discord ID of the entity.
|
|
:param flag_name: The name of the flag to check.
|
|
:return: True if the flag is set, False otherwise.
|
|
"""
|
|
async with self._connection.execute(
|
|
"SELECT 1 FROM flags WHERE entity_type = ? AND entity_id = ? AND flag_name = ?",
|
|
(entity_type, entity_id, flag_name),
|
|
) as cursor:
|
|
return await cursor.fetchone() is not None
|
|
|
|
async def is_channel_ingested(self, channel_id: int) -> bool:
|
|
"""
|
|
Checks whether a channel has already been bulk-ingested.
|
|
|
|
:param channel_id: The Discord channel ID.
|
|
:return: True if the channel has been ingested, False otherwise.
|
|
"""
|
|
async with self._connection.execute(
|
|
"SELECT 1 FROM ingested_channels WHERE channel_id = ?",
|
|
(channel_id,),
|
|
) as cursor:
|
|
return await cursor.fetchone() is not None
|
|
|
|
async def mark_channel_ingested(self, channel_id: int) -> None:
|
|
"""
|
|
Marks a channel as having been bulk-ingested.
|
|
|
|
:param channel_id: The Discord channel ID.
|
|
"""
|
|
await self._connection.execute(
|
|
"INSERT OR IGNORE INTO ingested_channels (channel_id) VALUES (?)",
|
|
(channel_id,),
|
|
)
|
|
await self._maybe_commit()
|
|
|
|
async def _maybe_commit(self) -> None:
|
|
"""Tracks a pending write. Commits if the threshold is reached, otherwise starts a flush timer."""
|
|
self._pending_writes += 1
|
|
if self._pending_writes >= AUTO_COMMIT_WRITE_THRESHOLD:
|
|
await self.commit()
|
|
elif self._flush_task is None:
|
|
self._flush_task = asyncio.create_task(self._flush_after_timeout())
|
|
|
|
async def _flush_after_timeout(self) -> None:
|
|
"""Background task that commits after the timeout elapses."""
|
|
try:
|
|
await asyncio.sleep(AUTO_COMMIT_TIMEOUT_SECONDS)
|
|
if self._pending_writes > 0:
|
|
await self.commit()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
logger.exception("Error in database flush timer.")
|