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
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:
+29
-15
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Listener cogs for Discord events."""
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Background tasks for Crabstero."""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user