Expanded linting rules, added codespell and pip-audit, and fixed all violations.
CI / Formatting (push) Successful in 11s
CI / Linting (push) Successful in 11s
CI / Tests (push) Successful in 15s
CI / Type Checking (push) Successful in 21s
CI / Spelling (push) Successful in 12s
Dependency Audit / Dependency Audit (push) Successful in 7s

This commit is contained in:
2026-02-20 10:30:36 -05:00
parent 0f2f43a55a
commit ee73e36f8e
19 changed files with 645 additions and 189 deletions
+29 -15
View File
@@ -12,10 +12,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Entry point for the Crabstero Discord bot.
"""Entry point for the Crabstero Discord bot.
Provides an argparse CLI with environment variable and systemd credential fallbacks for --token and --database-path.
Provides an argparse CLI with environment variable and systemd credential
fallbacks for --token and --database-path.
"""
import argparse
@@ -32,8 +32,7 @@ logger = logging.getLogger("crabstero")
def _read_credential(name: str) -> str | None:
"""
Read a value from a systemd credential file.
"""Read a value from a systemd credential file.
Looks for a file named *name* inside the directory pointed to by the
``CREDENTIALS_DIRECTORY`` environment variable (set automatically by
@@ -53,8 +52,7 @@ def _read_credential(name: str) -> str | None:
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""
Parses command-line arguments with environment variable fallbacks.
"""Parse command-line arguments with environment variable fallbacks.
:param argv: Optional argument list (defaults to sys.argv[1:]).
:return: Parsed arguments namespace.
@@ -66,41 +64,57 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser.add_argument(
"--token",
default=os.environ.get("TOKEN") or _read_credential("token"),
help="Discord bot token (default: TOKEN environment variable or systemd credential 'token').",
help=(
"Discord bot token"
" (default: TOKEN environment variable"
" or systemd credential 'token')."
),
)
parser.add_argument(
"--database-path",
"--database",
default=os.environ.get("DATABASE_PATH", "crabstero.db"),
help='Path to the SQLite database file (default: DATABASE_PATH environment variable or "crabstero.db").',
help=(
"Path to the SQLite database file"
" (default: DATABASE_PATH environment"
' 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).",
help=(
"Number of concurrent ingestion workers"
" (default: INGESTION_WORKERS environment"
" variable or 4)."
),
)
parser.add_argument(
"--ingest-only",
action="store_true",
default=False,
help="Run in ingest-only mode: ingest channel history and real-time messages but never respond.",
help=(
"Run in ingest-only mode: ingest channel"
" history and real-time messages but"
" never respond."
),
)
args = parser.parse_args(argv)
if args.token is None:
parser.error(
"a Discord bot token is required via --token, the TOKEN environment variable, or a systemd credential named 'token'"
"a Discord bot token is required via --token,"
" the TOKEN environment variable,"
" or a systemd credential named 'token'"
)
return args
def main() -> None:
"""
Main entry point. Parses arguments, configures logging, and starts the bot.
"""
"""Run the bot. Parse arguments, configure logging, and start the bot."""
args = _parse_args()
logging.basicConfig(
+14 -17
View File
@@ -12,8 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""
The simple nonversation Discord bot.
"""The simple nonversation Discord bot.
Provides the Crabstero subclass that owns the full bot lifecycle: database connection,
cog loading, ingestion worker pool, and graceful shutdown.
@@ -35,8 +34,7 @@ logger = logging.getLogger(__name__)
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
ingestion is handled by a bounded queue and a fixed worker pool.
@@ -49,8 +47,7 @@ class Crabstero(commands.Bot):
ingestion_workers: int = 4,
ingest_only: bool = False,
) -> None:
"""
Configures intents, stores configuration, and prepares ingestion queue state.
"""Configure intents, store configuration, and prepare ingestion queue state.
:param token: The Discord bot token.
:param database_path: The file path to the SQLite database.
@@ -78,10 +75,11 @@ class Crabstero(commands.Bot):
self._ingestion_workers: list[asyncio.Task[None]] = []
self.db: Database
self.http.user_agent = f"DiscordBot (https://git.logal.dev/LogalDeveloper/Crabstero, {crabstero_version})"
repo_url = "https://git.logal.dev/LogalDeveloper/Crabstero"
self.http.user_agent = f"DiscordBot ({repo_url}, {crabstero_version})"
async def setup_hook(self) -> None:
"""Opens the database, starts ingestion workers, loads all cogs, and syncs slash commands if changed."""
"""Open the database, start ingestion workers, and load all cogs."""
self.db = await Database.connect(self._database_path)
self._start_ingestion_workers()
@@ -92,7 +90,8 @@ class Crabstero(commands.Bot):
await server_events.setup(self)
if not self._ingest_only:
# Only sync slash commands if the registered commands differ from local definitions.
# Only sync slash commands if the registered commands
# differ from local definitions.
local_commands = {
cmd.name: cmd.description
for cmd in self.tree.get_commands()
@@ -111,12 +110,11 @@ class Crabstero(commands.Bot):
await self.tree.sync()
async def on_ready(self) -> None:
"""Logs that the bot has started successfully."""
"""Log that the bot has started successfully."""
logger.info("Crabstero started!")
async def start(self, token: str = "", *, reconnect: bool = True) -> None:
"""
Starts the bot using the stored token by default.
"""Start the bot using the stored token by default.
:param token: Optional token override. Falls back to the stored token if empty.
:param reconnect: Whether to automatically reconnect on disconnect.
@@ -124,7 +122,7 @@ class Crabstero(commands.Bot):
await super().start(token or self._token, reconnect=reconnect)
async def close(self) -> None:
"""Cancels ingestion workers, closes the database, and then the bot connection."""
"""Cancel ingestion workers, close the database, and then the bot connection."""
if self.is_closed():
return
logger.info("Shutting down Crabstero...")
@@ -139,21 +137,20 @@ class Crabstero(commands.Bot):
def queue_channel_for_ingestion(
self, channel: discord.TextChannel | discord.VoiceChannel
) -> None:
"""
Enqueues a single channel for background message history ingestion.
"""Enqueue a single channel for background message history ingestion.
:param channel: The channel to enqueue.
"""
self._ingestion_queue.put_nowait(channel)
def _start_ingestion_workers(self) -> None:
"""Spawns the fixed pool of ingestion worker tasks."""
"""Spawn the fixed pool of ingestion worker tasks."""
for _ in range(self._ingestion_worker_count):
task = asyncio.create_task(self._ingestion_worker())
self._ingestion_workers.append(task)
async def _ingestion_worker(self) -> None:
"""Loops forever pulling channels from the ingestion queue and ingesting them."""
"""Loop forever pulling channels from the ingestion queue and ingesting them."""
while True:
channel = await self._ingestion_queue.get()
try:
+72 -58
View File
@@ -12,8 +12,7 @@
# 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.
"""Async SQLite database access for Crabstero's persistent storage.
Uses aiosqlite for native async access. All methods are async def.
"""
@@ -52,7 +51,8 @@ CREATE TABLE IF NOT EXISTS markov_transitions (
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_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.
@@ -80,16 +80,15 @@ CREATE TABLE IF NOT EXISTS ingested_channels (
class Database:
"""
Manages all SQLite database operations for Crabstero.
"""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.
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.
"""Initialize the Database wrapper with an already-opened aiosqlite connection.
:param connection: An open aiosqlite connection.
"""
@@ -99,9 +98,10 @@ class Database:
@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.
"""Open a SQLite database, configure it, and create the schema.
Configure the database for performance and create 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.
@@ -121,7 +121,7 @@ class Database:
return cls(connection)
async def commit(self) -> None:
"""Commits pending writes and resets the flush timer."""
"""Commit pending writes and reset the flush timer."""
await self._connection.commit()
self._pending_writes = 0
if self._flush_task is not None:
@@ -129,7 +129,7 @@ class Database:
self._flush_task = None
async def close(self) -> None:
"""Cancels the flush timer, commits any pending writes, then closes the database connection."""
"""Cancel the flush timer, commit pending writes, and close the connection."""
if self._flush_task is not None:
self._flush_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
@@ -141,26 +141,30 @@ class Database:
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.
"""Insert 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 (?, ?, ?)",
"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.
"""Return a random starting word for a 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.
:return: A random starting word, or None if none exist.
"""
async with self._connection.execute(
"SELECT word FROM markov_start_words WHERE channel_id = ? ORDER BY RANDOM() LIMIT 1",
"SELECT word FROM markov_start_words"
" WHERE channel_id = ?"
" ORDER BY RANDOM() LIMIT 1",
(channel_id,),
) as cursor:
row = await cursor.fetchone()
@@ -169,28 +173,32 @@ class Database:
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.
"""Insert 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.
: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 (?, ?, ?, ?)",
"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.
"""Return a random next word for a given word in a 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",
"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()
@@ -199,26 +207,28 @@ class Database:
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.
"""Return a random sentence-ending next word for a given word in a channel.
A completing word is one whose last character is '.', '!', '?', or '§'.
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.
:return: A random completing next word, or None if none 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",
"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.
"""Store an image URL for a given channel.
:param channel_id: The Discord channel ID.
:param user_id: The Discord user ID of the contributor.
@@ -231,29 +241,31 @@ class Database:
await self._maybe_commit()
async def get_random_image(self, channel_id: int) -> str | None:
"""
Returns a random image URL for a given channel.
"""Return 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.
:return: A random image URL, or None if none exist.
"""
async with self._connection.execute(
"SELECT url FROM channel_images WHERE channel_id = ? ORDER BY RANDOM() LIMIT 1",
"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.
"""Set 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_type: The entity type ("channel", "server", "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 (?, ?, ?)",
"INSERT OR IGNORE INTO flags"
" (entity_type, entity_id, flag_name)"
" VALUES (?, ?, ?)",
(entity_type, entity_id, flag_name),
)
await self._maybe_commit()
@@ -261,15 +273,17 @@ class Database:
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.
"""Clear 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_type: The entity type ("channel", "server", "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 = ?",
"DELETE FROM flags"
" WHERE entity_type = ?"
" AND entity_id = ?"
" AND flag_name = ?",
(entity_type, entity_id, flag_name),
)
await self._maybe_commit()
@@ -277,23 +291,24 @@ class Database:
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.
"""Check whether a flag is set on a given entity.
:param entity_type: The type of entity ("channel", "server", or "user").
:param entity_type: The entity type ("channel", "server", "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 = ?",
"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.
"""Check 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.
@@ -305,8 +320,7 @@ class Database:
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.
"""Mark a channel as having been bulk-ingested.
:param channel_id: The Discord channel ID.
"""
@@ -317,7 +331,7 @@ class Database:
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."""
"""Track a pending write and commit or start a flush timer."""
self._pending_writes += 1
if self._pending_writes >= AUTO_COMMIT_WRITE_THRESHOLD:
await self.commit()
+5 -10
View File
@@ -12,8 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Provides async convenience wrappers around the database flag methods.
"""Provides async convenience wrappers around the database flag methods.
Accepts discord.py objects or raw integer IDs and translates them into the
entity_type/entity_id pairs used by the database layer.
@@ -45,8 +44,7 @@ class EntityType(enum.Enum):
def _entity_id(entity: discord.abc.Snowflake | int) -> str:
"""
Extracts a string entity ID from a Discord object or raw integer ID.
"""Extract a string entity ID from a Discord object or raw integer ID.
:param entity: A Discord entity or raw integer ID.
:return: The entity ID as a string.
@@ -60,8 +58,7 @@ async def set_flag(
entity_type: EntityType,
flag: Flag,
) -> None:
"""
Sets a flag on a given entity.
"""Set a flag on a given entity.
:param db: The database instance.
:param entity: The Discord entity or raw integer ID to set the flag on.
@@ -77,8 +74,7 @@ async def clear_flag(
entity_type: EntityType,
flag: Flag,
) -> None:
"""
Clears a flag on a given entity.
"""Clear a flag on a given entity.
:param db: The database instance.
:param entity: The Discord entity or raw integer ID to clear the flag on.
@@ -94,8 +90,7 @@ async def is_flag_set(
entity_type: EntityType,
flag: Flag,
) -> bool:
"""
Checks whether a flag is set on a given entity.
"""Check whether a flag is set on a given entity.
:param db: The database instance.
:param entity: The Discord entity or raw integer ID to check.
+1
View File
@@ -0,0 +1 @@
"""Listener cogs for Discord events."""
+19 -16
View File
@@ -12,8 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Handles responding to interactions.
"""Handles responding to interactions.
Provides the /pingme slash command as a Cog with an app command.
"""
@@ -35,13 +34,10 @@ logger = logging.getLogger(__name__)
class InteractionCog(commands.Cog):
"""
Cog for handling slash command interactions.
"""
"""Cog for handling slash command interactions."""
def __init__(self, bot: Crabstero) -> None:
"""
Creates a new interaction handler cog.
"""Create a new interaction handler cog.
:param bot: The bot instance.
"""
@@ -49,11 +45,13 @@ class InteractionCog(commands.Cog):
@app_commands.command(
name="pingme",
description="Opts into (or back out of) receiving pings for generated message which mention you.",
description=(
"Opts into (or back out of) receiving pings"
" for generated message which mention you."
),
)
async def pingme(self, interaction: discord.Interaction) -> None:
"""
Toggles the allowPings flag for the user who ran the command.
"""Toggle the allowPings flag for the user who ran the command.
:param interaction: The interaction event.
"""
@@ -66,7 +64,9 @@ class InteractionCog(commands.Cog):
self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS
)
await interaction.response.send_message(
"I will no longer ping you for messages which mention you. If you decide to opt back in, run `/pingme` any time.",
"I will no longer ping you for messages which"
" mention you. If you decide to opt back in,"
" run `/pingme` any time.",
ephemeral=True,
)
else:
@@ -74,23 +74,26 @@ class InteractionCog(commands.Cog):
self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS
)
await interaction.response.send_message(
"I will now ping you for messages which mention you. If you change your mind, run `/pingme` any time.",
"I will now ping you for messages which mention"
" you. If you change your mind, run"
" `/pingme` any time.",
ephemeral=True,
)
except Exception:
logger.exception(
"An exception occurred while attempting to execute slash command responder for command 'pingme'."
"An exception occurred while attempting to execute"
" slash command responder for command 'pingme'."
)
await interaction.response.send_message(
"An error occurred while executing your command. Please try again later.",
"An error occurred while executing your command."
" Please try again later.",
ephemeral=True,
)
async def setup(bot: Crabstero) -> None:
"""
Adds the InteractionCog to the bot.
"""Add the InteractionCog to the bot.
:param bot: The bot instance.
"""
+7 -12
View File
@@ -12,10 +12,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Handles created messages.
"""Handles created messages.
Responds to mentions and ingests normal text messages as a Cog with an on_message listener.
Responds to mentions and ingests normal text messages as a Cog with an
on_message listener.
"""
from typing import TYPE_CHECKING
@@ -30,13 +30,10 @@ if TYPE_CHECKING:
class MessageCog(commands.Cog):
"""
Cog for handling message creation events.
"""
"""Cog for handling message creation events."""
def __init__(self, bot: Crabstero) -> None:
"""
Creates a new message creation handler cog.
"""Create a new message creation handler cog.
:param bot: The bot instance.
"""
@@ -44,8 +41,7 @@ class MessageCog(commands.Cog):
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
"""
Responds to mentions and ingests normal text messages.
"""Respond to mentions and ingest normal text messages.
:param message: The message event.
"""
@@ -79,8 +75,7 @@ class MessageCog(commands.Cog):
async def setup(bot: Crabstero) -> None:
"""
Adds the MessageCog to the bot.
"""Add the MessageCog to the bot.
:param bot: The bot instance.
"""
+15 -22
View File
@@ -12,11 +12,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Handles server-level events which trigger channel history ingestion.
"""Handles server-level events which trigger channel history ingestion.
All of these events share the same outcome: queuing all textable channels for message history
ingestion when something changes that may grant new read permissions.
All of these events share the same outcome: queuing all textable channels
for message history ingestion when something changes that may grant new
read permissions.
"""
import contextlib
@@ -35,13 +35,10 @@ logger = logging.getLogger(__name__)
class ServerEventsCog(commands.Cog):
"""
Cog for handling server-level events that trigger channel history ingestion.
"""
"""Cog for handling server-level events that trigger channel history ingestion."""
def __init__(self, bot: Crabstero) -> None:
"""
Creates a new server events handler cog.
"""Create a new server events handler cog.
:param bot: The bot instance.
"""
@@ -49,9 +46,9 @@ class ServerEventsCog(commands.Cog):
@commands.Cog.listener()
async def on_guild_join(self, guild: discord.Guild) -> None:
"""
Queues all text channels for message history ingestion when joining a new server.
Also logs the join and sends an embed to the bot owner.
"""Queue all text channels for ingestion when joining a new server.
Also log the join and send an embed to the bot owner.
:param guild: The guild that was joined.
"""
@@ -79,8 +76,7 @@ class ServerEventsCog(commands.Cog):
@commands.Cog.listener()
async def on_guild_available(self, guild: discord.Guild) -> None:
"""
Queues all textable channels for message history ingestion when a server becomes available.
"""Queue all textable channels for ingestion when a server becomes available.
:param guild: The guild that became available.
"""
@@ -90,9 +86,9 @@ class ServerEventsCog(commands.Cog):
async def on_guild_role_update(
self, before: discord.Role, after: discord.Role
) -> None:
"""
Queues all text channels for message history ingestion when role permissions change,
but only if the bot is a member of the updated role.
"""Queue all text channels for ingestion when role permissions change.
Only triggers if the bot is a member of the updated role.
:param before: The role before the update.
:param after: The role after the update.
@@ -107,9 +103,7 @@ class ServerEventsCog(commands.Cog):
async def on_guild_channel_update(
self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel
) -> None:
"""
Queues all text channels for message history ingestion when channel override permissions
change.
"""Queue all text channels for ingestion on permission change.
:param before: The channel before the update.
:param after: The channel after the update.
@@ -121,8 +115,7 @@ class ServerEventsCog(commands.Cog):
async def setup(bot: Crabstero) -> None:
"""
Adds the ServerEventsCog to the bot.
"""Add the ServerEventsCog to the bot.
:param bot: The bot instance.
"""
+18 -18
View File
@@ -12,11 +12,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Ingests sentences and generates new ones using a Markov chain backed by SQLite storage.
"""Markov chain sentence ingestion and generation backed by SQLite.
The Markov chain stores word transitions per channel, using duplicate rows to represent frequency
weight. Random selection via ORDER BY RANDOM() LIMIT 1 naturally preserves this weighting.
The Markov chain stores word transitions per channel, using duplicate rows
to represent frequency weight. Random selection via ORDER BY RANDOM()
LIMIT 1 naturally preserves this weighting.
"""
import re
@@ -31,9 +31,10 @@ DEFAULT_SENTENCE_END = "\u00a7"
def is_complete_sentence(sentence: str) -> bool:
"""
Checks whether a given sentence ends with a default sentence end character, a period, an
exclamation mark, or a question mark.
"""Check whether a sentence ends with a valid terminator.
Valid terminators are the section sign, period, exclamation mark, or
question mark.
:param sentence: The sentence to test.
:return: True if the sentence ends with a valid terminator, False otherwise.
@@ -45,9 +46,10 @@ def is_complete_sentence(sentence: str) -> bool:
async def ingest(db: Database, channel_id: int, user_id: int, paragraph: str) -> None:
"""
Ingests a string potentially containing multiple smaller sentences into the Markov chain
for a given channel.
"""Ingest a paragraph into the Markov chain for a given channel.
The paragraph may contain multiple sentences which are split and ingested
individually.
:param db: The database instance.
:param channel_id: The Discord channel ID to associate with this data.
@@ -57,7 +59,8 @@ async def ingest(db: Database, channel_id: int, user_id: int, paragraph: str) ->
if not is_complete_sentence(paragraph):
paragraph += DEFAULT_SENTENCE_END
# Normalize whitespace, then split on sentence-ending punctuation followed by a space.
# 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)
@@ -68,8 +71,7 @@ async def ingest(db: Database, channel_id: int, user_id: int, paragraph: str) ->
async def _ingest_sentence(
db: Database, channel_id: int, user_id: int, sentence: str
) -> None:
"""
Ingests a string containing a single sentence into the Markov chain for a given channel.
"""Ingest a single sentence into the Markov chain for a given channel.
:param db: The database instance.
:param channel_id: The Discord channel ID to associate with this data.
@@ -99,14 +101,12 @@ async def _ingest_sentence(
async def generate(
db: Database, channel_id: int, soft_limit: int = 750, hard_limit: int = 1000
) -> str:
"""
Generates a new sentence using words learned from previously ingested sentences for a given
channel.
"""Generate a new sentence from previously ingested words for a channel.
:param db: The database instance.
:param channel_id: The Discord channel ID to generate from.
:param soft_limit: The amount of characters to try and limit sentence length around.
:param hard_limit: The amount of characters to cut off the sentence at if it gets too long.
:param soft_limit: Character count to aim for when wrapping up.
:param hard_limit: Character count to hard-cut the sentence at.
:return: A new generated sentence.
"""
word = await db.get_random_start_word(channel_id)
+7 -10
View File
@@ -12,11 +12,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Assists with generating Discord messages in response to other users and ingesting raw messages.
"""Generate Discord reply messages and ingest raw messages.
Orchestrates Markov chain generation and ingestion in the context of Discord messages, handling
reply logic, embed generation, mention filtering, and message ingestion with flag checks.
Orchestrates Markov chain generation and ingestion in the context of
Discord messages, handling reply logic, embed generation, mention
filtering, and message ingestion with flag checks.
"""
import re
@@ -39,8 +39,7 @@ _EMBED_CHANCE_THRESHOLD = 95 # Out of 100; sends an embed ~5% of the time.
async def reply_to_message(db: Database, message: discord.Message) -> None:
"""
Sends a new message in Discord in response to a given message.
"""Send a new message in Discord in response to a given message.
:param db: The database instance.
:param message: The message prompting the response.
@@ -115,8 +114,7 @@ async def reply_to_message(db: Database, message: discord.Message) -> None:
async def ingest_message(db: Database, message: discord.Message) -> None:
"""
Ingests a given message into its channel's Markov chain.
"""Ingest a given message into its channel's Markov chain.
:param db: The database instance.
:param message: The message to ingest.
@@ -148,8 +146,7 @@ async def ingest_message(db: Database, message: discord.Message) -> None:
async def _ingest_embed(
db: Database, channel_id: int, user_id: int, embed: discord.Embed
) -> None:
"""
Ingests a given embed into a given channel's Markov chain.
"""Ingest a given embed into a given channel's Markov chain.
:param db: The database instance.
:param channel_id: The ID of the channel to use for the Markov chain.
+1
View File
@@ -0,0 +1 @@
"""Background tasks for Crabstero."""
+9 -9
View File
@@ -12,8 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""
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.
"""
@@ -37,8 +36,7 @@ logger = logging.getLogger(__name__)
def queue_channels_for_ingestion(guild: discord.Guild, bot: Crabstero) -> None:
"""
Enqueues every textable channel in a guild for message history ingestion.
"""Enqueue every textable channel in a guild for message history ingestion.
:param guild: The Discord guild whose channels should be ingested.
:param bot: The bot instance.
@@ -52,9 +50,10 @@ async def ingest_channel(
channel: discord.TextChannel | discord.VoiceChannel,
db: Database,
) -> None:
"""
Bulk-ingests the message history of a given channel. The task will be ended early if
permissions do not allow ingesting this channel or if it has already been ingested in the past.
"""Bulk-ingest the message history of a given channel.
End early if permissions do not allow ingesting this channel or if it has
already been ingested.
:param channel: The channel to ingest.
:param db: The database instance.
@@ -62,7 +61,8 @@ async def ingest_channel(
try:
if not channel.permissions_for(channel.guild.me).read_message_history:
logger.warning(
"[%s] Unable to ingest textable channel history due to lacking permissions. Ignoring.",
"[%s] Unable to ingest channel history"
" due to lacking permissions. Ignoring.",
channel.id,
)
return
@@ -80,7 +80,7 @@ async def ingest_channel(
await ingest_message(db, message)
logger.info(
"[%s] Ingestion of textable channel history complete. %d messages ingested.",
"[%s] Ingestion of channel history complete. %d messages ingested.",
channel.id,
count,
)