From 8c24f3a9f369db8154e68c69b7458b12064be990 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:52:35 +0000 Subject: [PATCH 1/3] ci: update pre-commit hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.15.21 → v0.16.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.21...v0.16.1) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 13e4858..bc1f3c2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.21 + rev: v0.16.1 hooks: - id: ruff - id: ruff-format From 5ff9d363e5271aeb54858c226e80b1cc18d6580e Mon Sep 17 00:00:00 2001 From: seria Date: Mon, 10 Aug 2026 19:09:55 +0800 Subject: [PATCH 2/3] chore: delete AGENTS.md --- AGENTS.md | 81 ------------------------------------------------------- 1 file changed, 81 deletions(-) delete mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 09da14a..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,81 +0,0 @@ -# AGENTS.md - -This file provides guidance to agents when working with code in this repository. - -## Stack - -Python 3.12 Discord bot using `discord.py` (AutoShardedBot), Tortoise ORM, Pydantic v2, `uv` package manager, `loguru` for logging, Sentry for error tracking. - -## Commands - -```bash -uv run run.py # Start the bot -uv run ruff check --fix . # Lint with auto-fix -uv run ruff format . # Format -uv run pyright # Type check -``` - -No automated test suite — test manually in a Discord server. - -## Environment Variables (`.env`) - -- `DISCORD_TOKEN` — required -- `DB_URI` — defaults to `sqlite://embed_fixer.db` (PostgreSQL supported via `asyncpg` optional dep) -- `ENV` — `dev` or `prod` (health cog skipped in `dev`) -- `REDIS_URL`, `SENTRY_DSN`, `PROXY_URL`, `HEARTBEAT_URL` — optional - -## Critical Patterns - -### Settings Model (non-obvious architecture) - -`GuildSettings` and `UserSettings` are **Pydantic models**, NOT Tortoise ORM models. They are stored as JSON blobs in `SettingsTable` (Tortoise). Always use the Pydantic class methods: - -```python -settings, created = await GuildSettings.get_or_create(guild_id) -settings = await GuildSettings.get_or_none(guild_id) -await settings.save() -``` - -Never query `GuildSettingsTable` directly for settings — use the Pydantic wrapper. - -### URL Helpers (use these, not str methods) - -```python -from embed_fixer.utils.misc import domain_in_url, replace_domain, extract_urls, remove_query_params -domain_in_url(url, "twitter.com") # handles subdomains correctly -replace_domain(url, "twitter.com", "fxtwitter.com") -``` - -### Adding a New Domain/Fix - -1. Add entry to `DomainId` enum in [`embed_fixer/fixes.py`](embed_fixer/fixes.py) -2. Add `Domain` with `Website` patterns and `FixMethod` list to `DOMAINS` -3. `FixMethod.id` values must be globally unique integers across all domains -4. `Website.skip_method_ids` can exclude specific fix methods for certain URL patterns - -### Error Handling - -Use `capture_exception(e)` from [`embed_fixer/utils/misc.py`](embed_fixer/utils/misc.py) (not `sentry_sdk.capture_exception` directly) — it falls back to `logger.exception` when Sentry is not configured. - -### Translations - -All user-facing strings must use `translator.translate(key, lang=guild_lang)`. Keys are defined in [`l10n/en_US.yaml`](l10n/en_US.yaml). Get guild lang via `await translator.get_guild_lang(guild)`. - -### Webhook Username Sanitization - -Webhook usernames must use `sanitize_username()` to replace "discord" with "discorɗ" (Discord rejects usernames containing "discord"). The suffix `" (Embed Fixer)"` is appended and used to identify bot-sent webhook messages. - -### Database Migrations - -- SQLite: manual migration only -- PostgreSQL: `aerich upgrade` (migrations in `/migrations/embed_fixer/`) -- `GuildSettingsOld` in [`embed_fixer/models.py`](embed_fixer/models.py) is the legacy schema — do NOT add new fields there; add to `GuildSettings` (Pydantic model) only - -## Code Style - -- `from __future__ import annotations` at top of every file -- `TYPE_CHECKING` guard for import-only types -- `type Alias = ...` syntax (Python 3.12 style) for type aliases -- Google docstring convention (enforced by ruff) -- `ruff.toml`: line-length 100, `future-annotations=true`, `skip-magic-trailing-comma=true` -- Pydantic base classes are runtime-evaluated (configured in `ruff.toml` `[lint.flake8-type-checking]`) From 2b9f25189bbb9ead60dcecb26bf402e4e6969d5b Mon Sep 17 00:00:00 2001 From: seria Date: Mon, 10 Aug 2026 19:10:06 +0800 Subject: [PATCH 3/3] fix: apply ruff auto fix --- embed_fixer/bot.py | 2 +- embed_fixer/cogs/fixer.py | 12 ++++++------ embed_fixer/health.py | 2 +- embed_fixer/models.py | 18 +++++++++--------- embed_fixer/ui/common.py | 2 +- embed_fixer/ui/guild_settings.py | 2 +- embed_fixer/utils/download_media.py | 2 +- ruff.toml | 22 +++++++++++----------- 8 files changed, 31 insertions(+), 31 deletions(-) diff --git a/embed_fixer/bot.py b/embed_fixer/bot.py index a8dcd36..b189bdd 100644 --- a/embed_fixer/bot.py +++ b/embed_fixer/bot.py @@ -133,7 +133,7 @@ async def _is_pre_migration_db() -> bool: for table in ("guild_settings_v2", "guild_settings"): with contextlib.suppress(OperationalError): - await conn.execute_query(f"SELECT 1 FROM {table} LIMIT 1") # noqa: S608 + await conn.execute_query(f"SELECT 1 FROM {table} LIMIT 1") # ruff: ignore[hardcoded-sql-expression] return True return False diff --git a/embed_fixer/cogs/fixer.py b/embed_fixer/cogs/fixer.py index 827ada1..3765c20 100644 --- a/embed_fixer/cogs/fixer.py +++ b/embed_fixer/cogs/fixer.py @@ -381,7 +381,7 @@ def _apply_fxembed_translation(url: str, *, translang: str) -> str: # See https://github.com/FxEmbed/FxEmbed#translate-posts-xtwitter for more info return append_path_to_url(url, f"/{translang}") - async def _find_fixes( # noqa: C901, PLR0912, PLR0914, PLR0915 + async def _find_fixes( # ruff: ignore[complex-structure, too-many-branches, too-many-locals, too-many-statements] self, message: discord.Message | MockMessage, *, @@ -707,7 +707,7 @@ def _batch_medias(medias: list[Media], filesize_limit: int) -> list[list[Media]] return batches - async def _send_files( # noqa: PLR0913 + async def _send_files( # ruff: ignore[too-many-arguments] self, message: discord.Message, medias: list[Media], @@ -961,7 +961,7 @@ async def _send_message( ) return send_type - async def _add_delete_reaction( # noqa: PLR0913 + async def _add_delete_reaction( # ruff: ignore[too-many-arguments] self, message: discord.Message, interaction: Interaction | None, @@ -1108,7 +1108,7 @@ async def _handle_reply(self, message: discord.Message, resolved_ref: discord.Me capture_exception(e) @commands.Cog.listener("on_raw_reaction_add") - async def notify_user_on_react(self, payload: discord.RawReactionActionEvent) -> None: # noqa: PLR0911 + async def notify_user_on_react(self, payload: discord.RawReactionActionEvent) -> None: # ruff: ignore[too-many-return-statements] if payload.guild_id is None or payload.user_id == self.bot.user.id: return @@ -1174,7 +1174,7 @@ async def notify_user_on_react(self, payload: discord.RawReactionActionEvent) -> logger.error(f"Failed to send notification DM to user {payload.user_id}: {e}") @commands.Cog.listener("on_raw_reaction_add") - async def manage_reaction_removal(self, payload: discord.RawReactionActionEvent) -> None: # noqa: PLR0911 + async def manage_reaction_removal(self, payload: discord.RawReactionActionEvent) -> None: # ruff: ignore[too-many-return-statements] if payload.guild_id is None or payload.user_id == self.bot.user.id: return @@ -1222,7 +1222,7 @@ async def manage_reaction_removal(self, payload: discord.RawReactionActionEvent) ) @commands.Cog.listener("on_raw_reaction_add") - async def manage_rotate_fix_reaction(self, payload: discord.RawReactionActionEvent) -> None: # noqa: C901, PLR0911, PLR0912, PLR0914, PLR0915 + async def manage_rotate_fix_reaction(self, payload: discord.RawReactionActionEvent) -> None: # ruff: ignore[complex-structure, too-many-return-statements, too-many-branches, too-many-locals, too-many-statements] if payload.guild_id is None or payload.user_id == self.bot.user.id: return diff --git a/embed_fixer/health.py b/embed_fixer/health.py index ad6911e..cd47e50 100644 --- a/embed_fixer/health.py +++ b/embed_fixer/health.py @@ -20,7 +20,7 @@ async def __aenter__(self) -> Self: await self.start() return self - async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001 + async def __aexit__(self, exc_type, exc, tb) -> None: # ruff: ignore[missing-type-function-argument] await self.stop() async def health(self, _request: web.Request) -> web.Response: diff --git a/embed_fixer/models.py b/embed_fixer/models.py index 8c16f10..0c97407 100644 --- a/embed_fixer/models.py +++ b/embed_fixer/models.py @@ -29,7 +29,7 @@ class BaseSettings(pydantic.BaseModel): _table_class: ClassVar[type[SettingsTable]] @classmethod - async def get_or_create(cls, id: int) -> tuple[Self, bool]: # noqa: A002 + async def get_or_create(cls, id: int) -> tuple[Self, bool]: # ruff: ignore[builtin-argument-shadowing] obj, created = await cls._table_class.get_or_create(id=id) if created or not obj.data: settings = cls(id=id) @@ -38,23 +38,23 @@ async def get_or_create(cls, id: int) -> tuple[Self, bool]: # noqa: A002 return cls(id=id, **obj.data), created @classmethod - async def get_or_none(cls, id: int) -> Self | None: # noqa: A002 + async def get_or_none(cls, id: int) -> Self | None: # ruff: ignore[builtin-argument-shadowing] obj = await cls._table_class.get_or_none(id=id) if obj is None or not obj.data: return None return cls(id=id, **obj.data) @classmethod - async def create(cls, id: int) -> Self: # noqa: A002 + async def create(cls, id: int) -> Self: # ruff: ignore[builtin-argument-shadowing] settings = cls(id=id) await settings.save() return settings @classmethod - async def delete(cls, id: int) -> None: # noqa: A002 + async def delete(cls, id: int) -> None: # ruff: ignore[builtin-argument-shadowing] await cls._table_class.filter(id=id).delete() - async def save(self, *, update_fields: Iterable[str] | None = None) -> None: # noqa: ARG002 + async def save(self, *, update_fields: Iterable[str] | None = None) -> None: # ruff: ignore[unused-method-argument] obj, _ = await self.__class__._table_class.get_or_create(id=self.id) obj.data = self.model_dump(exclude={"id"}) await obj.save() @@ -67,20 +67,20 @@ class Meta: table = "ignore_me" @classmethod - async def add(cls, id: int) -> None: # noqa: A002 + async def add(cls, id: int) -> None: # ruff: ignore[builtin-argument-shadowing] with contextlib.suppress(IntegrityError): await cls.create(id=id) @classmethod - async def remove(cls, id: int) -> None: # noqa: A002 + async def remove(cls, id: int) -> None: # ruff: ignore[builtin-argument-shadowing] await cls.filter(id=id).delete() @classmethod - async def contains(cls, id: int) -> bool: # noqa: A002 + async def contains(cls, id: int) -> bool: # ruff: ignore[builtin-argument-shadowing] return await cls.filter(id=id).exists() @classmethod - async def toggle(cls, id: int) -> bool: # noqa: A002 + async def toggle(cls, id: int) -> bool: # ruff: ignore[builtin-argument-shadowing] if await cls.contains(id): await cls.remove(id) return False diff --git a/embed_fixer/ui/common.py b/embed_fixer/ui/common.py index e1bb76d..1395708 100644 --- a/embed_fixer/ui/common.py +++ b/embed_fixer/ui/common.py @@ -149,7 +149,7 @@ async def callback(self, i: Interaction) -> None: class SettingsSection(discord.ui.Section): - def __init__( # noqa: PLR0913 + def __init__( # ruff: ignore[too-many-arguments] self, *, title: str, diff --git a/embed_fixer/ui/guild_settings.py b/embed_fixer/ui/guild_settings.py index b7b14d7..12c2c69 100644 --- a/embed_fixer/ui/guild_settings.py +++ b/embed_fixer/ui/guild_settings.py @@ -364,7 +364,7 @@ def _add_role_selector_for_setting( self._add_selector_action_row(container, selector, action_row_id=ROLE_SELECTOR_ROW_ID) return cast("list[int]", getattr(guild_settings, attr_name)) - async def start(self, i: Interaction, *, setting: GuildSetting) -> None: # noqa: PLR0912 + async def start(self, i: Interaction, *, setting: GuildSetting) -> None: # ruff: ignore[too-many-branches] await i.response.defer(ephemeral=True) guild_settings, _ = await GuildSettings.get_or_create(id=self.guild.id) diff --git a/embed_fixer/utils/download_media.py b/embed_fixer/utils/download_media.py index fc013cc..1ed15ed 100644 --- a/embed_fixer/utils/download_media.py +++ b/embed_fixer/utils/download_media.py @@ -19,7 +19,7 @@ class MediaDownloader: - def __init__( # noqa: PLR0913 + def __init__( # ruff: ignore[too-many-arguments] self, session: aiohttp.ClientSession, *, diff --git a/ruff.toml b/ruff.toml index fa40c1f..732be81 100644 --- a/ruff.toml +++ b/ruff.toml @@ -42,21 +42,21 @@ select = [ "RUF", ] ignore = [ - "S101", # Assert used - "PLR2004", # Magic value used - "RUF003", # Comment contains ambiguous character - "PLR6301", # Method could be a function, class method, or static method - "ANN401", # typing.Any used - "DTZ007", # Naive datetime constructed - "E501", # Line too long - "S311", # Standard pseudo-random generators are not suitable for security/cryptographic purposes + "assert", # Assert used + "magic-value-comparison", # Magic value used + "ambiguous-unicode-character-comment", # Comment contains ambiguous character + "no-self-use", # Method could be a function, class method, or static method + "any-type", # typing.Any used + "call-datetime-strptime-without-zone", # Naive datetime constructed + "line-too-long", # Line too long + "suspicious-non-cryptographic-random-usage", # Standard pseudo-random generators are not suitable for security/cryptographic purposes - "RUF006", - "PLW0717", + "asyncio-dangling-task", + "too-many-statements-in-try-clause", ] [lint.per-file-ignores] -"**/__init__.py" = ["F403", "F401"] # Wildcard imports used +"**/__init__.py" = ["undefined-local-with-import-star", "unused-import"] # Wildcard imports used "test.py" = ["ALL"] "migrations/*.py" = ["ALL"]