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

This commit is contained in:
2026-03-29 20:08:30 -04:00
parent 603a927f12
commit abe1444e4c
16 changed files with 188 additions and 125 deletions
+2 -2
View File
@@ -106,7 +106,7 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
host, sep, port_str = args.listen_metrics.rpartition(":")
if not sep or not host:
parser.error(
"--listen-metrics must be in HOST:PORT format (e.g. 127.0.0.1:9090)"
"--listen-metrics must be in HOST:PORT format (e.g. 127.0.0.1:9090)",
)
try:
args.listen_metrics = (host, int(port_str))
@@ -117,7 +117,7 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser.error(
"a Discord bot token is required via --token,"
" the TOKEN environment variable,"
" or a systemd credential named 'token'"
" or a systemd credential named 'token'",
)
return args
+18 -6
View File
@@ -58,6 +58,7 @@ class Crabstero(commands.Bot):
self,
token: str,
database_path: str,
*,
ingest_only: bool = False,
metrics_address: tuple[str, int] | None = None,
) -> None:
@@ -104,7 +105,8 @@ class Crabstero(commands.Bot):
:raises RuntimeError: If accessed before :meth:`setup_hook` has run.
"""
if self._db is None:
raise RuntimeError("Database is not initialized")
msg = "Database is not initialized"
raise RuntimeError(msg)
return self._db
@property
@@ -123,7 +125,11 @@ class Crabstero(commands.Bot):
await server.start()
self._metrics_server = server
from crabstero.listeners import interaction, message, server_events
from crabstero.listeners import ( # noqa: PLC0415
interaction,
message,
server_events,
)
if not self._ingest_only:
await interaction.setup(self)
@@ -133,7 +139,8 @@ class Crabstero(commands.Bot):
@self.tree.error
async def on_app_command_error(
interaction: discord.Interaction, error: AppCommandError
interaction: discord.Interaction,
error: AppCommandError,
) -> None:
original = (
error.original if isinstance(error, CommandInvokeError) else error
@@ -203,14 +210,18 @@ class Crabstero(commands.Bot):
@override
async def on_error(
self, event_method: str, /, *args: object, **kwargs: object
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)
logger.error("Unhandled exception in %s.", event_method, exc_info=True) # noqa: LOG014
async def on_ready(self) -> None:
"""Log that the bot has started successfully."""
@@ -250,7 +261,8 @@ class Crabstero(commands.Bot):
if channel.id in self._ingestion_tasks:
return
task = asyncio.create_task(
self._ingest_one(channel), name=f"ingest-{channel.id}"
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))
+2 -1
View File
@@ -103,7 +103,8 @@ class IngestCache:
if self._task is not None:
return
self._task = asyncio.create_task(
self._cleanup_loop(), name="ingest-cache-cleanup"
self._cleanup_loop(),
name="ingest-cache-cleanup",
)
async def stop(self) -> None:
+11 -3
View File
@@ -261,7 +261,9 @@ class Database:
return row[0] if row else None
async def get_random_completing_next_word(
self, channel_id: int, word: str
self,
channel_id: int,
word: str,
) -> str | None:
"""Return a random sentence-ending next word for a given word in a channel.
@@ -352,7 +354,10 @@ class Database:
)
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:
"""Clear a flag on a given entity. If the flag is not set, this is a no-op.
@@ -370,7 +375,10 @@ class Database:
)
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:
"""Check whether a flag is set on a given entity.
+28 -9
View File
@@ -39,7 +39,10 @@ class ForgetMeView(TrackedView):
"""Confirmation view with Confirm/Cancel buttons for /forgetme."""
def __init__(
self, user_id: int, db: Database, interaction: discord.Interaction
self,
user_id: int,
db: Database,
interaction: discord.Interaction,
) -> None:
"""Create a new ForgetMeView.
@@ -64,12 +67,14 @@ class ForgetMeView(TrackedView):
@discord.ui.button(label="Confirm", style=discord.ButtonStyle.danger)
async def confirm(
self, interaction: discord.Interaction, button: discord.ui.Button[ForgetMeView]
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.
:param _button: The button that was pressed.
"""
self._responded = True
await self._db.forget_user(self._user_id, Flag.NO_INGEST)
@@ -85,12 +90,14 @@ class ForgetMeView(TrackedView):
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary)
async def cancel(
self, interaction: discord.Interaction, button: discord.ui.Button[ForgetMeView]
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.
:param _button: The button that was pressed.
"""
self._responded = True
metrics.FORGETME.labels(outcome="cancelled").inc()
@@ -145,10 +152,16 @@ class InteractionCog(commands.Cog):
:param interaction: The interaction event.
"""
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(
self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS
self.bot.db,
interaction.user,
EntityType.USER,
Flag.ALLOW_PINGS,
)
metrics.PINGME.labels(outcome="opted_out").inc()
await interaction.response.send_message(
@@ -159,7 +172,10 @@ class InteractionCog(commands.Cog):
)
else:
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.labels(outcome="opted_in").inc()
await interaction.response.send_message(
@@ -179,7 +195,10 @@ class InteractionCog(commands.Cog):
:param interaction: The interaction event.
"""
if await flags.is_flag_set(
self.bot.db, interaction.user, EntityType.USER, Flag.NO_INGEST
self.bot.db,
interaction.user,
EntityType.USER,
Flag.NO_INGEST,
):
metrics.FORGETME.labels(outcome="already_forgotten").inc()
await interaction.response.send_message(
+10 -5
View File
@@ -49,7 +49,8 @@ class MessageCog(commands.Cog):
channel = message.channel
if not isinstance(
channel, (discord.TextChannel, discord.Thread, discord.VoiceChannel)
channel,
(discord.TextChannel, discord.Thread, discord.VoiceChannel),
):
return
@@ -61,7 +62,8 @@ class MessageCog(commands.Cog):
if self.bot.ingest_only:
# In ingest-only mode, never reply — only ingest eligible messages.
if message.type == discord.MessageType.default and not isinstance(
channel, discord.Thread
channel,
discord.Thread,
):
await ingest_message(self.bot.db, message, self.bot.ingest_cache)
return
@@ -72,13 +74,15 @@ class MessageCog(commands.Cog):
# Only ingest non-thread messages; threads share their parent channel's chain.
if message.type == discord.MessageType.default and not isinstance(
channel, discord.Thread
channel,
discord.Thread,
):
await ingest_message(self.bot.db, message, self.bot.ingest_cache)
@commands.Cog.listener()
async def on_raw_message_delete(
self, payload: discord.RawMessageDeleteEvent
self,
payload: discord.RawMessageDeleteEvent,
) -> None:
"""Uningest a recently ingested message when it is deleted.
@@ -88,7 +92,8 @@ class MessageCog(commands.Cog):
@commands.Cog.listener()
async def on_raw_bulk_message_delete(
self, payload: discord.RawBulkMessageDeleteEvent
self,
payload: discord.RawBulkMessageDeleteEvent,
) -> None:
"""Uningest recently ingested messages when they are bulk-deleted.
+8 -3
View File
@@ -55,7 +55,8 @@ class ServerEventsCog(commands.Cog):
logger.info('Joined new server! (Name: "%s" | ID: %s)', guild.name, guild.id)
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(
name=f"{guild.name} ({guild.id})",
@@ -84,7 +85,9 @@ class ServerEventsCog(commands.Cog):
@commands.Cog.listener()
async def on_guild_role_update(
self, before: discord.Role, after: discord.Role
self,
before: discord.Role,
after: discord.Role,
) -> None:
"""Queue all text channels for ingestion when role permissions change.
@@ -101,7 +104,9 @@ class ServerEventsCog(commands.Cog):
@commands.Cog.listener()
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:
"""Queue all text channels for ingestion on permission change.
+16 -4
View File
@@ -73,6 +73,9 @@ def _split_sentences(paragraph: str) -> list[str]:
return _SENTENCE_SPLIT.split(normalized)
_MIN_WORDS_FOR_START = 2
def _tokenize_sentence(
sentence: str,
) -> tuple[list[str], list[tuple[str, str]]]:
@@ -85,7 +88,7 @@ def _tokenize_sentence(
sentence += DEFAULT_SENTENCE_END
words = sentence.split()
start_words: list[str] = [words[0]] if len(words) >= 2 else []
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
@@ -106,7 +109,10 @@ 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
db: Database,
channel_id: int,
user_id: int,
sentence: str,
) -> None:
"""Ingest a single sentence into the Markov chain for a given channel.
@@ -137,7 +143,10 @@ async def uningest(db: Database, channel_id: int, user_id: int, paragraph: str)
async def _uningest_sentence(
db: Database, channel_id: int, user_id: int, sentence: str
db: Database,
channel_id: int,
user_id: int,
sentence: str,
) -> None:
"""Remove a single sentence's Markov data from the chain.
@@ -153,7 +162,10 @@ async def _uningest_sentence(
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:
"""Generate a new sentence from previously ingested words for a channel.
+32 -15
View File
@@ -79,7 +79,10 @@ async def reply_to_message(db: Database, message: discord.Message) -> None:
embed = discord.Embed(
title=await markov.generate(db, channel_id, soft_limit=200, hard_limit=300),
description=await markov.generate(
db, channel_id, soft_limit=300, hard_limit=500
db,
channel_id,
soft_limit=300,
hard_limit=500,
),
)
@@ -117,8 +120,30 @@ async def reply_to_message(db: Database, message: discord.Message) -> None:
metrics.REPLIES_SENT.inc()
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
db: Database,
message: discord.Message,
cache: IngestCache | None = None,
) -> None:
"""Ingest a given message into its channel's Markov chain.
@@ -150,26 +175,18 @@ async def ingest_message(
if not message.content and not message.embeds:
return
embed_texts: list[str] = []
image_urls: list[str] = []
embed_texts, image_urls = _extract_embed_data(message.embeds)
with metrics.MESSAGE_INGESTION_DURATION.time():
if message.content:
await markov.ingest(db, channel_id, user_id, message.content)
for embed in message.embeds:
if embed.title:
embed_texts.append(embed.title)
await markov.ingest(db, channel_id, user_id, embed.title)
if embed.description:
embed_texts.append(embed.description)
await markov.ingest(db, channel_id, user_id, embed.description)
if embed.image and embed.image.url:
image_urls.append(embed.image.url)
for text in embed_texts:
await markov.ingest(db, channel_id, user_id, text)
if image_urls:
await db.add_images(
[ChannelImage(channel_id, user_id, url) for url in image_urls]
[ChannelImage(channel_id, user_id, url) for url in image_urls],
)
if cache is not None:
@@ -210,7 +227,7 @@ async def uningest_message(db: Database, cache: IngestCache, message_id: int) ->
[
ChannelImage(entry.channel_id, entry.user_id, url)
for url in entry.image_urls
]
],
)
metrics.MESSAGES_UNINGESTED.inc()
+2 -1
View File
@@ -148,7 +148,8 @@ class MetricsServer:
async def start(self) -> None:
"""Create the aiohttp application and start listening."""
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.router.add_get("/metrics", make_aiohttp_handler())
self._runner = web.AppRunner(app)