From 2a43735a4e9f52f6080099c76664be8c935d4420 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:30:45 -0600 Subject: [PATCH 001/109] docs: Organize Claude reports & add bug report I had Claude Fable generate a bug report. I will now work to resolve each bug. --- .../claude-reports/bug_hunt_2026_07.md | 610 ++++++++++++++++++ .../scale_performance_assessment_2026-07.md | 0 2 files changed, 610 insertions(+) create mode 100644 documentation/claude-reports/bug_hunt_2026_07.md rename documentation/{ => claude-reports}/scale_performance_assessment_2026-07.md (100%) diff --git a/documentation/claude-reports/bug_hunt_2026_07.md b/documentation/claude-reports/bug_hunt_2026_07.md new file mode 100644 index 0000000..9e4158e --- /dev/null +++ b/documentation/claude-reports/bug_hunt_2026_07.md @@ -0,0 +1,610 @@ + + +# InfiniBot Bug Hunt — July 2026 + +**Branch:** `bug-hunt-2026-07` +**Scope:** Every `.py` file under `src/` (54 files, ~25.6k lines). +**Method:** Manual read of every module, cross-checked against the installed `nextcord==3.2.0` +source for API semantics (embed proxies, `edit_message` return types, `DMChannel.purge`, +view `auto_defer`, audit-log behavior). Documentation only — **no code was changed.** + +This report is organized by severity, then by subsystem. Each finding has a stable ID +(e.g. `B4`, `L1`, `O1`) so fixes/issues can reference it. The three reported problem areas +are called out inline. + +> Reported symptoms this hunt was asked to explain: +> 1. *Some birthday messages don't go out.* → primarily **B1, B2, B3, B4**. +> 2. *Message logging randomly happens when it shouldn't / doesn't when it should.* → primarily **L1, L2, L3, L4**. +> 3. *The options menu, at scale, stops responding to interactions.* → primarily **O1**, amplified by **B8/C1/C5** (event-loop starvation). + +--- + +## Severity legend + +- **CRITICAL** — causes the reported user-visible failures, data loss, or a security hole. +- **HIGH** — wrong behavior or crash on a common path; feature silently breaks. +- **MEDIUM** — wrong on an edge case, or a scaling/perf problem that compounds. +- **LOW** — cosmetic, latent, or cleanup. + +--- + +## Verification status (added after subagent review) + +All 58 substantive findings below were independently re-checked by `bug-verifier` subagents +(static tracing only, cross-referenced against installed nextcord 3.2.0). Result: **53 CONFIRMED, +4 FALSE POSITIVE, 1 PLAUSIBLE-UNCERTAIN**, plus a few "confirmed but limited-impact" caveats. +The affected entries are annotated inline with a **[VERIFIED …]** line. Summary of the exceptions: + +- **O1 — FALSE POSITIVE (was CRITICAL).** The specific menu-hang isn't reachable on the standard + flow: `send_robust` returns the int id and `show_loading` reassigns `this_message_id` from that + return value, so the `followup.edit_message` branch receives an int and `disable_all` only runs + after setup (when it's an object). The code is genuinely fragile/confusing and worth refactoring, + but does not crash as described. **Consequently, symptom 3 (options menu freezing at scale) is + attributed to O2 + event-loop starvation (C1/C5/B13), not O1.** +- **L14 — FALSE POSITIVE.** Each `channel.send(view=...)` registers a *fresh* view instance keyed by + that message id (verified against nextcord's ViewStore dispatch), so button state and `stop()` are + per-message. The `init_views` singleton is only a post-restart orphan fallback — not shared across + live messages. +- **U6 — FALSE POSITIVE.** `key` is bound by the earlier `global_kill_keys` loop, which always runs + at least once (every feature defines a non-empty `global_kill`), so no `NameError` occurs today. +- **M6 — FALSE POSITIVE.** The naive-datetime fallback in `message_to_message_record_type` is + unreachable from the spam path; every live caller passes a real `nextcord.Message` (aware + `created_at`). +- **B7 — PLAUSIBLE-UNCERTAIN.** The getter fragility is real, but no legacy/migration path was found + that could actually place a non-JSON value in `runtime`, so it isn't demonstrably reachable today. +- **L9 — CONFIRMED but no live trigger.** The rejection construct is real; no current caller passes a + raw DB `MessageRecord` without `override_checks=True`, so impact is latent. +- **L10 — core CONFIRMED, downstream UNCERTAIN.** The aware/naive storage inconsistency is real; the + `.timestamp()`-crash chain in `cleanup_stale_channels` was not shown to be reachable. +- **P1 — CONFIRMED; the X3 caveat below is backwards** — X3 means the "skip if loaded" guard never + fires, so the chunk→`send_message` path is *more* likely to run post-boot, not "masked." + +--- + +# CRITICAL findings + +### B1 — Birthdays: per-guild `now` drift makes late-processed guilds miss their window +**`src/features/birthdays.py:65`, `src/core/scheduling.py:63-95`** + +`run_scheduled_tasks` loops over all guilds and calls `check_and_run_birthday_actions(bot, guild)` +for each. That function computes its *own* `now = datetime.datetime.now(...)` (line 65) and fires +only if `hour_minute_now == runtime` **exactly** (line 88). + +At 4k guilds the loop takes minutes to walk (each iteration does synchronous DB I/O — see B8/C1), +so a guild processed at, say, `09:01` when the cycle started at `09:00` computes `hour_minute_now = "09:01"` +and no longer matches a `"09:00"` runtime. Guilds late in `bot.guilds` order silently never fire. + +**Fix direction:** pass the cycle-start `current_time_utc` from the scheduler into the birthday +check and match against a *window* (e.g. `cycle_start <= runtime < cycle_start + interval`), not an +exact minute captured per-guild. + +### B2 — Birthdays: CPU throttle `continue` skips the guild's birthday check for the whole day +**`src/core/scheduling.py:66-78`** + +When CPU is over threshold at a monitoring checkpoint, the loop does `await asyncio.sleep(delay); continue`. +`continue` skips guild `i` entirely for this cycle. Because birthday matching is exact-minute (B3), a +guild skipped during its only matching cycle misses birthdays **for that whole day**. Throttling should +delay-then-process, not delay-then-skip. + +### B3 — Birthdays: exact-minute match requires runtime aligned to the 15-min tick and no misfire +**`src/features/birthdays.py:88`, `src/core/scheduling.py:28,190`** + +The scheduler runs every `INTERVAL_MINUTES = 15`; birthdays fire only if `hour_minute_now == runtime` +to the minute. Two consequences: +- Any stored runtime not on `:00/:15/:30/:45` never fires. The dashboard rounds new inputs to 15 min + (`dashboard.py:3925-3934`), but any legacy/imported value off-grid is dead. +- If a scheduler run is delayed past the target minute (APScheduler `misfire_grace_time` is 30 s per + `config.json["scheduler.misfire-grace-time-seconds"]`), the match minute is skipped and the day is missed. + +Same root cause as B1 — match a window, not a minute. + +### B4 — `EmbedProperty.to_embed()` mutates the stored color in place → second call crashes +**`src/core/db_manager.py:572-577`, `src/components/utils.py:259`** + +```python +def to_embed(self): + properties = self.properties # NOT a copy + if "color" in properties: + properties["color"] = get_discord_color_from_string(properties["color"]) + return NextcordEmbed(**properties) +``` + +`properties` aliases the cached dict, so the color string is replaced by a `nextcord.Color` object +**on the stored EmbedProperty**. A second `to_embed()` on the same instance passes a `Color` object to +`get_discord_color_from_string`, which does `color.lower()` (utils.py:259) → `AttributeError`. + +In birthdays this bites directly: when 2+ members in one guild have a birthday the same day, the loop +calls `server.birthdays_profile.embed.to_embed()` per member (`birthdays.py:160`) on the **same cached +EmbedProperty**. The second member's call throws, the exception is caught at `birthdays.py:209`, and the +**rest of that guild's birthday messages are skipped.** This is a direct contributor to "some birthday +messages don't go out." The same latent bug affects any code path that renders the same profile embed +twice (level-up embeds, join/leave — mostly masked today because those build a fresh `Server` per event). + +**Fix direction:** `properties = dict(self.properties)` (or `copy.deepcopy`) before mutating. + +### L1 — Message-edit logging deletes the DB record it just stored (finally-block bug) +**`src/core/bot.py:576-585`** + +```python +# Add the edited message to the database +with LogIfFailure(...): + if utils.feature_is_active(guild_id=guild.id, feature="logging"): + stored_messages.store_message_in_db(edited_message) +finally: + # Update the message in the database + with LogIfFailure(...): + if utils.feature_is_active(guild_id=payload.guild_id, feature="logging"): + stored_messages.remove_message_from_db(payload.message_id) +``` + +The `try` body upserts the edited message, and then the `finally` **deletes that same `message_id`**. +So after every edit the stored copy is gone. The next edit or deletion of that message finds no DB record +→ the log shows "Contents Unretrievable" / "message cannot be retrieved." Even the early-return paths +(channel is None, `edited_message` is None) fall through the `finally` and delete the record. This is a +primary cause of "logging doesn't happen when it should." The `finally` deletion appears to be leftover +logic and should be removed (the upsert already keeps the record current). +**[VERIFIED CONFIRMED — the `finally` runs on all early returns too.]** + +### L2 — Member-update logging uses an unfiltered `audit_logs(limit=1)` → wrong actor & phantom logs +**`src/features/action_logging.py:867-890`** + +`log_member_update` grabs `entry = await anext(guild.audit_logs(limit=1), None)` with **no `action=` filter**, +then attributes nickname/role/timeout changes to `entry.user`. The latest audit entry can be *any* action +type from *any* admin, so: +- The wrong person is credited with the change. +- Role changes InfiniBot itself performs (level rewards `leveling.py:446`, default roles, reaction roles) + get logged as member role changes attributed to whoever last touched the audit log — "logging happens + when it shouldn't." Each of the sub-loggers (`log_role_change`, `log_nickname_change`, `log_timeout_change`) + should fetch with the matching `action=` and validate `entry.target.id == after.id`. + +### L3 — Member-removal logging blames the wrong member (no `entry.target` check) +**`src/features/action_logging.py:913-945`** + +`log_member_removal` scans the last 5 audit entries for any `kick`/`ban` action and, if one is "fresh", +logs the *removed* member as kicked/banned — but never checks that the audit entry's `target` is the member +who left. A member who leaves voluntarily seconds after someone else was kicked/banned is logged as +kicked/banned. Compare `entry.target.id == member.id`. + +### L5 — Edit-log can be coerced into an @everyone ping (mention injection) +**`src/features/action_logging.py:303`, `src/core/bot.py:64`** + +For long edited messages (1025–2000 chars) the edit logger sends the **raw user content** as a normal +message: `await log_channel.send(content=task[2], reference=message)`. The bot is constructed with +`allowed_mentions = nextcord.AllowedMentions(everyone=True)` (bot.py:64), so a user who edits a long +message containing `@everyone`/`@here`/a role mention makes **InfiniBot ping it in the log channel**. +Send these with `allowed_mentions=nextcord.AllowedMentions.none()`. + +### O1 — Options menu: `send_robust` stores a Message *object* where an ID is expected → menu hangs +**`src/features/options_menu/entrypoint_ui.py:83-122`** + +> **[VERIFIED FALSE POSITIVE — retracted as a crash.]** The trace below misses that `send_robust` +> *returns the int id* and `show_loading` reassigns `self.this_message_id` from that return value +> (line 77), so at the `followup.edit_message` branch `this_message_id` is an int, and `disable_all` +> only runs after setup completes (when it is an object). The described hang is not reachable on the +> standard flow. The int/object-alternating variable is genuinely confusing and worth refactoring, +> but it does not crash. **Symptom 3 (menu freezing at scale) is therefore attributed to O2 + C1/C5/B13, +> not O1.** Kept for the refactor recommendation only. + +```python +async def send_robust(self, interaction, **kwargs): + if self.this_message_id is not None: + try: + message = await interaction.response.edit_message(**kwargs) + message_id = message.id # (A) edit_message may return None + except nextcord.errors.InteractionResponded: + message = await interaction.followup.edit_message(self.this_message_id, **kwargs) # (B) passes an object as the id + message_id = message.id + else: + ... + message = await interaction.response.send_message(**kwargs) + message_id = (await message.fetch()).id + self.this_message_id = message # (C) stores the OBJECT, not the id + return message_id +``` + +The standard flow is `setup → show_loading()` (first `send_robust`, sets `self.this_message_id` to a +`PartialInteractionMessage` object at (C)) → `show_options()` (second `send_robust`). On the second call +`self.this_message_id is not None`, so it takes the `if` branch; the interaction is already responded, so it +hits (B) and calls `interaction.followup.edit_message(, ...)` — but +`edit_message` expects a **message id**. That produces a malformed webhook route / error, the menu never +advances past "Syncing Data. Please Wait…", and `disable_all` (line 122, `self.this_message_id.id`) also +fails because `PartialInteractionMessage` has no `.id` (verified against nextcord 3.2.0). Additionally at +(A), `InteractionResponse.edit_message()` returns `Optional[Message]` (None inside threads) so `message.id` +can `AttributeError`. This is the core "options menu bugs out and stops responding." (At scale the failure +is worse because event-loop starvation — B8/C1/C5 — makes the auto-defer miss the 3-second window, but the +logic bug is present regardless of load.) + +**Fix direction:** always store the message *id* (never the object); use `interaction.original_message()` +to resolve an id once; guard against `edit_message` returning None. + +### S1 — Default roles: missing `await` on the owner-warning coroutine +**`src/features/default_roles.py:38`** + +```python +except nextcord.errors.Forbidden: + logging.warning(...) + send_error_message_to_server_owner( # <-- not awaited + member.guild, "Manage Roles", ... + ) +``` + +`send_error_message_to_server_owner` is a coroutine; without `await` it never runs (and raises +"coroutine was never awaited"). Owners are never told why default roles fail. + +### S2 — DM command `clear-last` calls `DMChannel.purge`, which doesn't exist +**`src/features/dm_commands.py:21`** + +`await message.channel.purge(limit=1, ...)` runs in a DM context (`on_message` routes DMs here, +`bot.py:487-489`). In nextcord 3.2.0 `purge` exists on `TextChannel`/`Thread`/`VoiceChannel` but **not** +`DMChannel` → `AttributeError` every time, swallowed by `LogIfFailure`. The feature has never worked. Delete +the bot's last message by fetching history and calling `message.delete()` per message instead. + +### U1 — `guild.owner` is a member-cache lookup → None while unchunked (scale regression) +**`src/components/utils.py:355,818,941`, `src/core/server_join_and_leave_manager.py:203`** + +Since startup chunking was disabled (scale plan item 3), `guild.owner` is frequently `None` because the +owner Member isn't cached. Effects: +- `user_has_config_permissions` (utils.py:818): `interaction.guild.owner == interaction.user` silently + fails for actual owners who lack the Mod role → they're denied their own dashboard. +- `send_error_message_to_server_owner` (utils.py:941-942): `member = guild.owner; if member is None: return` + → **owner error/permission DMs silently never send.** This also masks other bugs by suppressing their + warnings. +- `@owner` replacement (utils.py:355) resolves to "Unknown". +- Fallback join DM (`server_join_and_leave_manager.py:203`) does `guild.owner.send(...)` → `AttributeError`. + +Use `guild.owner_id` + `bot.get_user`/`fetch_user` (or `guild.fetch_member(guild.owner_id)`). + +### U2 — `apply_generic_replacements` silently drops all replacements in embed fields & footer +**`src/components/utils.py:371-378, 410-415`** (verified against nextcord 3.2.0 `embeds.py:522`) + +`nextcord.Embed.fields` returns a fresh list of `EmbedProxy` objects built on each access +(`[EmbedProxy(d) for d in self._fields]`), and `.footer` is likewise a throwaway proxy. The code does: + +```python +for field in embed.fields: + field.name = field.name.replace(key, value) # mutates a throwaway proxy + field.value = field.value.replace(key, value) +... +embed.footer.text = embed.footer.text.replace(key, value) # mutates a throwaway proxy +``` + +None of these writes reach the embed. So placeholders like `[age]`, `[level]`, `[rank]`, `@mention`, +`#channel` **are never replaced when they appear in a field or footer** — only title/description/url work. +Birthday embeds set `[age]`/`[realname]` as custom replacements and any field usage is lost. Rebuild fields +with `embed.clear_fields()` + `embed.add_field(...)`, and set the footer via `embed.set_footer(...)`. + +--- + +# HIGH findings + +### B7 — `typed_property` getter throws on legacy / non-JSON stored values +**`src/core/db_manager.py:444-450`** + +The getter does `packet = json.loads(str(value)); packet['status']`. A plain integer/string left by an +older schema (or any non-JSON value) raises, which propagates. For `birthdays_profile.runtime` +(a `typed_property`), a bad stored value throws inside `check_and_run_birthday_actions`, caught at +`birthdays.py:209`, aborting that guild's birthday run. Wrap in try/except with a sane fallback. +**[VERIFIED PLAUSIBLE-UNCERTAIN — the getter fragility is real, but no migration/legacy path was found +that could actually write a non-JSON value into `runtime` (default and every setter emit valid JSON), +so it isn't demonstrably reachable today. Harden anyway.]** + +### B11 — Daily maintenance does a per-member REST fetch (N+1) and checks a stale value +**`src/features/leveling.py:224,243`, `src/features/moderation.py:958`** + +`daily_leveling_maintenance` and `daily_moderation_maintenance` iterate DB rows and `await utils.get_member(...)` +each one. With chunking disabled, uncached members fall through to a REST `fetch_member` — one HTTP call per +leveled/striked member, per guild, at 3 a.m. local. This is slow and rate-limit-prone. + +Separately, leveling.py:243 checks `if member_level_info.points == 0:` to clean up, but `member_level_info` +is the **pre-decrement snapshot**; it should check the freshly computed `_points`. Cleanup of zeroed members +is therefore delayed by a day. + +### B13 / B14 — Daily DB maintenance blocks the loop and can mass-delete live guild data +**`src/core/db_manager.py:1081-1261`, `src/modules/database.py:241-291`** + +- `daily_database_maintenance` runs at 09:00 UTC on the event loop and calls `optimize_database(throttle=True)`, + which does a **blocking `time.sleep(0.1)`** between tables (`database.py:271`) plus synchronous VACUUM/checkpoint — + freezing all interactions/events for the duration. +- `cleanup_orphaned_guild_entries` (db_manager.py:1160) treats any guild **not in `bot.guilds`** as orphaned and + deletes all its rows. During a shard outage / `unavailable` guild window, `bot.guilds` is incomplete, so this + can wipe live servers' configs. Guard against running when shards are not all ready, or intersect with a + known-good guild set. + +### L4 — `entry_is_fresh` is broken at hour boundaries and too permissive +**`src/features/action_logging.py:96-108`** + +```python +return (entry.created_at.month == now.month and entry.created_at.day == now.day + and entry.created_at.hour == now.hour + and ((now.minute - entry.created_at.minute) <= 5)) +``` + +- Hour boundary false-negative: entry at `13:59`, now `14:01` → different hour → "not fresh" even though 2 min apart. +- Minute false-positive: `now.minute - entry.minute` can be negative or up to 59; an entry 40 min old in the same + hour reads as "fresh." +- Discord *stacks* repeated `message_delete` audit entries and keeps the original `created_at`, so a stale stacked + entry looks fresh and misattributes the deleter. Compute a real delta: `abs((now - entry.created_at).total_seconds()) <= N`. + +### L6 — Member logging force-chunks the whole guild on the first member update +**`src/features/action_logging.py:855-856`** + +`if not guild.chunked: await guild.chunk()` inside `log_member_update`. With startup chunking disabled, the first +member update in any logging-enabled large guild triggers a full gateway member chunk — exactly the flood the +chunking change was meant to avoid. Also, `on_member_update` only fires for already-cached members, so nickname/role +logging is structurally unreliable post-change (documented tradeoff, but worth noting alongside L2). + +### M1 — Nickname profanity: `.seconds` freshness + 1s upstream sleep ⇒ self-edits never caught +**`src/features/moderation.py:175`** + +`if (now - entry.created_at).seconds > 1: entry_is_fresh = False`. Two problems: `timedelta.seconds` +**wraps at 24 h** (a day-old entry can read as "fresh"), and the 1-second window is smaller than the 1-second +`asyncio.sleep` that `log_member_update` already performed before this path runs — so `user.id == member.id` +("they edited their own nick") is essentially never true, and the code takes the "someone else edited it" branch +instead. Use `.total_seconds()` with a realistic window. + +### O2 — Options menu: BanButton.load makes 2 REST calls per menu open +**`src/features/options_menu/ban_button.py:23,46`** + +`load` calls `await get_member(...)` (REST fetch if uncached) and `await interaction.guild.fetch_ban(...)` — two +network round-trips synchronously inside the menu-build loop, before any response is sent. Under load (and with the +event loop already contended) this routinely blows the 3-second interaction window, contributing to the "menu stops +responding" symptom. + +### O3 — Options menu: `relevant_member.top_role` on a `User` → AttributeError kills the whole menu +**`src/features/options_menu/ban_button.py:36`** + +`self.relevant_member` can be a `nextcord.User` (uncached message author) which has no `.top_role`. The check +`interaction.guild.me.top_role.position > self.relevant_member.top_role.position` then raises. Because +`OptionsView.setup` runs each `button.load` without per-button isolation (entrypoint_ui.py:66-67), one button's +exception aborts the entire options menu build. + +### S4 — Role-message select: a deleted role yields `add_roles(None)` → button dies +**`src/features/role_messages.py:79-86, 167-174`** + +`roles_add.append(interaction.guild.get_role(int(role)))` appends `None` when a role was deleted, then +`await interaction.user.add_roles(*roles_add)` passes `None` → `AttributeError` (only `Forbidden` is caught). +The role-message button then shows a generic error instead of applying the valid roles. Filter out `None`. + +### C1 — Every `get_configs()` / `get_global_kill_status()` call re-syncs & deep-copies the whole config +**`src/config/global_settings.py:131-155`, `src/config/file_manager.py:114-122`** + +`GlobalSetting.__init__` calls `sync_variables_with_image`, which for **every** leaf key does `path not in self.file`, +and each `__contains__` calls `_get_data()` → `copy.deepcopy` of the entire parsed file. `get_configs()` / +`get_global_kill_status()` construct a fresh instance each call, so a single call performs dozens of full-config +deep copies. These functions are called on the per-message hot path, per-guild in the scheduler, and throughout the +UI. The disk-read cache added in scale plan item 1 removed the file I/O but not this deep-copy/sync storm. Make the +config objects module-level singletons and sync once at startup. + +--- + +# MEDIUM findings + +### B5 — Feb-29 birthdays never fire on non-leap years +**`src/features/birthdays.py:104`** — `strftime('%m-%d', birth_date) = :month_day` never matches `02-29` in a +non-leap year. Decide on a fallback (Feb 28 / Mar 1) or document. + +### B6 — Deleted/hidden birthday channel drops the message with no owner notice +**`src/features/birthdays.py:150-157`** — when `channel_id` is set but `guild.get_channel()` returns None, nothing +is sent and no owner warning is issued (only the `Forbidden` path warns). Silent birthday loss. + +### B10 — Daily log rotation resets the configured log level to INFO +**`src/core/scheduling.py:49-51`** — at UTC midnight the scheduler calls `setup_logging()` (defaults to INFO), +discarding the `logging.log-level` from config that `main.configure_logging()` applied at boot. A DEBUG deployment +silently reverts to INFO each night. + +### L7 — `trigger_delete_log` can `AttributeError` on `user`/`deleter` +**`src/features/action_logging.py:387-399`** — when the audit log is "fresh" but the message is uncached, +`user = entry.target` may be None, and `user.id`/`deleter.id` then throw. Swallowed by `LogIfFailure` ⇒ no delete +log at all. + +### L9 — `message_checks` rejects DB-sourced `MessageRecord`s +**`src/config/messages/utils.py:160`** — a `MessageRecord` always has a `.guild` attribute (None until fetched), +so `hasattr(message, 'guild') and message.guild is None` is True ⇒ `message_checks` returns False. Any path that +caches/stores a `MessageRecord` (rather than a live `nextcord.Message`) without `override_checks=True` is silently +dropped. Check for the record type explicitly. +**[VERIFIED CONFIRMED as a code defect, but no live caller triggers it today — all current +cache/store call sites pass a live `nextcord.Message`. Latent.]** + +### L10 / L12 — Message timestamp format drift & delete-fallback author crash +**`src/config/messages/stored_messages.py:31-33`, `src/core/bot.py:620-630`** +- The insert stores an aware `last_updated`, but the `ON CONFLICT` update writes SQLite `CURRENT_TIMESTAMP` + (naive UTC string, different format). `cleanup_db` then does string comparisons across formats and + `cleanup_stale_channels` (`cached_messages.py:274-276`) calls `.timestamp()` on possibly-naive datetimes + (interpreted as *local* time) — stale-eviction and retention math drift by the host TZ offset. +- In the delete fallback, `await message.fetch("author")` can fail leaving `message.author` None; then + `trigger_delete_log` / `log_raw_message_delete` deref `message.author.id` → swallowed → no log. + +**[VERIFIED — L12 CONFIRMED. L10 core inconsistency (aware insert vs `CURRENT_TIMESTAMP` naive update) +CONFIRMED, but the specific `.timestamp()`-crash in `cleanup_stale_channels` was NOT shown reachable +(that cache is populated only from live messages), so treat the crash chain as uncertain — the storage +inconsistency itself stands.]** + +### L14 — Persistent singleton views share mutable button state; `stop()` kills all instances +**`src/features/action_logging.py:19-62` (ShowMoreButton), `src/features/moderation.py:23-88` (IncorrectButtonView)** + +> **[VERIFIED FALSE POSITIVE — retracted.]** Each `channel.send(view=...)` creates and registers a +> *fresh* view instance keyed by that message's id (confirmed against nextcord's ViewStore dispatch, +> `(component_type, message_id, custom_id)`), so `button.label` and `self.stop()` act on that message's +> own instance — not shared across live messages. The `init_views` singleton (keyed `message_id=None`) +> is used only as a fallback for orphaned messages after a restart. There is a narrower latent nuance +> there, but the claimed "kills all instances" mechanism does not occur under normal operation. + +Both are registered once in `init_views` and handle every pre-restart message of their type. `button.label` is toggled +on the shared instance, and `IncorrectButtonView.event` calls `self.stop()` (moderation.py:88) — stopping the singleton +disables *all* outstanding "Mark As Incorrect" buttons across the server until the next restart. Persistent views must +be stateless and must not call `stop()`. + +### M3 — Edited messages bypass the admin-role profanity exemption +**`src/core/bot.py:570` vs `src/features/moderation.py:1014-1022`** — the `on_message` path exempts both +administrators and members holding a configured `admin_role_ids` role. The edit path calls +`check_and_trigger_profanity_moderation_for_message` directly, which only checks `guild_permissions.administrator`. +Members with the configured admin role are punished for edits but not for original posts. + +### M4 / M6 — Strike timestamps mix naive and aware datetimes +**`src/features/moderation.py:358,367,978`, `src/config/messages/utils.py:203`** — strikes are written with naive +`datetime.datetime.now()` but expiry parses them as UTC (`parse_datetime_string`), so expiry is off by the host's UTC +offset; and `daily_moderation_maintenance` writes aware UTC (line 978), so records are inconsistent. Spam moderation +subtracts `time_now (aware) - _message.last_updated`, which can be naive (`messages/utils.py:203` fallback) → +`TypeError` if a DB-sourced record ever enters that path. +**[VERIFIED — M4 CONFIRMED. M6 FALSE POSITIVE: the naive `last_updated` fallback is unreachable from the +spam path (every live caller passes a `nextcord.Message` with an aware `created_at`), so the `TypeError` +does not occur today.]** + +### O5 / O6 — Edit-embed flow double-responds and can KeyError +**`src/features/options_menu/editing/embeds.py:14-54, 28`** — when the message is missing, `load_buttons` sends an +error response but `setup` continues and calls `interaction.response.edit_message` → `InteractionResponded`. And +`self.message_info = self.server.managed_messages[self.message_id]` KeyErrors if the message is no longer cataloged +(same pattern in `editing/reaction_roles.py:31`, `edit_button.py`). + +### C2 — JSONFile writes are not atomic → crash mid-write silently resets settings +**`src/config/file_manager.py:135`, `:88-104`** — `_set_data` overwrites the file directly. A crash mid-write leaves +malformed JSON; on next load `ensure_existence` renames it to `.bak` and recreates `{}`, silently resetting all +global-kill flags / admin IDs to defaults. Write to a temp file and `os.replace`. + +### C5 — A new `scoped_session` registry is created on every query and never removed +**`src/modules/database.py:104-107`** — `self.Session` is never a `scoped_session`, so `scoped_session(self.Session)` +builds a fresh registry per call that is never `.remove()`d. Combined with `QueuePool(pool_size=5)` this is wasteful +and can leak connections under load. Create one `scoped_session` at init and reuse it. + +### C8 / C9 — IntegratedList N+1 access and raw-interpolated secondary keys +**`src/core/db_manager.py:674-676, 1016-1023, 1046-1053`** — `_get_all_entries` fetches each row with a separate +query; `__len__` materializes the whole list (used by `len(server.birthdays)` on the hot path, `birthdays.py:79`); +`__next__` re-creates the generator and returns the first entry forever. `_get_entry` interpolates the secondary key +directly into SQL (int-only today, but a string key would be a syntax error / injection vector; `birthdays.py:100-118` +also hand-builds SQL against this private API). + +### C13 — `on_shard_ready` appends shard ids without dedup +**`src/core/bot.py:205-210`** — on shard reconnects the same id is appended again, so `shards_loaded` grows and the +"All shards loaded" branch re-fires; any gating that compares `len(shards_loaded) == shard_count` is unreliable. + +### C15 — Shared module-level error embed is mutated per error (races the error id) +**`src/components/ui_components.py:54-63, 202-203`, `src/core/bot.py:463-464`** — `INFINIBOT_ERROR_EMBED` is a single +module-level `Embed`; handlers call `embed.set_footer(text=f"...Error ID: {error_id}")` on it. Concurrent errors +overwrite each other's footer, so users can see the wrong error id. Build a fresh embed per error. + +### P1 — Purge command can double-respond after chunk feedback +**`src/features/purging.py:332-336`** — `chunk_guild_with_feedback` may send the interaction's initial response, then +`user_has_config_permissions` (utils.py:824) tries `interaction.response.send_message` → `InteractionResponded` +(unhandled). **[VERIFIED CONFIRMED and reachable. Correction: the original X3 caveat here was backwards — +X3 means the "skip if loaded" guard never fires, so the chunk→`send_message` path is *more* likely to run +post-boot, making this double-response more reachable, not masked.]** + +### X1 — Link-only views are created with `timeout=None` (view-store growth) +**`src/components/ui_components.py:453-527`** — `SupportView`, `InviteView`, `SupportAndInviteView`, etc. all pass +`timeout=None`. These are sent on many responses; each registers in nextcord's view store forever. The scale +leak-fix (#73) set a finite default on `CustomView`, but these explicitly opt out. Link-only buttons don't need to be +persistent — give them a finite timeout (they keep working as links after the view expires). + +### X3 — `chunk_guild_with_feedback` reads a stale import-time `bot_loaded` snapshot +**`src/components/ui_components.py:11,107`** — `from config.global_settings import ... bot_loaded` binds the value at +import (False). `on_ready` later sets the module attribute, but this reference never updates, so the +"skip if already loaded" guard (`if bot_loaded: return None`) never fires post-boot. Import the module and read +`global_settings.bot_loaded`, or call `get_bot_load_status()`. + +### LV2 — Every level-up announcement scans all member levels (N+1 SQL) +**`src/features/leveling.py:417`** — `process_level_change` → `get_member_rank` → `get_ranked_members` iterates the +entire `member_levels` table (each row via the IntegratedList N+1 path, C8) just to compute one member's rank for the +level-up embed. On an active large server this is a lot of SQL per message that levels someone up. + +### S3 — Join-to-create VC: unbounded creation & uncaught HTTPException +**`src/features/join_to_create_vcs.py:53-65`** — a user rapidly rejoining the trigger VC spawns a channel each time; +only `Forbidden` is caught on create/move, so an `HTTPException` (rate limit) leaves an orphaned VC and aborts. No +per-user/per-guild cap. + +--- + +# LOW findings (cleanup / latent) + +- **B9** `src/core/scheduling.py:170` docstring says "every 5 minutes" but the interval is 15; and line 44-45 logs + `"Logging skipped due to intended behavior."` on 6 of every 7 runs — noisy and contradictory. +- **B12** `scheduling.py:82-86` timezone cache TTL is 1 h, so a server's timezone change takes up to an hour to take + effect (cosmetic). +- **C3** `src/config/file_manager.py:318` defines `__dict__` as a *method*, shadowing the instance-dict descriptor; + breaks `vars()`/copy/pickle on `JSONFile`. Same anti-pattern in `db_manager.Simple_TableManager.__dict__` (line 165) + and `jokes.Joke.__dict__` (jokes.py:55). +- **C4** `GlobalSetting.get_variable` does a `__contains__` then a `__getitem__`, i.e. two full `_get_data` deep copies + per read (compounds C1). +- **C6** `Database.remove_extraneous_rows` uses `.rstrip(" AND")` (strips the *characters* `' '`, `'A'`, `'N'`, `'D'`, + not the suffix) and interpolates raw default values into SQL (`database.py:233-236`). +- **C7** `Database.execute_query` re-raises as `raise Exception(e)` (`database.py:127`), erasing the original exception + type for callers. +- **C10** `Simple_TableManager._init_regular_entry` assumes the primary key is the first column and that every other + column has a default (`db_manager.py:182-190`); a non-defaulted column would produce an invalid INSERT. +- **C11** `Simple_TableManager.__init__` runs a `SELECT *` existence check on every instantiation, and each property + getter does a `dir(self)` scan (`db_manager.py:234`) — expensive given how often `Server(...)`/profiles are built. +- **C12** Shard-count math `(_ // guilds_per_shard) + 1` (`shard_manager.py:43,105`) allocates an extra shard when the + guild count divides evenly. +- **C14** `LogIfFailure.__exit__` returns `self.suppress` for *any* exception type incl. `KeyboardInterrupt`/ + `CancelledError` (`log_manager.py:47-53`); it should re-raise `BaseException` subclasses that aren't `Exception`. +- **C16** `bot.py:238` `async def set(interaction)` shadows the builtin `set` at module scope (the module uses `set` + elsewhere only via literals, so latent). +- **O4** `BanButton.get_label` compares `self._type == "message"` but the view passes `"Message"` (capitalized, + `entrypoint_ui.py:27`) → the label is always "Ban Member" even on a message. +- **O7** `default=(option is original_color)` uses identity comparison on strings (`editing/embeds.py:108`, + `role_messages.py:325`, and the color-select in `reaction_roles`), relying on interning; use `==`. +- **O8** `OptionsButton.load` is documented to "return whether it should be added," but `EditButton.load`/`DeleteDMButton` + add themselves and the caller ignores the return value — inconsistent contract. +- **U3** `utils.timeout` returns `"Success revoked"` when the bot **lacks** `moderate_members` (`utils.py:1070-1074`); + callers test `startswith("Success")` (`moderation.py:423`) and then delete the strike as though the timeout worked. +- **U4** `get_infinibot_mod_role` **creates** the role as a side effect of a lookup (`utils.py:1013-1015`), and + `check_server_for_infinibot_mod_role` mutates `guild.roles` in place (`utils.py:1034-1035`). +- **U5** `standardize_str_indention` lstrips every line in its first loop, making the subsequent indentation logic dead + code (`utils.py:184-201`). +- **U6** `feature_is_active` final debug log references loop var `key` (`utils.py:500`) — `NameError` if a feature ever + has empty key tuples. **[VERIFIED FALSE POSITIVE — `key` is bound by the earlier `global_kill_keys` loop, which + always runs at least once since every feature defines a non-empty `global_kill`; no `NameError` today. The logged + `key` is merely semantically stale in some cases. Retracted.]** +- **U7** `send_error_message_to_server_owner` sleeps 1 s and reads the DB per call, dedups only 30 min, and swallows all + exceptions with a bare `except` (`utils.py:988`). +- **L8** Delete-log says "9/N attached below" (`action_logging.py:441`) but the slice attaches 8 (`embeds[0:8]`, line 462). +- **L11** `on_raw_message_edit` calls `remove_cached_message` then `cache_message` (`bot.py:558-559`); `cache_message` + already updates in place, so the remove is redundant. +- **L13** `file_computation` does a pointless mmap round-trip copy of attachment bytes (`action_logging.py:112-133`). +- **L15** Log embeds use naive `datetime.datetime.now()` for `timestamp=` throughout action_logging — cosmetic TZ shift. +- **S5** Autoban path sleeps ~6 s total inside `on_member_join` per autobanned member (`autobans.py:22,34`). +- **X2** `ExpiringSet.add` never purges, so write-mostly instances grow until a read (`custom_types.py:55-58`). +- **LV1** `grant_xp_for_message` anti-spam multiplier `points_multiplier += forgiveness * (points_multiplier/2)` + (`leveling.py:333`) grows ×2.5 per step and is clamped to 1, so it's ~always 1 except for near-identical messages — + intent unclear vs. the documented anti-spam design in `documentation/leveling_anti-spam_logic.ipynb`. +- **Reaction roles** `run_raw_reaction_add` force-chunks the guild (`reaction_roles.py:339-340`) — same concern as L6. + +--- + +# Cross-cutting themes + +1. **Time handling.** Exact-minute equality (B1/B3), `timedelta.seconds` wraparound (L4/M1/M2), and naive-vs-aware + datetime mixing (M4/M6/L10) recur across scheduling, logging, and moderation. A single tz-aware time utility + + window-based matching would fix a whole class of "sometimes fires, sometimes doesn't" bugs. +2. **Audit-log attribution.** L2/L3/L4/L7/M1 all stem from trusting `audit_logs(limit=1|5)` without filtering by + `action=` and validating `entry.target`. This is the engine behind "logging happens when it shouldn't." +3. **nextcord proxy/return-type semantics.** U2 (embed field/footer proxies), O1 (`edit_message` returns + Optional/Partial), and B4 (in-place mutation of cached dicts) are all "the object you got back isn't the object you + think you're mutating." +4. **Event-loop starvation at scale.** C1 (config deep-copy storm), C5 (per-query session registry), C8/C11 + (per-instance and N+1 SQL), B8/B13 (sync I/O + `time.sleep` on the loop) collectively blow the 3-second interaction + deadline — which is what turns the O1/O2 logic bugs into the visible "options menu stops responding" at scale. +5. **Persistent-view statefulness.** L14/X1 — singleton views registered in `init_views` carry mutable per-message + state and call `stop()`, which is unsafe for views meant to outlive a single interaction. + +# Suggested triage order + +1. **L1** (edit logging deletes its own record) — one-line-ish removal, directly fixes reported symptom 2. +2. **O1** (options-menu message-id handling) — fixes reported symptom 3's logic core. +3. **B1/B2/B3/B4** (birthday window + embed color copy) — fixes reported symptom 1. +4. **U1, U2** (owner resolution, embed field/footer replacements) — silently breaking many features. +5. **L2/L3/L4** (audit-log attribution) — the rest of reported symptom 2. +6. **S1, S2, S4, U3** (quick correctness fixes). +7. **C1/C5/C8/B13** (event-loop starvation) — the scale multiplier behind symptom 3. +8. Security: **L5** (@everyone injection) and **C2/B14** (data-loss hazards). + +--- + +*Generated by Anthropic Claude Fable 5 via Claude Code (CLI). Documentation-only pass — no source files were modified.* +*Line references are against the `bug-hunt-2026-07` branch at the time of writing; verify before fixing.* diff --git a/documentation/scale_performance_assessment_2026-07.md b/documentation/claude-reports/scale_performance_assessment_2026-07.md similarity index 100% rename from documentation/scale_performance_assessment_2026-07.md rename to documentation/claude-reports/scale_performance_assessment_2026-07.md From 1c5a1a27504c86d36699ff5c044ecf2e7ed5c942 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:43:17 -0600 Subject: [PATCH 002/109] Fix L1: message-edit handler no longer deletes the DB record it just stored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on_raw_message_edit try/finally removed the stored message after every edit (including early-return paths), so subsequent edit/delete logs found no record ('Contents Unretrievable'). The upsert already keeps the record current; the finally-block deletion was leftover logic. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/core/bot.py | 92 ++++++++++++++++++++++--------------------------- 1 file changed, 42 insertions(+), 50 deletions(-) diff --git a/src/core/bot.py b/src/core/bot.py index d088a04..9ba5649 100644 --- a/src/core/bot.py +++ b/src/core/bot.py @@ -531,58 +531,50 @@ async def on_raw_message_edit(payload: nextcord.RawMessageUpdateEvent) -> None: if payload.guild_id is None: return - edited_message = None - try: - # Find guild and channel - channel = await utils.get_channel(payload.channel_id, bot=bot) - if channel is None: - return + # Find guild and channel + channel = await utils.get_channel(payload.channel_id, bot=bot) + if channel is None: + return - guild = channel.guild - if guild is None or not guild.me: - return + guild = channel.guild + if guild is None or not guild.me: + return - # If we have it, grab the original message - original_message = payload.cached_message - if original_message is None: - # Find db stored message - original_message = stored_messages.get_message_from_db(payload.message_id) - - # Find the message - edited_message = await utils.get_message(channel, payload.message_id) - if edited_message is None: - return - - # Update the message's cache - with LogIfFailure(feature="cached_messages.remove_cached_message & cached_messages.cache_message"): - cached_messages.remove_cached_message(edited_message.id, channel.id) - cached_messages.cache_message(edited_message) - - # Resolve the author if it's not a Member object (e.g., if the member is not cached) - if not isinstance(edited_message.author, nextcord.Member): - with LogIfFailure(feature="utils.get_member (message edit profanity check)"): - resolved_author = await utils.get_member(guild, edited_message.author.id) - if resolved_author is not None: - edited_message.author = resolved_author - - # Punish profanity (if any) - with LogIfFailure(feature="moderation.check_and_trigger_profanity_moderation_for_message"): - await moderation.check_and_trigger_profanity_moderation_for_message(bot, Server(guild.id), edited_message) - - # Log the message - with LogIfFailure(feature="action_logging.log_raw_message_edit"): - await action_logging.log_raw_message_edit(guild, original_message, edited_message) - - # Add the edited message to the database - with LogIfFailure(feature="stored_messages.store_message_in_db(edited_message)"): - if utils.feature_is_active(guild_id=guild.id, feature="logging"): - stored_messages.store_message_in_db(edited_message) - - finally: - # Update the message in the database - with LogIfFailure(feature="stored_messages.remove_message_from_db"): - if utils.feature_is_active(guild_id=payload.guild_id, feature="logging"): - stored_messages.remove_message_from_db(payload.message_id) + # If we have it, grab the original message + original_message = payload.cached_message + if original_message is None: + # Find db stored message + original_message = stored_messages.get_message_from_db(payload.message_id) + + # Find the message + edited_message = await utils.get_message(channel, payload.message_id) + if edited_message is None: + return + + # Update the message's cache + with LogIfFailure(feature="cached_messages.remove_cached_message & cached_messages.cache_message"): + cached_messages.remove_cached_message(edited_message.id, channel.id) + cached_messages.cache_message(edited_message) + + # Resolve the author if it's not a Member object (e.g., if the member is not cached) + if not isinstance(edited_message.author, nextcord.Member): + with LogIfFailure(feature="utils.get_member (message edit profanity check)"): + resolved_author = await utils.get_member(guild, edited_message.author.id) + if resolved_author is not None: + edited_message.author = resolved_author + + # Punish profanity (if any) + with LogIfFailure(feature="moderation.check_and_trigger_profanity_moderation_for_message"): + await moderation.check_and_trigger_profanity_moderation_for_message(bot, Server(guild.id), edited_message) + + # Log the message + with LogIfFailure(feature="action_logging.log_raw_message_edit"): + await action_logging.log_raw_message_edit(guild, original_message, edited_message) + + # Keep the stored copy current (upsert) so future edit/delete logs can retrieve it + with LogIfFailure(feature="stored_messages.store_message_in_db(edited_message)"): + if utils.feature_is_active(guild_id=guild.id, feature="logging"): + stored_messages.store_message_in_db(edited_message) @bot.event async def on_raw_message_delete(payload: nextcord.RawMessageDeleteEvent) -> None: From 21045a9ae94499bc6c02d8e862ef9900273c494f Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:43:41 -0600 Subject: [PATCH 003/109] Fix B4: to_embed() no longer mutates the stored EmbedProperty color in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit properties aliased the cached dict, so the first to_embed() replaced the color string with a nextcord.Color object; a second call on the same instance then crashed in get_discord_color_from_string (color.lower()). This aborted the rest of a guild's birthday run when 2+ members shared a birthday. Copy the dict before converting. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/core/db_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/db_manager.py b/src/core/db_manager.py index c789cd0..2bf9920 100644 --- a/src/core/db_manager.py +++ b/src/core/db_manager.py @@ -570,10 +570,10 @@ def to_dict(self): return self.properties def to_embed(self): - properties = self.properties + properties = dict(self.properties) # copy so the stored dict keeps its color string if "color" in properties: properties["color"] = get_discord_color_from_string(properties["color"]) - + return NextcordEmbed(**properties) def get(self, key, default=None): From 81e40bf699d2b5a1f6c269ae03a3da7b63f02ce4 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:45:13 -0600 Subject: [PATCH 004/109] Fix B1/B3: match birthday runtime against the scheduler cycle window, not an exact minute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each guild previously computed its own 'now' and fired only on exact HH:MM equality with the stored runtime. At scale the guild loop takes minutes, so guilds processed after the matching minute silently never fired (B1); off-grid legacy runtimes and slightly-late runs could never match either (B3). The scheduler now passes its cycle-start time into check_and_run_birthday_actions, which fires when the runtime falls in [cycle start floored to the 15-min grid, +15 min). Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/core/scheduling.py | 9 +++++++-- src/features/birthdays.py | 38 +++++++++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/core/scheduling.py b/src/core/scheduling.py index 1dcb128..6d9fa9a 100644 --- a/src/core/scheduling.py +++ b/src/core/scheduling.py @@ -91,8 +91,13 @@ async def run_scheduled_tasks() -> None: # Time calculation current_time_local = current_time_utc.astimezone(tz) - # Birthday checks - await check_and_run_birthday_actions(bot, guild) + # Birthday checks (pass the cycle start so guilds processed late in the + # loop still match the window this cycle covers) + await check_and_run_birthday_actions( + bot, guild, + cycle_start=current_time_utc, + interval_minutes=INTERVAL_MINUTES, + ) # Daily maintenance # 3am local time is a good time to run daily maintenance since it's a low traffic hour diff --git a/src/features/birthdays.py b/src/features/birthdays.py index c46240c..08e06a1 100644 --- a/src/features/birthdays.py +++ b/src/features/birthdays.py @@ -35,24 +35,35 @@ def calculate_age(local_datetime: datetime.datetime, birth_date: datetime.date | return age -async def check_and_run_birthday_actions(bot: nextcord.Client, guild: nextcord.Guild) -> None: +async def check_and_run_birthday_actions( + bot: nextcord.Client, + guild: nextcord.Guild, + cycle_start: datetime.datetime | None = None, + interval_minutes: int = 15, +) -> None: """ |coro| Run the 15-minute birthday task. - This function is intended to be run every 15 minutes. It checks if the runtime - for birthday messages is now, and if so, sends out birthday messages to the - specified channels. + This function is intended to be run every ``interval_minutes`` minutes. It checks + whether the guild's configured runtime falls inside the current scheduler cycle's + window, and if so, sends out birthday messages to the specified channels. :param bot: The bot client. :type bot: nextcord.Client :param guild: The guild to check for birthdays. :type guild: nextcord.Guild + :param cycle_start: The UTC time the scheduler cycle started. All guilds in a cycle + must share this value so late-processed guilds don't miss their window. + Defaults to the current UTC time. + :type cycle_start: datetime.datetime | None + :param interval_minutes: Width of the match window (the scheduler interval). + :type interval_minutes: int :return: None :rtype: None """ - + logging.debug(f"Running scheduled action for birthdays in on guild: {guild.name} ({guild.id})") if get_global_kill_status()["birthdays"]: @@ -62,8 +73,13 @@ async def check_and_run_birthday_actions(bot: nextcord.Client, guild: nextcord.G logging.warning("SKIPPING birthdays because of bot load status.") return - now = datetime.datetime.now(datetime.timezone.utc).replace(second=0, microsecond=0) - hour_minute_now = now.strftime("%H:%M") + if cycle_start is None: + cycle_start = datetime.datetime.now(datetime.timezone.utc) + # Window: [cycle start floored to the interval grid, +interval_minutes). This also + # catches any hypothetical legacy runtimes not aligned to the grid, and runs that + # start slightly late. + cycle_minutes = cycle_start.hour * 60 + cycle_start.minute + window_start = cycle_minutes - (cycle_minutes % interval_minutes) try: if guild is None: @@ -82,10 +98,10 @@ async def check_and_run_birthday_actions(bot: nextcord.Client, guild: nextcord.G # Extract HH:MM from values like "18:00 UTC" or "8:00 PDT" or "18:00" runtime_text = str(server.birthdays_profile.runtime) # stored as UTC time m = re.search(r"\b(\d{1,2}):(\d{2})\b", runtime_text) - runtime = f"{m.group(1).zfill(2)}:{m.group(2)}" if m else None - logging.debug(f"Runtime: {runtime}, Current Time: {hour_minute_now}") - - if runtime is None or hour_minute_now != runtime: + runtime_minutes = int(m.group(1)) * 60 + int(m.group(2)) if m else None + logging.debug(f"Runtime: {runtime_text}, Cycle window start (min of day): {window_start}") + + if runtime_minutes is None or not (window_start <= runtime_minutes < window_start + interval_minutes): return logging.debug(f"Found a server with runtime now. Server: {guild.name} (ID: {guild.id})") From 34f285141c5346f237a55348d42f4ad4ef4250d2 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:45:29 -0600 Subject: [PATCH 005/109] Fix B2: CPU throttling delays guild processing instead of skipping the guild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The throttle branch did sleep-then-continue, dropping guild i from the cycle entirely; a guild skipped during its matching window missed its birthdays for the day. Now it sleeps and then processes the guild. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/core/scheduling.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/scheduling.py b/src/core/scheduling.py index 6d9fa9a..20a0be2 100644 --- a/src/core/scheduling.py +++ b/src/core/scheduling.py @@ -74,8 +74,9 @@ async def run_scheduled_tasks() -> None: else: delay = min(1.0, 0.3 * (cpu / throttle_cpu)) # Lighter throttling for normal high CPU logging.debug(f"CPU {cpu}% > {throttle_cpu}%, throttling for {delay}s (normal)") + # Delay, then still process this guild — skipping it would drop + # its birthday/maintenance checks for the whole cycle (or day). await asyncio.sleep(delay) - continue # Timezone caching try: From c75dcd0999d6b43891edbdcd3973bd4f282eeb16 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:47:21 -0600 Subject: [PATCH 006/109] Fix U1: resolve guild owner via owner_id instead of the member-cache-only guild.owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With startup chunking disabled, guild.owner is frequently None. This made owner permission checks deny actual owners, silently dropped owner error/warning DMs, rendered @owner as 'Unknown', and crashed the fallback join DM. Adds utils.get_guild_owner() (owner_id + cached fetch fallback) and uses owner_id comparisons where an object isn't needed. Also hardens the admin /view-server owner line against an uncached owner. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human-review notes:** Changes in utils.apply_generic_replacements() does not use the new utils.get_guild_owner() function because it's a synchronous best-effort function. In the future, I'll update the docs to specify that some fields may return "Unknown" for uncached members. --- src/components/utils.py | 35 ++++++++++++++++++++--- src/core/server_join_and_leave_manager.py | 7 ++++- src/features/admin_commands.py | 2 +- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/components/utils.py b/src/components/utils.py index 3fa86d3..882eca2 100644 --- a/src/components/utils.py +++ b/src/components/utils.py @@ -352,7 +352,12 @@ def apply_generic_replacements( replacements["@serverid"] = str(guild.id) replacements["@server"] = guild.name replacements["@membercount"] = str(guild.member_count) - replacements["@owner"] = guild.owner.display_name if guild.owner else "Unknown" + owner = guild.owner + if owner is None and guild.owner_id: + # Member cache miss (unchunked guild) — try the user cache (sync context) + from core.bot import get_bot + owner = get_bot().get_user(guild.owner_id) + replacements["@owner"] = owner.display_name if owner else "Unknown" # Add time and date replacements epoch = int(datetime.datetime.now().timestamp()) @@ -777,6 +782,25 @@ async def get_member(guild: nextcord.Guild, user_id: int, override_failed_cache: failed_member_fetches.add((guild.id, user_id)) return None +async def get_guild_owner(guild: nextcord.Guild) -> nextcord.Member | None: + """ + |coro| + Resolve a guild's owner. `guild.owner` is a member-cache lookup and is often None + while the guild is unchunked, so fall back to fetching by `guild.owner_id`. + + :param guild: The guild whose owner to resolve. + :type guild: nextcord.Guild + :return: The owner as a Member, or None if they can't be resolved. + :rtype: nextcord.Member | None + """ + if not guild or guild.owner_id is None: + return None + + if guild.owner is not None: + return guild.owner + + return await get_member(guild, guild.owner_id) + async def check_and_warn_if_channel_is_text_channel(interaction: Interaction) -> bool: """ |coro| @@ -815,7 +839,8 @@ async def user_has_config_permissions(interaction: Interaction, notify: bool = T Whether or not the interaction can continue. """ - if interaction.guild.owner == interaction.user: return True + # Compare by owner_id — guild.owner is a cache lookup and can be None while unchunked + if interaction.user.id == interaction.guild.owner_id: return True infinibot_mod_role = await get_infinibot_mod_role(interaction.guild) if infinibot_mod_role in interaction.user.roles: @@ -938,8 +963,10 @@ async def send_error_message_to_server_owner( logging.debug(f"Skipping sending error message to server owner (guild_id: {guild.id}) because it was already sent recently. ({guild}, {permission}, {message}, {administrator}, {channel}, {guild_permission})") return - member = guild.owner - if member == None: return + member = await get_guild_owner(guild) + if member == None: + logging.warning(f"Could not resolve owner of guild {guild.id}; owner error message not sent.") + return # Ensure that InfiniBot is still in this server if guild.id in recently_left_guilds: diff --git a/src/core/server_join_and_leave_manager.py b/src/core/server_join_and_leave_manager.py index 2e86b26..f94e7aa 100644 --- a/src/core/server_join_and_leave_manager.py +++ b/src/core/server_join_and_leave_manager.py @@ -200,7 +200,12 @@ async def _send_fallback_dm(guild: nextcord.Guild, view: NewServerJoinView) -> N ) fallback_embed.set_footer(text=str(guild.id)) - await guild.owner.send(embed=fallback_embed, view=ResendSetupMessageView()) + owner = await utils.get_guild_owner(guild) + if owner is None: + logging.warning(f"Could not resolve owner of '{guild.name}' (ID: {guild.id}); fallback DM not sent") + return + + await owner.send(embed=fallback_embed, view=ResendSetupMessageView()) logging.info(f"Fallback DM sent to owner of '{guild.name}' due to no accessible channels") except Exception as err: diff --git a/src/features/admin_commands.py b/src/features/admin_commands.py index 638069a..9814356 100644 --- a/src/features/admin_commands.py +++ b/src/features/admin_commands.py @@ -488,7 +488,7 @@ async def handle_info_command(message: nextcord.Message, message_parts: list): guild_name = guild.name if guild.name else "<>" guild_name = guild_name.replace("@", "@\u200b") # Prevent mentions in embed title - description = f"""Owner: {guild.owner} ({guild.owner.id}) + description = f"""Owner: {guild.owner if guild.owner else ""} ({guild.owner_id}) Members: {guild.member_count} **Time In Server**: {duration.days} days""" From 676195c21c5627eb350dfaba2fc19e20252168f1 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:48:49 -0600 Subject: [PATCH 007/109] Fix U2: embed placeholder/channel replacements now reach fields and footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nextcord's Embed.fields/.footer accessors return throwaway EmbedProxy objects, so the previous in-place proxy writes were silently discarded — placeholders like [age]/@mention in embed fields or the footer were never replaced. Replacements now rebuild fields via clear_fields()/add_field() and set the footer via set_footer(). Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a potential issue that was properly resolved **Human Review Notes:** This wasn't really an accessible issue since InfiniBot currently doesn't support footer or field customization for custom embeds. BUT, it might in the future, and this is a good change. --- src/components/utils.py | 69 ++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/src/components/utils.py b/src/components/utils.py index 882eca2..9abf41d 100644 --- a/src/components/utils.py +++ b/src/components/utils.py @@ -331,6 +331,27 @@ def apply_generic_replacements( :return: The modified embed with replaced placeholders. :rtype: nextcord.Embed """ + def _apply_text_transform(transform, include_url: bool = True) -> None: + # nextcord's .footer/.fields accessors return throwaway EmbedProxy objects, + # so writes must go through set_footer()/add_field() to reach the embed. + if embed.title: + embed.title = transform(embed.title) + if embed.description: + embed.description = transform(embed.description) + if include_url and embed.url: + embed.url = transform(embed.url) + if embed.footer and embed.footer.text: + embed.set_footer(text=transform(embed.footer.text), icon_url=embed.footer.icon_url) + if embed.fields: + fields = [(field.name, field.value, field.inline) for field in embed.fields] + embed.clear_fields() + for name, value, inline in fields: + embed.add_field( + name=transform(name) if name else name, + value=transform(value) if value else value, + inline=inline if inline is not None else True, + ) + if not skip_placeholder_replacement: # Defensive copy: custom_replacements defaults to a shared {} and callers # may reuse their dict, so never mutate the passed-in object in place. @@ -368,19 +389,12 @@ def apply_generic_replacements( replacements["@date"] = f"" # Replace placeholders with values - for key, value in replacements.items(): - if embed.title: - embed.title = embed.title.replace(key, value) - if embed.description: - embed.description = embed.description.replace(key, value) - if embed.footer and embed.footer.text: - embed.footer.text = embed.footer.text.replace(key, value) - if embed.url: - embed.url = embed.url.replace(key, value) - - for field in embed.fields: - field.name = field.name.replace(key, value) - field.value = field.value.replace(key, value) + def replace_placeholders(text: str) -> str: + for key, value in replacements.items(): + text = text.replace(key, value) + return text + + _apply_text_transform(replace_placeholders) if not skip_channel_replacement: # Optimization: Skip channel replacement if no "#" in the embed @@ -401,23 +415,20 @@ def apply_generic_replacements( # Note: Voice channels and stage channels typically cannot be mentioned in the same way # as text channels, so we exclude them from channel replacement - - for channel in all_channels: + + channel_replacements = { + f"#{channel.name}": channel.mention + for channel in all_channels # Ensure the channel has a mention attribute before using it - if hasattr(channel, 'mention') and hasattr(channel, 'name'): - channel_placeholder = f"#{channel.name}" - channel_mention = channel.mention - - if embed.title: - embed.title = embed.title.replace(channel_placeholder, channel_mention) - if embed.description: - embed.description = embed.description.replace(channel_placeholder, channel_mention) - if embed.footer and embed.footer.text: - embed.footer.text = embed.footer.text.replace(channel_placeholder, channel_mention) - - for field in embed.fields: - field.name = field.name.replace(channel_placeholder, channel_mention) - field.value = field.value.replace(channel_placeholder, channel_mention) + if hasattr(channel, 'mention') and hasattr(channel, 'name') + } + + def replace_channels(text: str) -> str: + for channel_placeholder, channel_mention in channel_replacements.items(): + text = text.replace(channel_placeholder, channel_mention) + return text + + _apply_text_transform(replace_channels, include_url=False) return embed From 980007b2b0a342c5026e71ebb03449f9433c8dc9 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:49:19 -0600 Subject: [PATCH 008/109] Fix L4: entry_is_fresh compares a real time delta instead of calendar fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field-wise comparison rejected entries seconds old across an hour boundary (13:59 vs 14:01) and accepted entries up to 59 minutes old within the same hour (also mis-accepting Discord's stacked audit entries that keep their original created_at). Now: abs(now - created_at) <= 5 minutes. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Well that was embarrassing... Mb. Good change. --- src/features/action_logging.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/features/action_logging.py b/src/features/action_logging.py index 4d436fe..b3a04b5 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -102,10 +102,10 @@ def entry_is_fresh(entry: nextcord.AuditLogEntry) -> bool: :return: A boolean indicating whether the entry is considered fresh. :rtype: bool """ - return (entry.created_at.month == datetime.datetime.now(datetime.timezone.utc).month - and entry.created_at.day == datetime.datetime.now(datetime.timezone.utc).day - and entry.created_at.hour == datetime.datetime.now(datetime.timezone.utc).hour - and ((datetime.datetime.now(datetime.timezone.utc).minute - entry.created_at.minute) <= 5)) + # Use a real time delta — comparing calendar fields breaks at hour boundaries and + # lets anything from earlier in the same hour count as "fresh". + now = datetime.datetime.now(datetime.timezone.utc) + return abs((now - entry.created_at).total_seconds()) <= 5 * 60 # File Computation From 05080fab54726b3cd312c87da257a6f9997d7c20 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:52:04 -0600 Subject: [PATCH 009/109] Fix L2: member-update logging filters audit entries by action and validates the target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit log_member_update previously grabbed the single latest audit entry of any type and attributed nickname/role/timeout changes to its user — crediting the wrong actor and logging InfiniBot's own role grants as admin actions. Each change type now looks up the newest matching-action entry targeting the updated member; when none is found (or it isn't fresh), the log says 'Someone' instead of guessing. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/action_logging.py | 67 +++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/src/features/action_logging.py b/src/features/action_logging.py index b3a04b5..8aa2050 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -484,13 +484,16 @@ async def log_nickname_change(before: nextcord.Member, after: nextcord.Member, e logging.error("Log channel is None. Cannot log nickname change.") return - user = entry.user - fresh_audit_log = entry_is_fresh(entry) + fresh_audit_log = entry is not None and entry_is_fresh(entry) + user = entry.user if fresh_audit_log else None # Create an embed for the nickname change event embed = nextcord.Embed( title="Nickname Changed", - description=f"{user.mention} changed {after.mention}'s nickname.", + description=( + f"{user.mention} changed {after.mention}'s nickname." + if user else f"{after.mention}'s nickname was changed." + ), color=nextcord.Color.blue(), timestamp=datetime.datetime.now() ) @@ -671,11 +674,12 @@ def __repr__(self): if guild.premium_subscriber_role.id in deleted_roles.ids(): deleted_roles.remove(guild.premium_subscriber_role.id) - # Remove roles that were just logged + # Remove roles that were just logged (dedup key: actor if known, else the target) + dedup_actor_id = entry.user.id if entry is not None and entry.user else after.id added_roles_ids = [role.id for role in added_roles.roles] deleted_roles_ids = [role.id for role in deleted_roles.roles] for role_info in fresh_role_updates: - if role_info[0] == entry.user.id: + if role_info[0] == dedup_actor_id: if role_info[2] == "added" and role_info[1] in added_roles_ids: added_roles.remove(role_info[1]) elif role_info[2] == "removed" and role_info[1] in deleted_roles_ids: @@ -683,15 +687,14 @@ def __repr__(self): if len(added_roles) == 0 and len(deleted_roles) == 0: return - + # Add to fresh role updates - for role in added_roles: fresh_role_updates.add((entry.user.id, role.id, "added")) - for role in deleted_roles: fresh_role_updates.add((entry.user.id, role.id, "removed")) + for role in added_roles: fresh_role_updates.add((dedup_actor_id, role.id, "added")) + for role in deleted_roles: fresh_role_updates.add((dedup_actor_id, role.id, "removed")) - user = entry.user - fresh_audit_log = entry_is_fresh(entry) + fresh_audit_log = entry is not None and entry_is_fresh(entry) if fresh_audit_log: - description = f"{user.mention} modified {after.mention}'s roles." + description = f"{entry.user.mention} modified {after.mention}'s roles." else: description = f"Someone modified {after.mention}'s roles." @@ -729,9 +732,10 @@ async def log_timeout_change(before: nextcord.Member, after: nextcord.Member, en logging.error("Log channel is None. Cannot log timeout change.") return - user = entry.user - fresh_audit_log = entry_is_fresh(entry) - + fresh_audit_log = entry is not None and entry_is_fresh(entry) + user = entry.user if fresh_audit_log else None + actor = user.mention if user else "Someone" + if before.communication_disabled_until is None: # Member was not previously timed out, calculate the timeout duration timeout_time: datetime.timedelta = after.communication_disabled_until - datetime.datetime.now(datetime.timezone.utc) @@ -745,7 +749,7 @@ async def log_timeout_change(before: nextcord.Member, after: nextcord.Member, en # Create an embed for the timeout event embed = nextcord.Embed( title="Member Timed-Out", - description=f"{user.mention} timed out {after.mention} for about {timeout_time_ui_text}", + description=f"{actor} timed out {after.mention} for about {timeout_time_ui_text}", color=nextcord.Color.orange(), timestamp=datetime.datetime.now() ) @@ -761,7 +765,7 @@ async def log_timeout_change(before: nextcord.Member, after: nextcord.Member, en # Timeout was revoked manually embed = nextcord.Embed( title="Timeout Revoked", - description=f"{user.mention} revoked {after.mention}'s timeout", + description=f"{actor} revoked {after.mention}'s timeout", color=nextcord.Color.orange(), timestamp=datetime.datetime.now() ) @@ -864,29 +868,32 @@ async def log_member_update(before: nextcord.Member, after: nextcord.Member) -> # Wait 1 second for the audit log to catch up await asyncio.sleep(1) - # Get audit log entry - try: - entry = await anext( - guild.audit_logs(limit=1), - None - ) - except nextcord.errors.HTTPException: - return # No audit log entry found - - if entry == None: - # No audit log entry - return + async def find_audit_entry(action: AuditLogAction) -> nextcord.AuditLogEntry | None: + """Newest audit entry of the given action type that targets this member. + An unfiltered latest-entry lookup attributes changes to whoever performed + the most recent unrelated action (including InfiniBot's own role grants).""" + try: + async for _entry in guild.audit_logs(limit=5, action=action): + target = getattr(_entry, "target", None) + if target is not None and getattr(target, "id", None) == after.id: + return _entry + except nextcord.errors.HTTPException: + pass + return None # Nickname change -------------------------------------------------------------- if before.nick != after.nick: + entry = await find_audit_entry(AuditLogAction.member_update) await log_nickname_change(before, after, entry, log_channel) - # Roles change -------------------------------------------------------------- + # Roles change -------------------------------------------------------------- if before.roles != after.roles: + entry = await find_audit_entry(AuditLogAction.member_role_update) await log_role_change(before, after, entry, guild, log_channel) - + # Timeout change -------------------------------------------------------------- if before.communication_disabled_until != after.communication_disabled_until: + entry = await find_audit_entry(AuditLogAction.member_update) await log_timeout_change(before, after, entry, log_channel) async def log_member_removal(guild: nextcord.Guild, member: nextcord.abc.User) -> None: From 1777692c5e5ca3db1333f09ed4707ef122ce4a66 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:52:21 -0600 Subject: [PATCH 010/109] Fix L3: member-removal logging only matches kick/ban audit entries targeting the leaver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit log_member_removal accepted any fresh kick/ban in the last 5 audit entries, so a member leaving voluntarily shortly after someone else was kicked or banned was logged as kicked/banned themselves. Now requires entry.target.id == member.id. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/action_logging.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/features/action_logging.py b/src/features/action_logging.py index 8aa2050..630b0bf 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -922,8 +922,11 @@ async def log_member_removal(guild: nextcord.Guild, member: nextcord.abc.User) - entry = None async for _entry in entries: if _entry.action == AuditLogAction.kick or _entry.action == AuditLogAction.ban: - entry = _entry - break + # Only accept entries that target the member who actually left + target = getattr(_entry, "target", None) + if target is not None and getattr(target, "id", None) == member.id: + entry = _entry + break except nextcord.Forbidden: await utils.send_error_message_to_server_owner(guild, "View Audit Log", guild_permission=True) return From 339da36c34ae5b1cd2b203760ee0f8ec49198414 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:52:49 -0600 Subject: [PATCH 011/109] Fix L5: edit log sends raw user content with AllowedMentions.none() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long edited messages (1025-2000 chars) are relayed verbatim into the log channel, and the bot allows @everyone mentions globally — so editing a long message containing @everyone/@here/role mentions made InfiniBot ping them in the log channel. Mention parsing is now disabled on those sends. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/action_logging.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/features/action_logging.py b/src/features/action_logging.py index 630b0bf..3d5d831 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -300,7 +300,12 @@ async def trigger_edit_log(guild: nextcord.Guild, original_message: nextcord.Mes completed_content_tasks = [] for task in content_tasks: - content_message = await log_channel.send(content = task[2], reference = message) + # task[2] is raw user-authored content — never let it ping @everyone/roles/users + content_message = await log_channel.send( + content = task[2], + reference = message, + allowed_mentions = nextcord.AllowedMentions.none() + ) completed_content_tasks.append([task[0], task[1], content_message.jump_url]) completed_embed_tasks = [] From b6ca5b36ff5bd5666fff23adbad49fb704495fa6 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:53:09 -0600 Subject: [PATCH 012/109] Fix S1: await send_error_message_to_server_owner in default-roles Forbidden handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coroutine was created but never awaited, so owners were never told why default roles failed (and a 'never awaited' warning was emitted). Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Woops. --- src/features/default_roles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/default_roles.py b/src/features/default_roles.py index 41aae40..eab7829 100644 --- a/src/features/default_roles.py +++ b/src/features/default_roles.py @@ -35,7 +35,7 @@ async def add_roles_for_new_member(member: nextcord.Member): logging.warning( f"Cannot add roles to member {member.id} in guild {member.guild.name} ({member.guild.id}). Warning owner..." ) - send_error_message_to_server_owner( + await send_error_message_to_server_owner( member.guild, "Manage Roles", message=( From 64dd35c00b58612f8724d1f4454091c8cb79cdcb Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:53:39 -0600 Subject: [PATCH 013/109] Fix S2: DM clear-last no longer calls the nonexistent DMChannel.purge() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nextcord's DMChannel has no purge(), so the command raised AttributeError (swallowed by LogIfFailure) every time and had never worked. It now walks recent DM history and deletes the bot's most recent message. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Verified to be an issue in production. Can't believe I did that. --- src/features/dm_commands.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/dm_commands.py b/src/features/dm_commands.py index 62f46e6..3fa1b10 100644 --- a/src/features/dm_commands.py +++ b/src/features/dm_commands.py @@ -18,7 +18,11 @@ async def check_and_run_dm_commands(bot: nextcord.Client, message: nextcord.Mess if message.author.id == bot.application_id: return if message.content.lower() == "clear-last": # Clear last message - await message.channel.purge(limit=1, check=lambda m: m.author.id == bot.application_id) + # DMChannel has no purge(); find the bot's most recent message and delete it + async for dm_message in message.channel.history(limit=50): + if dm_message.author.id == bot.application_id: + await dm_message.delete() + break embed = nextcord.Embed(title = "Cleared Last Message", description="Last message has been cleared.", color = nextcord.Color.green()) await message.channel.send(embed = embed, delete_after = 3) From c04331d880c309b87b98886d48bb13b8bd3cbede Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:54:05 -0600 Subject: [PATCH 014/109] Fix S4: role-message selects skip roles deleted from the guild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit guild.get_role() returns None for deleted roles; passing None to add_roles/remove_roles raised AttributeError (only Forbidden was caught), killing the button instead of applying the remaining valid roles. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/role_messages.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/features/role_messages.py b/src/features/role_messages.py index 793a4e5..cd7b5ea 100644 --- a/src/features/role_messages.py +++ b/src/features/role_messages.py @@ -75,10 +75,13 @@ async def select_callback(self, interaction: Interaction): roles_add = [] roles_remove = [] for role in self.available_roles: + role_obj = interaction.guild.get_role(int(role)) + if role_obj is None: + continue # Role was deleted from the guild; skip so add/remove_roles doesn't get None if role in selected_roles: - roles_add.append(interaction.guild.get_role(int(role))) + roles_add.append(role_obj) else: - roles_remove.append(interaction.guild.get_role(int(role))) + roles_remove.append(role_obj) error = False try: @@ -163,10 +166,13 @@ async def select_callback(self, interaction: Interaction): roles_add = [] roles_remove = [] for role in self.available_roles: + role_obj = interaction.guild.get_role(int(role)) + if role_obj is None: + continue # Role was deleted from the guild; skip so add/remove_roles doesn't get None if role in selected_roles: - roles_add.append(interaction.guild.get_role(int(role))) + roles_add.append(role_obj) else: - roles_remove.append(interaction.guild.get_role(int(role))) + roles_remove.append(role_obj) error = False try: From 4dcaa319af0c66c3b950656e0ae1c9dce97bc33d Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:55:13 -0600 Subject: [PATCH 015/109] Fix M1: nickname-profanity self-edit detection uses a workable freshness window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old check used timedelta.seconds (wraps at 24h, so day-old entries could read as fresh) with a 1-second window that was always exceeded by the 1-second audit-log sleep already performed upstream — so 'they edited their own nick' was essentially never detected. Now uses total_seconds() with a 10s window and validates the audit entry targets the member. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/moderation.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/features/moderation.py b/src/features/moderation.py index 1965a30..fcaf783 100644 --- a/src/features/moderation.py +++ b/src/features/moderation.py @@ -167,12 +167,18 @@ async def check_and_punish_nickname_for_profanity(bot: nextcord.Client, guild: n if not guild.me.guild_permissions.view_audit_log: await utils.send_error_message_to_server_owner(guild, "View Audit Log", guild_permission=True) return - entry = await anext(guild.audit_logs(limit=1, action=nextcord.AuditLogAction.member_update), None) + # Find a recent member_update entry targeting this member + entry = None + async for _entry in guild.audit_logs(limit=5, action=nextcord.AuditLogAction.member_update): + target = getattr(_entry, "target", None) + if target is not None and getattr(target, "id", None) == member.id: + entry = _entry + break if entry == None: return - # Confirm that this entry is fresh (within 1 second) - entry_is_fresh = True - if (datetime.datetime.now(datetime.timezone.utc) - entry.created_at).seconds > 1: entry_is_fresh = False + # Confirm that this entry is fresh. Use total_seconds() (.seconds wraps at 24h) + # and a window that covers the ~1s audit-log sleep upstream in log_member_update. + entry_is_fresh = (datetime.datetime.now(datetime.timezone.utc) - entry.created_at).total_seconds() <= 10 user = entry.user if entry_is_fresh and user.id == member.id: From 783a51811621690b34b89f39116b04b6dbf6f228 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:55:34 -0600 Subject: [PATCH 016/109] Fix L6: don't force-chunk the whole guild on the first member update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit log_member_update chunked any unchunked guild before logging, re- creating the gateway member-chunk flood that disabling startup chunking was meant to remove. before/after are already populated (the event only fires for cached members), and the audit-log lookup doesn't need the member cache. Co-Authored-By: Claude Fable 5 ✗ Incomplete change; see notes below: **Human Review Notes:** The paradigm of this change is correct, but it actually breaks all member-update events since this function requires a cached user to work. After Claude finishes its commits, I'm going to change the on_member_update() function into on_raw_member_update(), correcting these issues. --- src/features/action_logging.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/features/action_logging.py b/src/features/action_logging.py index 3d5d831..fde0575 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -861,8 +861,9 @@ async def log_member_update(before: nextcord.Member, after: nextcord.Member) -> log_channel = await get_logging_channel(guild) if not log_channel: return - if not guild.chunked: - await guild.chunk() + # Note: no guild.chunk() here — on_member_update only fires for members nextcord + # already cached, and force-chunking large guilds on the first member update is + # exactly the gateway flood that disabling startup chunking was meant to avoid. if not guild.me: return From 61a551ece1228f8cdde5567917a8945ec63ccfaf Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:57:07 -0600 Subject: [PATCH 017/109] =?UTF-8?q?Fix=20O2/O3:=20options-menu=20ban=20but?= =?UTF-8?q?ton=20=E2=80=94=20no=20per-open=20REST=20calls,=20no=20User.top?= =?UTF-8?q?=5Frole=20crash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O3: the hierarchy check dereferenced relevant_member.top_role, which raises AttributeError when the message author is an uncached nextcord.User; compare against the fetched Member instead. Button loads are now also isolated so one failing button can't abort the whole menu build. O2: menu opens no longer pay two REST round-trips — the member fetch is skipped when a Member object is already in hand, and the already-banned check (fetch_ban) is deferred to the button press, which shows an 'Already Banned' notice instead. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a potential issue that was properly resolved --- src/features/options_menu/ban_button.py | 47 +++++++++++----------- src/features/options_menu/entrypoint_ui.py | 7 +++- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/features/options_menu/ban_button.py b/src/features/options_menu/ban_button.py index 9f1d08b..35cb291 100644 --- a/src/features/options_menu/ban_button.py +++ b/src/features/options_menu/ban_button.py @@ -20,10 +20,11 @@ async def load(self, interaction: Interaction, data: dict): self.relevant_member = self.message.author if self.message else self.member - self.relevant_member_fetched = await get_member(interaction.guild, self.relevant_member.id) - - - # Confirm that the member is not already banned + # Avoid a REST fetch when we already have a Member object + if isinstance(self.relevant_member, nextcord.Member): + self.relevant_member_fetched = self.relevant_member + else: + self.relevant_member_fetched = await get_member(interaction.guild, self.relevant_member.id) enabled = ( interaction.user.guild_permissions.ban_members @@ -33,31 +34,15 @@ async def load(self, interaction: Interaction, data: dict): self.relevant_member_fetched is None or ( interaction.guild.me.id != self.relevant_member.id - and interaction.guild.me.top_role.position > self.relevant_member.top_role.position + and interaction.guild.me.top_role.position > self.relevant_member_fetched.top_role.position ) ) ) - - if enabled: - - # Ensure that the member is not already banned - if interaction.guild.me.guild_permissions.ban_members: - try: - ban_entry = await interaction.guild.fetch_ban(self.relevant_member) - except nextcord.NotFound: - # If the member is not banned, we can add the button - pass - except nextcord.Forbidden: - # If the bot does not have permission to fetch bans, we can still add the button - pass - else: - # If the member is already banned, we do not add the button - if ban_entry: - return False + if enabled: self.outer.add_item(self) return True - + return False class BanView(CustomView): @@ -80,6 +65,22 @@ async def setup(self, interaction: Interaction): await disabled_feature_override(self, interaction) return + # Deferred from BanButton.load so the menu build doesn't pay this REST call + if interaction.guild.me.guild_permissions.ban_members: + try: + ban_entry = await interaction.guild.fetch_ban(self.parent.relevant_member) + except (nextcord.NotFound, nextcord.Forbidden): + ban_entry = None + + if ban_entry: + embed = nextcord.Embed( + title="Already Banned", + description=f"`{self.parent.relevant_member}` is already banned from this server.", + color=nextcord.Color.orange() + ) + await interaction.response.edit_message(embed=embed, view=None) + return + if self.parent.relevant_member_fetched: # If the member is in the server description = f"Are you sure that you want to ban {self.parent.relevant_member.mention}?\n\nThis means that they will be kicked and not be able to re-join this server unless they are un-baned.\n\nNo messages will be deleted." diff --git a/src/features/options_menu/entrypoint_ui.py b/src/features/options_menu/entrypoint_ui.py index 5a91b0a..25ddbbe 100644 --- a/src/features/options_menu/entrypoint_ui.py +++ b/src/features/options_menu/entrypoint_ui.py @@ -62,9 +62,12 @@ async def setup(self, interaction: Interaction): "member": self.relevant_object }) - # Load buttons + # Load buttons (isolated so that one failing button doesn't kill the whole menu) for button in self.buttons: - await button.load(interaction, data) + try: + await button.load(interaction, data) + except Exception as e: + logging.error(f"Options button {type(button).__name__} failed to load: {e}", exc_info=True) if len(self.children) == 0: await self.show_error(interaction, f"Hmmm. You don't have any options for this {self.relevant_object_type.lower()}.") From c9396723d4a458add66dc2268990b9e5a6fb5409 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:57:39 -0600 Subject: [PATCH 018/109] Harden B7: typed_property getter tolerates malformed/legacy stored values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit json.loads + packet['status'] raised on any non-JSON stored value, propagating out of property reads (e.g. birthdays_profile.runtime inside the birthday loop, aborting that guild's run). No live write path produces such values today (verified PLAUSIBLE-UNCERTAIN), but the getter now falls back to the raw number or UNSET/None instead of raising. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a potential issue that was properly resolved **Human Review Notes:** I'm not sure this is a real bug, since I don't think my prod db has any legacy values that would trigger this. But, regardless, this is a good change in case I do. --- src/core/db_manager.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/core/db_manager.py b/src/core/db_manager.py index 2bf9920..4a367ac 100644 --- a/src/core/db_manager.py +++ b/src/core/db_manager.py @@ -441,10 +441,24 @@ def getter_modifier(value): else: return 0 # value = {"status": ('SET'|'UNSET'|'NONE'), "value": ______} - packet = json.loads(str(value)) - if packet['status'] == 'UNSET': + try: + packet = json.loads(str(value)) + status = packet['status'] + except (ValueError, TypeError, KeyError): + # Non-JSON / malformed stored value (e.g. legacy schema). Don't let one + # bad column abort the caller. + logging.warning(f"typed_property '{property_name}' holds a malformed value {value!r}; treating as unset/raw.") + # Ints and floats in this fallback state can be assumed as raw values. + # String values are ambiguous, so we treat them as unset. This is a + # best-effort approach to avoid breaking the caller. + if isinstance(value, (int, float)): + return value + if accept_unset_value: return UNSET_VALUE + if accept_none_value: return None + return 0 + if status == 'UNSET': return UNSET_VALUE - if packet['status'] == 'NONE': + if status == 'NONE': return None return packet['value'] From b7fac992cadb8461c9a09d8182e472a1a32d058a Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 12:59:40 -0600 Subject: [PATCH 019/109] Fix B11: batch member resolution in daily maintenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit daily_leveling_maintenance and daily_moderation_maintenance fetched each DB-listed member individually (REST call per uncached member per guild at 3am). They now resolve members via a new utils.get_members_batch() — member cache first, then gateway query_members in batches of 100 — and skip the run (rather than mass-deleting rows) if the gateway query fails. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Claude wanted to change src/features/leveling.py:251 to reference the pre-decremented points value. Technically, other db cleanup code would resolve that, but I believe that would have been a regression, for the reason outlined in the comment that I added. --- src/components/utils.py | 34 ++++++++++++++++++++++++++++++++++ src/features/leveling.py | 15 +++++++++++++-- src/features/moderation.py | 12 ++++++++++-- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/components/utils.py b/src/components/utils.py index 9abf41d..19647ec 100644 --- a/src/components/utils.py +++ b/src/components/utils.py @@ -812,6 +812,40 @@ async def get_guild_owner(guild: nextcord.Guild) -> nextcord.Member | None: return await get_member(guild, guild.owner_id) +async def get_members_batch(guild: nextcord.Guild, user_ids: list[int]) -> dict[int, nextcord.Member] | None: + """ + |coro| + Resolve many members at once: member cache first, then gateway member queries in + batches of 100 — instead of one REST fetch per uncached id (N+1). + + :param guild: The guild to resolve members in. + :type guild: nextcord.Guild + :param user_ids: The user IDs to resolve. + :type user_ids: list[int] + :return: user_id -> Member for every id still in the guild (absent ids left the + guild), or None if the gateway query failed — callers must NOT interpret a + failure as "these members left". + :rtype: dict[int, nextcord.Member] | None + """ + members_by_id: dict[int, nextcord.Member] = {} + uncached_ids: list[int] = [] + for user_id in user_ids: + member = guild.get_member(user_id) + if member is not None: + members_by_id[user_id] = member + else: + uncached_ids.append(user_id) + + try: + for i in range(0, len(uncached_ids), 100): + found = await guild.query_members(user_ids=uncached_ids[i:i + 100]) + members_by_id.update({member.id: member for member in found}) + except Exception as e: + logging.warning(f"Batched member query failed for guild {guild.id}: {e}") + return None + + return members_by_id + async def check_and_warn_if_channel_is_text_channel(interaction: Interaction) -> bool: """ |coro| diff --git a/src/features/leveling.py b/src/features/leveling.py index 164a8b9..64ce38a 100644 --- a/src/features/leveling.py +++ b/src/features/leveling.py @@ -218,10 +218,18 @@ async def daily_leveling_maintenance(bot: nextcord.Client, guild: nextcord.Guild if server.leveling_profile.active == False: return if server.leveling_profile.points_lost_per_day == 0: return + # Resolve all leveled members up front (cache + batched gateway queries) + # instead of paying a REST fetch per row. + level_infos = list(server.member_levels) + members_by_id = await utils.get_members_batch(guild, [info.member_id for info in level_infos]) + if members_by_id is None: + logging.warning(f"Could not resolve members for leveling maintenance in guild {guild.id}; skipping this run.") + return + # Go through each member and edit - for member_level_info in server.member_levels: + for member_level_info in level_infos: try: - member = await utils.get_member(guild, member_level_info.member_id) + member = members_by_id.get(member_level_info.member_id) if member == None: logging.warning(f"Member {member_level_info.member_id} not found in guild {guild.id}. Removing from member levels.") # Remove the member if they are not found @@ -240,6 +248,9 @@ async def daily_leveling_maintenance(bot: nextcord.Client, guild: nextcord.Guild await process_level_change(guild, member) # Remove the member if they have no points + # This is purely a database optimization to reduce the number of rows in + # the member_levels table. Members without records are assumed to have 0 + # points elsewhere in the codebase. if member_level_info.points == 0: server.member_levels.delete(member_level_info.member_id) diff --git a/src/features/moderation.py b/src/features/moderation.py index fcaf783..a0c35a6 100644 --- a/src/features/moderation.py +++ b/src/features/moderation.py @@ -958,10 +958,18 @@ async def daily_moderation_maintenance(bot: nextcord.Client, guild: nextcord.Gui if server.profanity_moderation_profile.strike_system_active == False: return if server.profanity_moderation_profile.strike_expiring_active == False: return + # Resolve all striked members up front (cache + batched gateway queries) + # instead of paying a REST fetch per row. + strike_infos = list(server.moderation_strikes) + members_by_id = await utils.get_members_batch(guild, [info.member_id for info in strike_infos]) + if members_by_id is None: + logging.warning(f"Could not resolve members for moderation maintenance in guild {guild.id}; skipping this run.") + return + # Go through each member and edit - for member_strike_info in server.moderation_strikes: + for member_strike_info in strike_infos: try: - member = await utils.get_member(guild, member_strike_info.member_id) + member = members_by_id.get(member_strike_info.member_id) if member is None: continue From 9e10cbce8c443fc7f8043eb5246fd905feed9707 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 13:01:58 -0600 Subject: [PATCH 020/109] Fix B13/B14: daily DB maintenance off the event loop; guard orphan cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B13: the WAL checkpoint, optimize_database (which time.sleep()s between tables), and message-log cleanup ran synchronously on the event loop at 09:00 UTC, freezing all interactions for the duration. They now run via asyncio.to_thread. The orphaned-guild phase stays on the loop because it depends on a per-connection SQLite TEMP table across serial queries. B14: orphaned-guild cleanup treated any guild missing from bot.guilds as orphaned; during shard reconnects/outages that set is incomplete, so it could wipe live servers' data. The phase is now skipped unless the bot is fully ready, has guilds, and none are flagged unavailable. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/core/db_manager.py | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/core/db_manager.py b/src/core/db_manager.py index 4a367ac..6773558 100644 --- a/src/core/db_manager.py +++ b/src/core/db_manager.py @@ -1137,20 +1137,24 @@ async def daily_database_maintenance(bot: nextcord.Client): logging.warning("SKIPPING: Bot load status prevents maintenance") return + # All blocking phases run in a worker thread (asyncio.to_thread) — VACUUM, + # checkpoints, and optimize's time.sleep throttling would otherwise freeze + # the event loop (and every interaction) for the duration. + # WAL checkpoint phase ----------------------------------------------------------------------------------- logging.info("Starting WAL checkpoint...") checkpoint_start = datetime.datetime.now(datetime.timezone.utc) - - get_database().checkpoint() - + + await asyncio.to_thread(get_database().checkpoint) + checkpoint_duration = datetime.datetime.now(datetime.timezone.utc) - checkpoint_start logging.info(f"WAL checkpoint completed in {checkpoint_duration.total_seconds():.2f}s") # Database optimization phase ----------------------------------------------------------------------------------- logging.info("Starting database optimization...") optimize_start = datetime.datetime.now(datetime.timezone.utc) - - total_deleted += get_database().optimize_database(throttle=True) + + total_deleted += await asyncio.to_thread(get_database().optimize_database, throttle=True) optimize_duration = datetime.datetime.now(datetime.timezone.utc) - optimize_start logging.info(f"Optimization completed in {optimize_duration.total_seconds():.2f}s") @@ -1161,20 +1165,29 @@ async def daily_database_maintenance(bot: nextcord.Client): message_log_cleanup_start = datetime.datetime.now(datetime.timezone.utc) from config.messages import stored_messages - total_deleted += stored_messages.cleanup_db() + total_deleted += await asyncio.to_thread(stored_messages.cleanup_db) message_log_cleanup_duration = datetime.datetime.now(datetime.timezone.utc) - message_log_cleanup_start logging.info(f"Message log cleanup completed in {message_log_cleanup_duration.total_seconds():.2f}s") # Orphaned Guild Entries cleanup phase ----------------------------------------------------------------------------------- - logging.info(f"Scanning tables for orphaned guild entries...") - orphaned_guild_entries_cleanup_start = datetime.datetime.now(datetime.timezone.utc) + # Safety: bot.guilds is incomplete while shards are (re)connecting or guilds + # are flagged unavailable — running the cleanup then would treat live servers + # as orphaned and delete their data. + if not bot.is_ready() or len(bot.guilds) == 0 or any(guild.unavailable for guild in bot.guilds): + logging.warning("SKIPPING orphaned-guild cleanup: shard(s) not ready or one or more guilds unavailable.") + else: + logging.info(f"Scanning tables for orphaned guild entries...") + orphaned_guild_entries_cleanup_start = datetime.datetime.now(datetime.timezone.utc) - total_deleted += cleanup_orphaned_guild_entries(set([int(guild.id) for guild in bot.guilds]), get_database()) + # Runs on the loop (not to_thread): it relies on a per-connection SQLite + # TEMP table across queries, which needs serial use of the pooled connection. + valid_guild_ids = set([int(guild.id) for guild in bot.guilds]) + total_deleted += cleanup_orphaned_guild_entries(valid_guild_ids, get_database()) - orphaned_guild_entries_cleanup_duration = datetime.datetime.now(datetime.timezone.utc) - orphaned_guild_entries_cleanup_start - logging.info(f"Orphaned guild entries cleanup completed in {orphaned_guild_entries_cleanup_duration.total_seconds():.2f}s") + orphaned_guild_entries_cleanup_duration = datetime.datetime.now(datetime.timezone.utc) - orphaned_guild_entries_cleanup_start + logging.info(f"Orphaned guild entries cleanup completed in {orphaned_guild_entries_cleanup_duration.total_seconds():.2f}s") # Final report total_duration = datetime.datetime.now(datetime.timezone.utc) - start_time From 6ae7ea4f4bd7631be8a09f3662ca91f79a2cd2fc Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 13:04:37 -0600 Subject: [PATCH 021/109] Fix C1/C4: config singletons + no whole-file deep copies on read paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every get_configs()/get_global_kill_status() call constructed a fresh GlobalSetting, whose init re-synced every leaf key — and each membership check/read deep-copied the entire parsed JSON file (multiple times per lookup via __contains__ + __getitem__). On the per-message and per-guild hot paths this was a deep-copy storm. - JSONFile._get_data() now returns the cached dict for read-only paths and deep-copies only for write paths (mutable=True); __getitem__ copies just the returned value so callers still can't mutate the cache. - get_configs()/get_global_kill_status() return module-level singletons, keyed by file_manager.base_path so tests that repoint the config directory get fresh instances. 10k hot-path reads: ~0.03s. Full test suite passes. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Took me a minute to understand, but after tracing the code paths, I agree that this works and is a good change. --- src/config/file_manager.py | 49 +++++++++++++++++++---------------- src/config/global_settings.py | 19 ++++++++++++-- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/src/config/file_manager.py b/src/config/file_manager.py index 06c9669..385e044 100644 --- a/src/config/file_manager.py +++ b/src/config/file_manager.py @@ -102,24 +102,25 @@ def ensure_existence(self) -> None: except OSError as e2: logging.error(f"Failed to remove malformed file {self.path}: {e2}") - def _get_data(self) -> dict: + def _get_data(self, mutable: bool = False) -> dict: """ Retrieves the data from the file. - :param self: The instance of the JSONFile object. - :type self: JSONFile + :param mutable: If True, return a deep copy the caller may mutate (for write + paths). If False (default), return the cached dict directly — callers + must treat it as read-only. Read paths are hot (per-message feature + checks), so they must not deep-copy the whole file. + :type mutable: bool :return: The data from the file. :rtype: dict """ - if self.path in self._cache: - return copy.deepcopy(self._cache[self.path]) # Return a copy to prevent external modifications - - self.ensure_existence() + if self.path not in self._cache: + self.ensure_existence() + with open(self.path, "r") as file: + self._cache[self.path] = json.loads(file.read()) - with open(self.path, "r") as file: - data = json.loads(file.read()) - self._cache[self.path] = data - return copy.deepcopy(data) # Return a copy to prevent external modifications + data = self._cache[self.path] + return copy.deepcopy(data) if mutable else data def _set_data(self, data: dict) -> None: """ @@ -224,14 +225,16 @@ def __getitem__(self, key: str) -> any: """ data = self._get_data() - if key not in self: + try: + if isinstance(key, str) and '.' in key: + value = self._get_nested(data, key.split('.')) + else: + value = data[key] + except KeyError: raise KeyError(f"{key} does not exist in {self.file_name}.") - if isinstance(key, str) and '.' in key: - keys = key.split('.') - return self._get_nested(data, keys) - else: - return data[key] + # Deep-copy only the returned value so callers can't mutate the cache + return copy.deepcopy(value) def __setitem__(self, key: str, value: any) -> None: """ @@ -244,7 +247,7 @@ def __setitem__(self, key: str, value: any) -> None: :return: None :rtype: None """ - data = self._get_data() + data = self._get_data(mutable=True) if isinstance(key, str) and '.' in key: keys = key.split('.') self._update_nested(data, keys, value) @@ -263,11 +266,11 @@ def __delitem__(self, key: str) -> None: :rtype: None :raises KeyError: If the key does not exist in the JSON file. """ - data = self._get_data() + data = self._get_data(mutable=True) if key not in self: raise KeyError(f"{key} does not exist in {self.file_name}.") - + if isinstance(key, str) and '.' in key: keys = key.split('.') # Navigate to the parent and delete the final key @@ -289,7 +292,7 @@ def __iter__(self) -> iter: :rtype: iter """ data = self._get_data() - return iter(data) + return iter(list(data)) # snapshot the keys so writes during iteration can't break it def __str__(self) -> str: """ @@ -340,7 +343,7 @@ def add_variable(self, key, value) -> None: :return: None :rtype: None """ - data = self._get_data() + data = self._get_data(mutable=True) if key in self: raise KeyError(f"{key} already exists in {self.file_name}. Use __setitem__() instead. Implementation: JSONFile[key] = value") @@ -398,5 +401,5 @@ def items(self) -> iter: :return: An iterator over the key-value pairs in the JSON file. :rtype: iter """ - data = self._get_data() + data = self._get_data(mutable=True) # copy: callers get values they may mutate return data.items() \ No newline at end of file diff --git a/src/config/global_settings.py b/src/config/global_settings.py index d8178b3..b7fda09 100644 --- a/src/config/global_settings.py +++ b/src/config/global_settings.py @@ -289,6 +289,21 @@ def __init__(self): super().__init__("config", variable_list) +# Module-level singletons: constructing a GlobalSetting re-syncs every leaf key +# against the file, which is far too expensive for the per-message/per-guild hot +# paths these getters sit on. Keyed by file_manager.base_path so tests that +# repoint the config directory get fresh instances. +_global_setting_singletons: dict = {} + +def _get_global_setting_singleton(cls): + from config import file_manager + cache_key = (cls, file_manager.base_path) + instance = _global_setting_singletons.get(cache_key) + if instance is None: + instance = cls() + _global_setting_singletons[cache_key] = instance + return instance + def get_global_kill_status() -> GlobalKillStatus: """ Retrieve the global kill status. @@ -297,7 +312,7 @@ def get_global_kill_status() -> GlobalKillStatus: :rtype: GlobalKillStatus """ - return GlobalKillStatus() + return _get_global_setting_singleton(GlobalKillStatus) def get_configs() -> Configs: """ @@ -306,7 +321,7 @@ def get_configs() -> Configs: :return: An instance representing the config. :rtype: Configs """ - return Configs() + return _get_global_setting_singleton(Configs) def get_bot_load_status() -> bool: From f2b06ddb80ed070cf7be4fd79726642a1d769d13 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 13:05:04 -0600 Subject: [PATCH 022/109] Fix B6: warn the owner when the configured birthday channel can't be resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deleted/hidden birthday channel silently dropped the message (only the Forbidden path warned). The owner now gets the same style of DM warning. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Good change. --- src/features/birthdays.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/features/birthdays.py b/src/features/birthdays.py index 08e06a1..84f3677 100644 --- a/src/features/birthdays.py +++ b/src/features/birthdays.py @@ -168,6 +168,18 @@ async def check_and_run_birthday_actions( channel = guild.system_channel elif channel_id is not None: channel = guild.get_channel(channel_id) + if channel is None: + # Configured channel was deleted or is invisible — warn instead of silently dropping + await utils.send_error_message_to_server_owner( + guild, + None, + message=utils.standardize_str_indention(f""" + InfiniBot couldn't find the configured birthday channel (ID: {channel_id}) to send a birthday message. To fix this, either: + - Ensure the channel exists and InfiniBot can see it, or + - Change the notification channel for birthdays in the dashboard (`/dashboard`). + """), + administrator=False + ) else: logging.warning(f"Birthday channel id is NONE somehow in server {guild.id}. Ignoring...") channel = None From bef473ba4c9b3531557595403634c93668e50e48 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 13:05:43 -0600 Subject: [PATCH 023/109] Fix B10: midnight log rotation preserves the configured log level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daily setup_logging() call used the INFO default, discarding the logging.log-level from config that was applied at boot. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Again: Woops, mb. --- src/core/scheduling.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/scheduling.py b/src/core/scheduling.py index 20a0be2..c618037 100644 --- a/src/core/scheduling.py +++ b/src/core/scheduling.py @@ -48,7 +48,9 @@ async def run_scheduled_tasks() -> None: # Start new log if it is a new day if current_time_utc.hour == 0 and current_time_utc.minute == 0: logging.warning("New day, generating new log file") - setup_logging() + # Preserve the configured log level — the default (INFO) would silently + # revert a DEBUG deployment every midnight + setup_logging(level=logging.root.level) from core.bot import bot guilds = list(bot.guilds) From 105083e2794ec3cb2a64077d15ce69f705db5e1c Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:21:38 -0600 Subject: [PATCH 024/109] Fix L7: delete log tolerates unresolvable audit user/target instead of crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a fresh message_delete audit entry but an uncached message, entry.target (and in theory entry.user) can be None; the subsequent .id/.mention derefs raised AttributeError, which LogIfFailure swallowed — so no delete log was produced at all. Unresolvable actors now downgrade to the non-attributed log path, and a None author on a DB- sourced record falls back to the generic description. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a plausible issue that was properly resolved --- src/features/action_logging.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/features/action_logging.py b/src/features/action_logging.py index fde0575..46695bd 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -389,6 +389,7 @@ async def trigger_delete_log(bot: nextcord.Client, channel: nextcord.TextChannel if entry and fresh_audit_log: # We prioritize the author of the message if we know it, but if we don't we use this + # (entry.target can be None when the message is uncached) if not message: user = entry.target else: user = message.author # Set the deleter (because we didn't know that before) @@ -398,7 +399,13 @@ async def trigger_delete_log(bot: nextcord.Client, channel: nextcord.TextChannel # We don't actually need any of this information to exist then if not message: user = None deleter = None - + + # A fresh entry can still have unresolvable user/deleter — treat that like a + # non-fresh entry instead of crashing on .id/.mention below + if fresh_audit_log and (user is None or deleter is None): + fresh_audit_log = False + deleter = None + # Eliminate whether InfiniBot is the author / deleter (only do this if we're sure that the audit log is fresh) if fresh_audit_log and user.id == bot.application_id: return if fresh_audit_log and deleter.id == bot.application_id: return @@ -421,7 +428,7 @@ async def trigger_delete_log(bot: nextcord.Client, channel: nextcord.TextChannel else: code = 2 else: - if not message: + if not message or message.author is None: embed.description = f"A message was deleted from {channel.mention}" code = 3 else: From 5dea2983b820f24765de8ec7ce8e00c966a0c54e Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:24:45 -0600 Subject: [PATCH 025/109] Fix L10/L12: consistent aware message timestamps; delete log survives failed author fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L10: the upsert's conflict path wrote SQLite CURRENT_TIMESTAMP (naive, different format) while inserts bound an aware ISO datetime — skewing string-based retention comparisons and fromisoformat() parsing. The update now binds an aware UTC timestamp from Python (keeping the bump- on-edit semantics). The message_to_message_record_type fallback timestamp is also aware now. L12: a failed MessageRecord.fetch('author') in on_raw_message_delete propagated (aborting the delete log), and log_raw_message_delete deref'd message.author.id unguarded. The fetch is now isolated, and the InfiniBot self-check falls back to the record's author_id when author is None. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/config/messages/stored_messages.py | 7 ++++--- src/config/messages/utils.py | 2 +- src/core/bot.py | 7 +++++-- src/features/action_logging.py | 5 ++++- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/config/messages/stored_messages.py b/src/config/messages/stored_messages.py index 3230ccf..b6016a5 100644 --- a/src/config/messages/stored_messages.py +++ b/src/config/messages/stored_messages.py @@ -25,12 +25,12 @@ def store_message_in_db(message: nextcord.Message | MessageRecord, override_chec message_record = message_to_message_record_type(message) query = """ - INSERT INTO messages + INSERT INTO messages (message_id, guild_id, channel_id, author_id, content, last_updated) VALUES (:message_id, :guild_id, :channel_id, :author_id, :content, :last_updated) ON CONFLICT(message_id) DO UPDATE SET content = excluded.content, - last_updated = CURRENT_TIMESTAMP + last_updated = :updated_now """ affected_rows = get_database().execute_query(query, { 'message_id': message_record.message_id, @@ -38,7 +38,8 @@ def store_message_in_db(message: nextcord.Message | MessageRecord, override_chec 'channel_id': message_record.channel_id, 'author_id': message_record.author_id, 'content': message_record.content, - 'last_updated': message_record.last_updated + 'last_updated': message_record.last_updated, + 'updated_now': datetime.datetime.now(datetime.timezone.utc) }, commit=True, return_affected_rows=True) success = affected_rows > 0 diff --git a/src/config/messages/utils.py b/src/config/messages/utils.py index a1de1ed..dbe5791 100644 --- a/src/config/messages/utils.py +++ b/src/config/messages/utils.py @@ -200,7 +200,7 @@ def getattr_recursive(name, obj): guild_id = get_var(message, 'guild.id', 'guild_id') author_id = get_var(message, 'author.id', 'author_id') content = message.content - last_updated = get_var(message, 'last_updated', 'created_at') or datetime.datetime.now() + last_updated = get_var(message, 'last_updated', 'created_at') or datetime.datetime.now(datetime.timezone.utc) embeds = getattr(message, 'embeds', []) attachments = getattr(message, 'attachments', []) diff --git a/src/core/bot.py b/src/core/bot.py index 9ba5649..f821264 100644 --- a/src/core/bot.py +++ b/src/core/bot.py @@ -614,8 +614,11 @@ async def on_raw_message_delete(payload: nextcord.RawMessageDeleteEvent) -> None # Actual values are important instead of object approximations, so replace them message.guild = guild message.channel = channel - # Some additional info needs to be fetched - await message.fetch("author") + # Some additional info needs to be fetched. A failed fetch leaves + # message.author as None (downstream logging handles that) — it + # must not abort the delete log entirely. + with LogIfFailure(feature="MessageRecord.fetch('author')"): + await message.fetch("author") # Log the message with LogIfFailure(feature="action_logging.log_raw_message_delete"): diff --git a/src/features/action_logging.py b/src/features/action_logging.py index 46695bd..721be36 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -838,8 +838,11 @@ async def log_raw_message_delete(bot: nextcord.Client, guild: nextcord.Guild, ch await asyncio.sleep(1) # We need this time delay for some other features # Do not trigger if confident that the message was InfiniBot's + # (author can be None on a DB-sourced record whose author fetch failed, so fall + # back to the record's author_id) if message: - if message.author.id == bot.application_id: + author_id = message.author.id if message.author is not None else getattr(message, "author_id", None) + if author_id == bot.application_id: logging.debug(f"Message {message_id}'s author is InfiniBot; skipping delete log.") return From da2f28053f19a225f786c75c6aa9017fdfe28b30 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:25:16 -0600 Subject: [PATCH 026/109] Fix L9: message_checks no longer rejects DB-sourced MessageRecords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MessageRecord always defines .guild (None until fetched), so the generic 'has guild attr and it is None' check returned False for every record — silently dropping any store/cache of a MessageRecord without override_checks=True. Records are now validated by guild_id. (Latent: no current caller hits this, per verification.) Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** This was a sly one. Good catch. --- src/config/messages/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/config/messages/utils.py b/src/config/messages/utils.py index dbe5791..863c617 100644 --- a/src/config/messages/utils.py +++ b/src/config/messages/utils.py @@ -157,7 +157,10 @@ def message_checks(message: nextcord.Message | MessageRecord) -> bool: """ if not message: return False - if hasattr(message, 'guild') and message.guild is None: + if isinstance(message, MessageRecord): + if message.guild_id is None: + return False + elif hasattr(message, 'guild') and message.guild is None: return False if hasattr(message, 'author') and hasattr(message.author, 'bot') and message.author.bot: return False # Don't cache bot messages From 306034d86c6112ae936f68a55d875241e963e21b Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:26:01 -0600 Subject: [PATCH 027/109] Fix M3: message edits respect the configured admin-role profanity exemption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on_message path exempts administrators and members holding a configured admin_role_ids role, but the edit path calls check_and_trigger_profanity_moderation_for_message directly, which only checked guild_permissions.administrator — so admin-role members were punished for edits but not for original posts. The role exemption now lives inside the shared check (still bypassed by skip_admin_check=True, whose caller performs both checks itself). Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** It's fine for now, but I don't love how the codepath here is so messy, where we need this check inside check_and_trigger_profanity_moderation_for_message(). --- src/features/moderation.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/features/moderation.py b/src/features/moderation.py index a0c35a6..ce15f4a 100644 --- a/src/features/moderation.py +++ b/src/features/moderation.py @@ -495,9 +495,14 @@ async def check_and_trigger_profanity_moderation_for_message( logging.debug(f"Tried to check profanity for a non-member: {message.author}. Guild ID: {message.guild.id}") return False - if not skip_admin_check and message.author.guild_permissions.administrator: - logging.debug(f"Skipped profanity check for admin: {message.author}. Guild ID: {message.guild.id}") - return False + if not skip_admin_check: + if message.author.guild_permissions.administrator: + logging.debug(f"Skipped profanity check for admin: {message.author}. Guild ID: {message.guild.id}") + return False + + if any(role.id in server.moderation_profile.admin_role_ids for role in message.author.roles): + logging.debug(f"Skipped profanity check for member with admin role: {message.author}. Guild ID: {message.guild.id}") + return False # Message Content - remove URLs to prevent false positives msg = remove_urls_from_text(message.content).lower() From 0afa814ba72331d047e9987dc2a9c99f3c372b3d Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:27:00 -0600 Subject: [PATCH 028/109] Fix M4: strike timestamps are written as aware UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strikes were stored with naive local datetime.now() but expiry parses last_strike as UTC (parse_datetime_string), skewing strike expiry by the host's UTC offset — and daily maintenance already wrote aware UTC, leaving records inconsistent. Both write sites now use aware UTC. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Yes... and, the container that runs this is UTC, so these bugs never manifested. Still good fixes. --- src/features/moderation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/moderation.py b/src/features/moderation.py index ce15f4a..83c26ac 100644 --- a/src/features/moderation.py +++ b/src/features/moderation.py @@ -361,7 +361,7 @@ def update_strikes_for_member(guild_id:int, member_id:int, amount:int): if server.profanity_moderation_profile.strike_system_active: if member_id in server.moderation_strikes: old_strikes = server.moderation_strikes[member_id].strikes - server.moderation_strikes.edit(member_id=member_id, strikes=old_strikes + amount, last_strike=datetime.datetime.now()) + server.moderation_strikes.edit(member_id=member_id, strikes=old_strikes + amount, last_strike=datetime.datetime.now(datetime.timezone.utc)) updated_strike_count = server.moderation_strikes[member_id].strikes if updated_strike_count <= 0: @@ -370,7 +370,7 @@ def update_strikes_for_member(guild_id:int, member_id:int, amount:int): else: if amount > 0: - server.moderation_strikes.add(member_id=member_id, strikes=amount, last_strike=datetime.datetime.now()) + server.moderation_strikes.add(member_id=member_id, strikes=amount, last_strike=datetime.datetime.now(datetime.timezone.utc)) updated_strike_count = amount return updated_strike_count From 3b83bfcc41c14e378c33a81826940653bf0984b7 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:29:54 -0600 Subject: [PATCH 029/109] Fix O5/O6: message editors stop after an error response and guard the managed-message lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the target message was gone, load/load_buttons sent an error response but setup continued and called interaction.response.edit_message — raising InteractionResponded (O5). And managed_messages[message_id] KeyError'd when the message was no longer cataloged (O6). load now returns a success bool that setup honors, and an uncataloged message gets a proper 'Message Not Editable' response. Applied to the embed, reaction-role, and role-message editors alike. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a possible issue that was properly resolved --- src/features/options_menu/editing/embeds.py | 30 ++++++++++++++----- .../options_menu/editing/reaction_roles.py | 28 ++++++++++++----- .../options_menu/editing/role_messages.py | 11 ++++--- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/features/options_menu/editing/embeds.py b/src/features/options_menu/editing/embeds.py index 4c3399d..84cc9d1 100644 --- a/src/features/options_menu/editing/embeds.py +++ b/src/features/options_menu/editing/embeds.py @@ -11,7 +11,7 @@ def __init__(self, message_id: int): super().__init__() self.message_id = message_id - async def load_buttons(self, interaction: Interaction): + async def load_buttons(self, interaction: Interaction) -> bool: self.message = await utils.get_message(interaction.channel, self.message_id) if self.message is None: # Message no longer exists or was recently not found @@ -23,21 +23,35 @@ async def load_buttons(self, interaction: Interaction): ), ephemeral=True ) - return + return False self.server = Server(interaction.guild.id) + if self.message_id not in self.server.managed_messages: + # Message is no longer cataloged as an InfiniBot-managed message + await interaction.response.send_message( + embed=nextcord.Embed( + title="Message Not Editable", + description="This message is no longer tracked by InfiniBot and can't be edited.", + color=nextcord.Color.red() + ), + ephemeral=True + ) + return False self.message_info = self.server.managed_messages[self.message_id] - + self.clear_items() - + edit_text_btn = self.EditTextButton(self) self.add_item(edit_text_btn) - + edit_color_btn = self.EditColorButton(self) self.add_item(edit_color_btn) - + + return True + async def setup(self, interaction: Interaction): - await self.load_buttons(interaction) - + if not await self.load_buttons(interaction): + return # load_buttons already responded with an error + if not utils.feature_is_active(guild_id=interaction.guild.id, feature="options_menu__editing"): await ui_components.disabled_feature_override(self, interaction) return diff --git a/src/features/options_menu/editing/reaction_roles.py b/src/features/options_menu/editing/reaction_roles.py index f22e4f4..575711b 100644 --- a/src/features/options_menu/editing/reaction_roles.py +++ b/src/features/options_menu/editing/reaction_roles.py @@ -14,7 +14,7 @@ def __init__(self, message_id: int): super().__init__() self.message_id = message_id - async def load_buttons(self, interaction: Interaction): + async def load_buttons(self, interaction: Interaction) -> bool: self.message = await utils.get_message(interaction.channel, self.message_id) if self.message is None: # Message no longer exists or was recently not found @@ -26,18 +26,32 @@ async def load_buttons(self, interaction: Interaction): ), ephemeral=True ) - return + return False self.server = Server(interaction.guild.id) + if self.message_id not in self.server.managed_messages: + # Message is no longer cataloged as an InfiniBot-managed message + await interaction.response.send_message( + embed=nextcord.Embed( + title="Message Not Editable", + description="This message is no longer tracked by InfiniBot and can't be edited.", + color=nextcord.Color.red() + ), + ephemeral=True + ) + return False self.message_info = self.server.managed_messages[self.message_id] - + self.clear_items() - + self.add_item(self.EditTextButton(self)) self.add_item(self.EditOptionsButton(self, self.message_info)) - + + return True + async def setup(self, interaction: Interaction): - await self.load_buttons(interaction) - + if not await self.load_buttons(interaction): + return # load_buttons already responded with an error + if not utils.feature_is_active(feature="options_menu__editing", guild_id=interaction.guild.id): await ui_components.disabled_feature_override(self, interaction) return diff --git a/src/features/options_menu/editing/role_messages.py b/src/features/options_menu/editing/role_messages.py index 6f767c9..c54766b 100644 --- a/src/features/options_menu/editing/role_messages.py +++ b/src/features/options_menu/editing/role_messages.py @@ -549,7 +549,7 @@ async def create_btn_callback(self, interaction: Interaction): async def callback(self, interaction: Interaction): await self.MultiOrSingleSelectView(self.outer).setup(interaction) - async def load(self, interaction: Interaction): + async def load(self, interaction: Interaction) -> bool: self.message = await utils.get_message(interaction.channel, self.message_id) if self.message is None: # Message no longer exists or was recently not found @@ -561,7 +561,7 @@ async def load(self, interaction: Interaction): ), ephemeral=True ) - return + return False self.guild = interaction.guild if (self.title is None and self.description is None @@ -620,9 +620,12 @@ async def load(self, interaction: Interaction): ) self.confirm_btn.callback = self.confirm_btn_callback self.add_item(self.confirm_btn) - + + return True + async def setup(self, interaction: Interaction): - await self.load(interaction) + if not await self.load(interaction): + return # load already responded with an error description = utils.standardize_str_indention( """Edit your role message by making to the text, color, and options. Once finished, click on \"Confirm Edits\" From 10b5217910391f0ba2f5e947605e4eb63bb63786 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:30:59 -0600 Subject: [PATCH 030/109] Fix C2: JSONFile writes are atomic (temp file + os.replace) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A crash mid-write previously left malformed JSON, which ensure_existence then 'repaired' by renaming to .bak and recreating {} — silently resetting all global-kill flags and admin IDs. Writes now go to a temp file, fsync, then os.replace over the target. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe a plausible issue that was properly resolved **Human Review Notes:** I'm not sure how important this really was... I think it would have been fine, but sure—the change doesn't make anything worse. --- src/config/file_manager.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/config/file_manager.py b/src/config/file_manager.py index 385e044..33745eb 100644 --- a/src/config/file_manager.py +++ b/src/config/file_manager.py @@ -133,9 +133,18 @@ def _set_data(self, data: dict) -> None: """ self.ensure_existence() - with open(self.path, "w") as file: - file.write(json.dumps(data, indent=2)) - self._cache[self.path] = copy.deepcopy(json.loads(json.dumps(data))) # Store a copy to prevent external modifications & ensure consistent formatting + # Atomic write: dump to a temp file and os.replace() it over the target, so a + # crash mid-write can't leave malformed JSON (which ensure_existence would + # 'repair' by silently resetting all settings to defaults). + serialized = json.dumps(data, indent=2) + temp_path = f"{self.path}.tmp" + with open(temp_path, "w") as file: + file.write(serialized) + file.flush() + os.fsync(file.fileno()) + os.replace(temp_path, self.path) + + self._cache[self.path] = json.loads(serialized) # Store a copy to prevent external modifications & ensure consistent formatting def _update_nested(self, data: dict, keys: list, value: any) -> None: """ From 65981500396bfd9c4c7e6ba5550ff30166eb9485 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:32:20 -0600 Subject: [PATCH 031/109] Fix C5: stop building a new scoped_session registry per query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute_query wrapped the sessionmaker in a fresh scoped_session on every call, creating a thread-local registry each time that was never removed. Sessions now come straight from the sessionmaker (one short- lived session per query, safe from any thread). Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a plausible issue that was properly resolved **Human Review Notes:** I don't know much about this, but what the AI is saying seems correct. --- src/modules/database.py | 194 +++++++++++++++++++++++++--------------- 1 file changed, 124 insertions(+), 70 deletions(-) diff --git a/src/modules/database.py b/src/modules/database.py index cf85670..699bb89 100644 --- a/src/modules/database.py +++ b/src/modules/database.py @@ -7,7 +7,7 @@ from typing import Any, Generator from sqlalchemy import create_engine, text -from sqlalchemy.orm import sessionmaker, scoped_session +from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import QueuePool from components.utils import format_var_to_pythonic_type @@ -22,9 +22,16 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, exc_traceback): if exc_type is not None: if issubclass(exc_type, Exception): - logging.error("Uncaught exception in database: ", exc_info=(exc_type, exc_value, exc_traceback)) + logging.error( + "Uncaught exception in database: ", + exc_info=(exc_type, exc_value, exc_traceback), + ) else: - logging.critical("Critical uncaught exception in database:", exc_info=(exc_type, exc_value, exc_traceback)) + logging.critical( + "Critical uncaught exception in database:", + exc_info=(exc_type, exc_value, exc_traceback), + ) + class Database: """ @@ -41,6 +48,7 @@ class Database: all_column_defaults (dict): Dictionary mapping table names to column defaults. all_column_types (dict): Dictionary mapping table names to column types. """ + def __init__(self, db_url, db_build_file_path): """ Initialize the database connection. @@ -53,20 +61,24 @@ def __init__(self, db_url, db_build_file_path): None """ # Initialize the database connection - self.engine = create_engine(db_url, pool_size = 5, poolclass = QueuePool) # create_engine runs a validity check on db_url + self.engine = create_engine( + db_url, pool_size=5, poolclass=QueuePool + ) # create_engine runs a validity check on db_url self.Session = sessionmaker(bind=self.engine) self._dburl = db_url - self.tables:list[str] = [] - self.tags:dict[str, any] = {} # Dictionary to store tags for tables - self.all_column_defaults:dict[str, dict[str, str]] = {} - self.all_column_types:dict[str, dict[str, str]] = {} - self.all_column_names:dict[str, list[str]] = {} - self.all_primary_keys:dict[str, str] = {} + self.tables: list[str] = [] + self.tags: dict[str, any] = {} # Dictionary to store tags for tables + self.all_column_defaults: dict[str, dict[str, str]] = {} + self.all_column_types: dict[str, dict[str, str]] = {} + self.all_column_names: dict[str, list[str]] = {} + self.all_primary_keys: dict[str, str] = {} self.execute_query("PRAGMA journal_mode=WAL;") - self.build_database(db_build_file_path) # Database must be valid at initialization - self.index_tables() # Index tables at runtime + self.build_database( + db_build_file_path + ) # Database must be valid at initialization + self.index_tables() # Index tables at runtime atexit.register(self.cleanup) # Ensure cleanup at program exit def cleanup(self): @@ -84,7 +96,15 @@ def __del__(self): def get_db_url(self): return self._dburl - def execute_query(self, sql: str, args: dict = {}, commit: bool = False, multiple_values: bool = False, return_affected_rows: bool = False, **kwargs): + def execute_query( + self, + sql: str, + args: dict = {}, + commit: bool = False, + multiple_values: bool = False, + return_affected_rows: bool = False, + **kwargs, + ): """ Execute an SQL query and handle exceptions. @@ -96,18 +116,15 @@ def execute_query(self, sql: str, args: dict = {}, commit: bool = False, multipl return_affected_rows (bool, optional): Whether to return the number of affected rows instead of query results. Returns: - any: + any: return_affected_rows = True: Number of rows affected by INSERT/UPDATE/DELETE operations. multiple_values = False: Query result(s) simplified to a single value. multiple_values = True: All query results wrapped in a list. """ - # Use scoped_session if self.Session isn't already a scoped_session - session_factory = scoped_session(self.Session) if not isinstance(self.Session, scoped_session) else self.Session - - with session_factory() as session: + with self.Session() as session: try: result = session.execute(text(sql), args) - + if return_affected_rows: # For INSERT/UPDATE/DELETE operations, return the number of affected rows affected_count = result.rowcount @@ -120,12 +137,12 @@ def execute_query(self, sql: str, args: dict = {}, commit: bool = False, multipl if commit: session.commit() return data if multiple_values else self.get_query_first_value(data) - + except Exception as e: logging.error(f"Error executing SQL query: {sql}", exc_info=True) session.rollback() raise Exception(e) - + def build_database(self, build_file_path: str) -> None: """ Build the database by applying instructions from a file. @@ -138,41 +155,55 @@ def build_database(self, build_file_path: str) -> None: if not os.path.exists(build_file_path): raise FileNotFoundError("Database build file not found.") - with open(build_file_path, 'r') as file: + with open(build_file_path, "r") as file: sql_instructions = file.read() # Split the SQL instructions into separate queries - for instruction in re.split(r'\n\s*\n', sql_instructions.strip()): + for instruction in re.split(r"\n\s*\n", sql_instructions.strip()): self.execute_query(instruction, commit=True) def index_tables(self) -> None: """ Index all tables and their column defaults. """ - self.tables = [row[0] for row in self.execute_query("SELECT name FROM sqlite_master WHERE type='table'", multiple_values=True)] + self.tables = [ + row[0] + for row in self.execute_query( + "SELECT name FROM sqlite_master WHERE type='table'", + multiple_values=True, + ) + ] self.all_column_defaults = {} self.all_column_types = {} self.all_column_names = {} self.all_primary_keys = {} - + for table in self.tables: column_defaults = {} column_types = {} column_names = [] primary_key = None - table_info = self.execute_query(f"PRAGMA table_info({table})", multiple_values=True) - table_schema = self.execute_query(f"SELECT sql FROM sqlite_master WHERE type='table' AND name='{table}'", - multiple_values=False) - + table_info = self.execute_query( + f"PRAGMA table_info({table})", multiple_values=True + ) + table_schema = self.execute_query( + f"SELECT sql FROM sqlite_master WHERE type='table' AND name='{table}'", + multiple_values=False, + ) + # Tags - _tags = self._extract_tags_from_line(table_schema[0].split("\n")[0]) if table_schema else {} + _tags = ( + self._extract_tags_from_line(table_schema[0].split("\n")[0]) + if table_schema + else {} + ) if _tags: self.tags[table] = _tags - for col_info in table_info: column_names.append(col_info[1]) - if len(col_info) < 5: continue + if len(col_info) < 5: + continue col_name, _type, _, default_value, primary_key_status = col_info[1:6] if default_value is not None: @@ -181,7 +212,7 @@ def index_tables(self) -> None: if primary_key_status == 1: primary_key = col_name - + self.all_column_defaults[table] = column_defaults self.all_column_types[table] = column_types self.all_column_names[table] = column_names @@ -206,15 +237,17 @@ def _extract_tags_from_line(self, line: str) -> dict[str, any]: for tag_part in tag_parts: if "(" in tag_part and tag_part.endswith(")"): # Tag with argument: extract tag name and argument - tag_name = tag_part[:tag_part.index("(")] - argument = tag_part[tag_part.index("(") + 1:-1] + tag_name = tag_part[: tag_part.index("(")] + argument = tag_part[tag_part.index("(") + 1 : -1] tags[tag_name] = argument else: # Simple tag without argument tags[tag_part] = True return tags - def remove_extraneous_rows(self, table:str, skip_table_validation_check=False) -> None: + def remove_extraneous_rows( + self, table: str, skip_table_validation_check=False + ) -> None: """ Check if a table has extraneous rows and remove them. (Will only run on tables marked as `#optimize`) @@ -225,7 +258,8 @@ def remove_extraneous_rows(self, table:str, skip_table_validation_check=False) - int: Number of rows deleted. """ if not skip_table_validation_check: - if table not in self.tags or 'optimize' not in self.tags[table]: return 0 + if table not in self.tags or "optimize" not in self.tags[table]: + return 0 column_defaults = self.all_column_defaults[table] @@ -238,7 +272,9 @@ def remove_extraneous_rows(self, table:str, skip_table_validation_check=False) - # Execute the query return self.execute_query(select_query, commit=True, return_affected_rows=True) - def optimize_database(self, throttle: bool = False, throttle_delay: float = 0.1) -> None: + def optimize_database( + self, throttle: bool = False, throttle_delay: float = 0.1 + ) -> None: """ Synchronous database optimization with throttling and metrics - throttle: Enable delay between table optimizations @@ -250,7 +286,11 @@ def optimize_database(self, throttle: bool = False, throttle_delay: float = 0.1) Returns: int: Total number of rows deleted during optimization. """ - tables_to_optimize = [table for table in self.tables if table in self.tags and 'optimize' in self.tags[table]] + tables_to_optimize = [ + table + for table in self.tables + if table in self.tags and "optimize" in self.tags[table] + ] start_time = time.monotonic() total_tables = len(tables_to_optimize) @@ -277,7 +317,11 @@ def optimize_database(self, throttle: bool = False, throttle_delay: float = 0.1) finally: total_duration = time.monotonic() - start_time - avg_per_table = (total_duration - total_throttle_time) / processed_tables if processed_tables else 0 + avg_per_table = ( + (total_duration - total_throttle_time) / processed_tables + if processed_tables + else 0 + ) logging.info( f"Database optimization completed\n" @@ -290,7 +334,7 @@ def optimize_database(self, throttle: bool = False, throttle_delay: float = 0.1) return rows_deleted - def force_remove_entry(self, table:str, id:int) -> None: + def force_remove_entry(self, table: str, id: int) -> None: """ Force remove an entire entry from a table. BE VERY CAREFUL WITH THIS. @@ -299,14 +343,18 @@ def force_remove_entry(self, table:str, id:int) -> None: id (int): Entry ID. """ id_sql_name = self.get_id_sql_name(table) - self.execute_query(f"DELETE FROM {table} WHERE {id_sql_name} = :id", - args={"id": id}, - commit=True) - + self.execute_query( + f"DELETE FROM {table} WHERE {id_sql_name} = :id", + args={"id": id}, + commit=True, + ) + def checkpoint(self): self.execute_query("PRAGMA wal_checkpoint(RESTART)", commit=True) - - def get_column_default(self, table:str, column_name:str, format=False) -> (bool | int | str | UNSET_VALUE | Any): + + def get_column_default( + self, table: str, column_name: str, format=False + ) -> bool | int | str | UNSET_VALUE | Any: """ Get the default value of a column in a table. @@ -322,19 +370,19 @@ def get_column_default(self, table:str, column_name:str, format=False) -> (bool column_defaults = self.all_column_defaults[table] except KeyError: raise KeyError(f"Table {table} not found in all_column_defaults.") - + try: default_value = column_defaults[column_name] except KeyError: return UNSET_VALUE - + if format: _type = self.get_column_type(table, column_name) default_value = format_var_to_pythonic_type(_type, default_value) return default_value - - def get_column_type(self, table:str, column_name:str) -> (str | UNSET_VALUE): + + def get_column_type(self, table: str, column_name: str) -> str | UNSET_VALUE: """ Get the data type of a column in a table. @@ -346,17 +394,17 @@ def get_column_type(self, table:str, column_name:str) -> (str | UNSET_VALUE): Returns: str: Data type of the column. UNSET_VALUE if no default value is set. """ - try: + try: column_types = self.all_column_types[table] except KeyError: raise KeyError(f"Table {table} not found in all_column_types.") - + try: return column_types[column_name] except KeyError: return UNSET_VALUE - - def get_query_first_value(self, query_result:list[tuple]): + + def get_query_first_value(self, query_result: list[tuple]): """ Cleans up query results to a single value or tuple. Should only be used on a raw query @@ -368,12 +416,14 @@ def get_query_first_value(self, query_result:list[tuple]): any: Simplified query result. """ # Ensure that query_result is not None. If so, return None and stop. - if query_result is None: return None - - if len(query_result) > 0: return query_result[0] + if query_result is None: + return None + + if len(query_result) > 0: + return query_result[0] return None - def does_entry_exist(self, table:str, id: int) -> bool: + def does_entry_exist(self, table: str, id: int) -> bool: """ Check if an entry exists in the specified table. @@ -385,12 +435,14 @@ def does_entry_exist(self, table:str, id: int) -> bool: bool: True if entry exists, False otherwise. """ id_sql_name = self.get_id_sql_name(table) - entry = self.execute_query(f"SELECT * FROM {table} WHERE {id_sql_name} = :id", - args={"id": id}, - multiple_values=True) + entry = self.execute_query( + f"SELECT * FROM {table} WHERE {id_sql_name} = :id", + args={"id": id}, + multiple_values=True, + ) return bool(entry) - - def get_table_unique_entries(self, table:str) -> Generator[int, int, int]: + + def get_table_unique_entries(self, table: str) -> Generator[int, int, int]: """ Get all unique entries from a specific table. @@ -398,8 +450,9 @@ def get_table_unique_entries(self, table:str) -> Generator[int, int, int]: list[int] (Generator[int, int, int]): Generator yielding integer IDs. """ id_sql_name = self.get_id_sql_name(table) - ids = self.execute_query(f"SELECT DISTINCT {id_sql_name} FROM {table}", - multiple_values=True) + ids = self.execute_query( + f"SELECT DISTINCT {id_sql_name} FROM {table}", multiple_values=True + ) for id_tuple in ids: id = id_tuple[0] yield id @@ -417,10 +470,10 @@ def get_unique_entries_for_database(self) -> list[int]: for table in self.tables: ids = self.get_table_unique_entries(table) unique_ids.update(ids) - + return list(unique_ids) - - def get_id_sql_name(self, table:str) -> str: + + def get_id_sql_name(self, table: str) -> str: """ Get the SQL name of the primary key of a table. @@ -436,4 +489,5 @@ def get_id_sql_name(self, table:str) -> str: if self.all_primary_keys[table] == None: raise KeyError(f"Table {table} does not have a primary key.") - return self.all_primary_keys[table] \ No newline at end of file + return self.all_primary_keys[table] + From 35c7e252f51b8f7d63512c69459b005a981d0acb Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:36:06 -0600 Subject: [PATCH 032/109] Fix C8/C9: IntegratedList single-query iteration, COUNT(*) len, parameterized keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _get_all_entries fetched every row with its own SELECT (N+1); it now issues one query for all rows of the primary key. - __len__ materialized the entire list to count it (hit per guild via len(server.birthdays) in the scheduler); it now uses COUNT(*). - _get_entry interpolated the secondary key value raw into SQL (syntax error / injection vector for non-integer keys); it now binds parameters. - Removed the broken __next__, which re-created the generator each call and returned the first entry forever; iteration goes through __iter__. - to_dict reuses the single-query iteration. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as proper optimizations/fixes that this commit properly implements --- src/core/db_manager.py | 40 +++++++++++++++++----------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/src/core/db_manager.py b/src/core/db_manager.py index 6773558..2448519 100644 --- a/src/core/db_manager.py +++ b/src/core/db_manager.py @@ -685,9 +685,11 @@ def _get_entry(self, second_key_value): Returns: tuple: The SQL row matching the primary and secondary key values, or None if not found. """ - response = self.database.execute_query(f"SELECT * FROM {self.table_name} " \ - f"WHERE {self.primary_key_sql_name} = {self.primary_key_value} " \ - f"AND {self.secondary_key_sql_name} = {second_key_value}") + response = self.database.execute_query( + f"SELECT * FROM {self.table_name} " + f"WHERE {self.primary_key_sql_name} = :primary_key_value " + f"AND {self.secondary_key_sql_name} = :secondary_key_value", + {'primary_key_value': self.primary_key_value, 'secondary_key_value': second_key_value}) return response @@ -698,11 +700,11 @@ def _get_all_entries(self): Yields: dataclass: A dataclass representing each entry in the list. """ - # Get all entries in the table - for secondary_key_value in self._get_all_secondary_values(): - data = self._get_entry(secondary_key_value) - if data is not None: - yield self._package_list_into_dataclass(data) + # One query for all rows + query = f"SELECT * FROM {self.table_name} WHERE {self.primary_key_sql_name} = :primary_key_value" + rows = self.database.execute_query(query, {'primary_key_value': self.primary_key_value}, multiple_values=True) + for row in rows or []: + yield self._package_list_into_dataclass(row) def _get_all_secondary_values(self): """ @@ -1034,7 +1036,10 @@ def __len__(self): Returns: int: The length of the list. """ - return len(list(self._get_all_entries())) + row = self.database.execute_query( + f"SELECT COUNT(*) FROM {self.table_name} WHERE {self.primary_key_sql_name} = :primary_key_value", + {'primary_key_value': self.primary_key_value}) + return row[0] if row is not None else 0 def __contains__(self, secondary_key_value): """ @@ -1056,16 +1061,7 @@ def __iter__(self): Generator: A generator yielding dataclass instances for each entry. """ return self._get_all_entries() - - def __next__(self): - """ - Return the next entry in the list. - Returns: - dataclass: A dataclass representing the next entry. - """ - return next(self._get_all_entries()) - def __str__(self) -> str: """ Return a string representation of the list. @@ -1085,11 +1081,9 @@ def to_dict(self): dict: A dictionary containing the entries in the list. """ result = {} - for secondary_key_value in self._get_all_secondary_values(): - data = self._get_entry(secondary_key_value) - if data is not None: - dataclass_entry = self._package_list_into_dataclass(data) - result[secondary_key_value] = dataclass_entry.get_data() + for dataclass_entry in self._get_all_entries(): + secondary_key_value = getattr(dataclass_entry, self.secondary_key_sql_name) + result[secondary_key_value] = dataclass_entry.get_data() return result async def daily_database_maintenance(bot: nextcord.Client): From 9c1c0fcd3912a7a701a4fff1ec16a6ff9f882cc0 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:36:42 -0600 Subject: [PATCH 033/109] Fix C13: on_shard_ready dedups shard ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shard reconnects re-fire on_shard_ready; appending the same id again grew shards_loaded unboundedly and made the 'all shards loaded' gate (len == shard_count) fire repeatedly / unreliably. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Actually, I experienced the symptoms of this bug in prod, I think, so this is a welcome change. --- src/core/bot.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/core/bot.py b/src/core/bot.py index f821264..75b41d6 100644 --- a/src/core/bot.py +++ b/src/core/bot.py @@ -203,11 +203,14 @@ async def on_shard_ready(shard_id: int) -> None: logging.info(f"Shard {shard_id} ready with {len(shard_guilds)} guilds") with global_settings.ShardLoadedStatus() as shards_loaded: - shards_loaded.append(shard_id) - - # Check if all shards are loaded - if len(shards_loaded) == bot.shard_count: - logging.info(f"All {bot.shard_count} shards loaded.") + # Dedup: on_shard_ready re-fires on shard reconnects, and duplicate ids would + # both grow the list unboundedly and re-trigger the all-shards-loaded branch + if shard_id not in shards_loaded: + shards_loaded.append(shard_id) + + # Check if all shards are loaded + if len(shards_loaded) == bot.shard_count: + logging.info(f"All {bot.shard_count} shards loaded.") @bot.event async def on_close() -> None: From d6fe50745bcd710666b6a425344b2600cf26b076 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:37:31 -0600 Subject: [PATCH 034/109] Fix C15: error handlers copy the shared error embed before setting the footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INFINIBOT_ERROR_EMBED is a single module-level Embed; the command/view/ modal error handlers all set_footer(...) on it directly, so concurrent errors overwrote each other's footer and users could see the wrong error ID. Each handler now works on a .copy(). Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** How could I be so stupid? Yes, this change is good & necessary. --- src/components/ui_components.py | 4 ++-- src/core/bot.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/ui_components.py b/src/components/ui_components.py index 9236f1d..0355c78 100644 --- a/src/components/ui_components.py +++ b/src/components/ui_components.py @@ -199,7 +199,7 @@ async def on_error(self, error: Exception, item, interaction: Interaction) -> No logging.error(f"Error ID: {error_id} - Exception in view interaction", exc_info=error) # Inform the user about the error with the ID - embed = INFINIBOT_ERROR_EMBED + embed = INFINIBOT_ERROR_EMBED.copy() # fresh copy per error - concurrent errors must not overwrite each other's footer embed.set_footer(text = f"View Interaction - Error ID: {error_id}") if not interaction.response.is_done(): @@ -253,7 +253,7 @@ async def on_error(self, error: Exception, interaction: Interaction) -> None: logging.error(f"Error ID: {error_id} - Exception in modal interaction", exc_info=error) # Inform the user about the error with the ID - embed = INFINIBOT_ERROR_EMBED + embed = INFINIBOT_ERROR_EMBED.copy() # fresh copy per error - concurrent errors must not overwrite each other's footer embed.set_footer(text = f"Modal Interaction - Error ID: {error_id}") await interaction.response.send_message( embed=embed, ephemeral=True, view=SupportView() diff --git a/src/core/bot.py b/src/core/bot.py index 75b41d6..8e5d3df 100644 --- a/src/core/bot.py +++ b/src/core/bot.py @@ -463,7 +463,7 @@ async def on_application_command_error(interaction: Interaction, error) -> None: logging.error(f"Error ID: {error_id} - Unhandled exception in application command", exc_info=error) # Send a user-friendly error message - embed = ui_components.INFINIBOT_ERROR_EMBED + embed = ui_components.INFINIBOT_ERROR_EMBED.copy() # fresh copy per error - concurrent errors must not overwrite each other's footer embed.set_footer(text = f"Command Execution - Error ID: {error_id}") try: await interaction.response.send_message(embed=embed, ephemeral=True, view=ui_components.SupportView()) From e00e15184991b1bfd7c41171f0d5a828b5207ab1 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:38:37 -0600 Subject: [PATCH 035/109] Fix: chunk_guild_with_feedback chunks regardless of bot load status Issue found via bug report X3. However, X3 suggested just reading the bot load status through the getter. With the changes to guild chunking on startup (disabling it), this code no longer makes sense to include and this bug was actually saving me. I just removed the check entirely, so that chunk_guild_with_feedback always tries to chunk the guild regardless of load status. This is authored my cypress-exe exclusively (no Claude in this one). --- src/components/ui_components.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/ui_components.py b/src/components/ui_components.py index 0355c78..5eaaf24 100644 --- a/src/components/ui_components.py +++ b/src/components/ui_components.py @@ -8,7 +8,7 @@ from nextcord.utils import MISSING from components import utils -from config.global_settings import bot_loaded, get_configs, required_permissions, VIEW_TIMEOUT +from config.global_settings import get_bot_load_status, get_configs, required_permissions, VIEW_TIMEOUT from core.log_manager import get_uuid_for_logging # View overrides @@ -103,10 +103,6 @@ async def chunk_guild_with_feedback(interaction: Interaction) -> int: if interaction.guild.chunked: return None - # Skip if bot is already fully loaded. If the guild still shows as unchunked, something is wrong, but chunking again won't help. - if bot_loaded: - return None - # Send loading message to user await interaction.response.send_message( embed=nextcord.Embed( From f9cdbe55db660004ef2fc440a5a31aa7026c35f1 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:39:58 -0600 Subject: [PATCH 036/109] Fix P1: permission-denied notice uses followup when the interaction was already responded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chunk_guild_with_feedback can send the interaction's initial response (loading message); user_has_config_permissions then raised InteractionResponded trying to send its Missing Permissions notice (e.g. in /purge). The notify path now checks interaction.response.is_done() and falls back to the followup webhook. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a possible issue that was properly resolved --- src/components/utils.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/utils.py b/src/components/utils.py index 19647ec..9cadda2 100644 --- a/src/components/utils.py +++ b/src/components/utils.py @@ -891,7 +891,14 @@ async def user_has_config_permissions(interaction: Interaction, notify: bool = T if infinibot_mod_role in interaction.user.roles: return True - if notify: await interaction.response.send_message(embed = nextcord.Embed(title = "Missing Permissions", description = "You need to have the Infinibot Mod role to use this command.\n\nGo to our [docs](https://cypress-exe.github.io/InfiniBot/docs/getting-started/install-and-setup/#the-infinibot-mod-role) for more information.", color = nextcord.Color.red()), ephemeral = True) + if notify: + embed = nextcord.Embed(title = "Missing Permissions", description = "You need to have the Infinibot Mod role to use this command.\n\nGo to our [docs](https://cypress-exe.github.io/InfiniBot/docs/getting-started/install-and-setup/#the-infinibot-mod-role) for more information.", color = nextcord.Color.red()) + # An earlier step (e.g. chunk_guild_with_feedback) may have already sent the + # initial response — use the followup in that case instead of double-responding + if interaction.response.is_done(): + await interaction.followup.send(embed = embed, ephemeral = True) + else: + await interaction.response.send_message(embed = embed, ephemeral = True) return False async def check_text_channel_permissions(channel: nextcord.abc.GuildChannel, auto_warn: bool, custom_channel_name: str = None) -> bool: From 5b003433dfc6cfee06b3bc6350aac0330a6bee40 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:40:43 -0600 Subject: [PATCH 037/109] Fix X1: link-only views use the finite default timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SupportView/InviteView/TopGG* views contain only link buttons but passed timeout=None, registering each sent instance in nextcord's view store forever (unbounded growth — these accompany many responses). Link buttons need no callback dispatch, so they keep working after the view expires. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Good change; I hope it'll help with the unbounded memory growth I've been having. --- src/components/ui_components.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/ui_components.py b/src/components/ui_components.py index 5eaaf24..da3b39c 100644 --- a/src/components/ui_components.py +++ b/src/components/ui_components.py @@ -448,21 +448,21 @@ async def continueButtonCallback(self, interaction: Interaction): # Common Add-On Views class SupportView(CustomView): def __init__(self): - super().__init__(timeout = None) + super().__init__() support_server_btn = nextcord.ui.Button(label = "Go to Support Server", style = nextcord.ButtonStyle.link, url = get_configs()["links.support-server-invite-link"]) self.add_item(support_server_btn) class InviteView(CustomView): def __init__(self): - super().__init__(timeout = None) + super().__init__() invite_btn = nextcord.ui.Button(label = "Add to Your Server", style = nextcord.ButtonStyle.link, url = get_configs()["links.bot-invite-link"]) self.add_item(invite_btn) class SupportAndInviteView(CustomView): def __init__(self): - super().__init__(timeout = None) + super().__init__() support_server_btn = nextcord.ui.Button(label = "Support Server", style = nextcord.ButtonStyle.link, url = get_configs()["links.support-server-invite-link"]) self.add_item(support_server_btn) @@ -472,7 +472,7 @@ def __init__(self): class SupportInviteAndTopGGVoteView(CustomView): def __init__(self): - super().__init__(timeout = None) + super().__init__() support_server_btn = nextcord.ui.Button(label = "Support Server", style = nextcord.ButtonStyle.link, url = get_configs()["links.support-server-invite-link"]) self.add_item(support_server_btn) @@ -485,7 +485,7 @@ def __init__(self): class SupportInviteAndTopGGReviewView(CustomView): def __init__(self): - super().__init__(timeout = None) + super().__init__() support_server_btn = nextcord.ui.Button(label = "Support Server", style = nextcord.ButtonStyle.link, url = get_configs()["links.support-server-invite-link"]) self.add_item(support_server_btn) @@ -498,7 +498,7 @@ def __init__(self): class TopGGVoteView(CustomView): def __init__(self): - super().__init__(timeout = None) + super().__init__() topGG_vote_btn = nextcord.ui.Button(label = "Vote for InfiniBot", style = nextcord.ButtonStyle.link, url = get_configs()["links.topgg-vote-link"]) self.add_item(topGG_vote_btn) @@ -508,7 +508,7 @@ def __init__(self): class TopGGAll(CustomView): def __init__(self): - super().__init__(timeout = None) + super().__init__() topGG_btn = nextcord.ui.Button(label = "Visit on Top.GG", style = nextcord.ButtonStyle.link, url = get_configs()["links.topgg-link"]) self.add_item(topGG_btn) From e21e3b584e37e7621c38c52cbaf406ef02816774 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:42:18 -0600 Subject: [PATCH 038/109] Fix S3: join-to-create VCs HTTPException handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only Forbidden was caught on create/move — an HTTPException (rate limit) propagated and could leave an orphaned empty VC that nothing deletes. Create failures are now logged and a failed move cleans up the just- created channel. Note: Originally, S3 included a rate limit, but I felt that this just made the user experience worse without actually solving real issues, so I removed it. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a possible issue that was properly resolved --- src/features/join_to_create_vcs.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/features/join_to_create_vcs.py b/src/features/join_to_create_vcs.py index 9ac9a7c..42212f5 100644 --- a/src/features/join_to_create_vcs.py +++ b/src/features/join_to_create_vcs.py @@ -4,6 +4,7 @@ from components import utils from config.server import Server + async def run_join_to_create_vc_member_update(member: nextcord.Member, before: nextcord.VoiceState, after: nextcord.VoiceState): """ |coro| @@ -47,7 +48,7 @@ async def run_join_to_create_vc_member_update(member: nextcord.Member, before: n return if not voice_channel.permissions_for(member.guild.me).manage_channels: return - + # Create a new voice channel in the same category category = after.channel.category if after.channel.category else member.guild try: @@ -56,13 +57,24 @@ async def run_join_to_create_vc_member_update(member: nextcord.Member, before: n except nextcord.errors.Forbidden: await utils.send_error_message_to_server_owner(member.guild, "Manage Channels", guild_permission=True) return - + except nextcord.errors.HTTPException as e: + logging.warning(f"Failed to create join-to-create VC in guild {member.guild.id}: {e}") + return + # Attempt to move the member to the new voice channel try: await member.move_to(new_vc) except nextcord.errors.Forbidden: await utils.send_error_message_to_server_owner(member.guild, "Move Members") return + except nextcord.errors.HTTPException as e: + # Don't leave an orphaned empty VC behind (nothing would ever delete it) + logging.warning(f"Failed to move member {member.id} to join-to-create VC in guild {member.guild.id}: {e}") + try: + await new_vc.delete() + except nextcord.errors.HTTPException: + pass + return # Handle member leaving a voice channel if before.channel is not None: From b82b359649d8ec5edd9f9b133ed49a3f07c15755 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:43:52 -0600 Subject: [PATCH 039/109] Fix B9: correct scheduler docstring interval and demote the noisy skip-log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docstring claimed a 5-minute interval (it's 15), and 'Logging skipped due to intended behavior.' was emitted at INFO on 6 of every 7 runs — now a clearer DEBUG message. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as technically an issue that was properly resolved. **Human Review Notes:** Sure... Not a big issue, but sure. --- src/core/scheduling.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/scheduling.py b/src/core/scheduling.py index c618037..bca3374 100644 --- a/src/core/scheduling.py +++ b/src/core/scheduling.py @@ -42,7 +42,7 @@ async def run_scheduled_tasks() -> None: log_on_correct_behavior = (current_time_utc.minute // INTERVAL_MINUTES) % 7 == 0 if (not log_on_correct_behavior): - logging.info("Logging skipped due to intended behavior.") + logging.debug("Skipping progress/summary logging for this run (only 1 in 7 runs logs them).") try: # Start new log if it is a new day @@ -175,8 +175,8 @@ async def run_scheduled_tasks() -> None: def start_scheduler() -> None: """ - Starts the scheduler. Scheduler will run the job function every 5 minutes, - aligned to the nearest 5-minute interval (00:00, 00:05, 00:10, etc.). + Starts the scheduler. Scheduler will run the job function every INTERVAL_MINUTES + minutes (currently 15), aligned to the interval grid (00:00, 00:15, 00:30, 00:45). """ global scheduler From abe1be7667731b83fca08ed79f80c045f9e6b04b Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:44:25 -0600 Subject: [PATCH 040/109] Fix L8: delete-log embed count matches what is actually attached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notice said '9/N are attached below' but the send attaches embeds[0:8] (8 embeds) alongside the log embed. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Geez, yeah. This was a bug. --- src/features/action_logging.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/action_logging.py b/src/features/action_logging.py index 721be36..111b487 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -450,7 +450,8 @@ async def trigger_delete_log(bot: nextcord.Client, channel: nextcord.TextChannel if message and message.embeds != []: attachedMessage = "Attached below" if len(message.embeds) > 9: - attachedMessage = f"9/{len(message.embeds)} are attached below" + # The send below attaches embeds[0:8] alongside the log embed (9 total) + attachedMessage = f"8/{len(message.embeds)} are attached below" embed.add_field(name = "Embeds", value = f"This message contained one or more embeds. ({attachedMessage})", inline = False) embeds = message.embeds From 23c7e11b406b70b9720eec56c399b958ed51e240 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:45:14 -0600 Subject: [PATCH 041/109] Fix L11: drop the redundant cache removal on message edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cache_message already updates an existing cached entry in place, so the preceding remove_cached_message call was unnecessary (and re-appending reset the message's FIFO position). Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/core/bot.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/core/bot.py b/src/core/bot.py index 8e5d3df..00b1854 100644 --- a/src/core/bot.py +++ b/src/core/bot.py @@ -554,9 +554,8 @@ async def on_raw_message_edit(payload: nextcord.RawMessageUpdateEvent) -> None: if edited_message is None: return - # Update the message's cache - with LogIfFailure(feature="cached_messages.remove_cached_message & cached_messages.cache_message"): - cached_messages.remove_cached_message(edited_message.id, channel.id) + # Update the message's cache (cache_message updates an existing entry in place) + with LogIfFailure(feature="cached_messages.cache_message"): cached_messages.cache_message(edited_message) # Resolve the author if it's not a Member object (e.g., if the member is not cached) From 9fc96b3881f4e2b60554ea06d5564a0eee7a24f5 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:45:54 -0600 Subject: [PATCH 042/109] Fix L13: remove the mmap round-trip copy in file_computation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attachment bytes were copied into an anonymous mmap and straight back out again before being wrapped in BytesIO — pure overhead. Also narrows the bare except to Exception. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Tbh, I have a limited understanding of all of this, but as far as I can tell this is a genuinely good change that actually prevents errors for 0 byte attachments. --- src/features/action_logging.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/features/action_logging.py b/src/features/action_logging.py index 111b487..d3dfa18 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -4,7 +4,6 @@ import io import logging import math -import mmap from typing import Union from nextcord import AuditLogAction, Interaction import nextcord @@ -118,18 +117,10 @@ async def file_computation(file: nextcord.Attachment) -> nextcord.File | None: :return: The processed file, or None if there was an error :rtype: nextcord.File or None """ - # ChatGPT :) try: file_bytes = await file.read() - - with mmap.mmap(-1, len(file_bytes), access=mmap.ACCESS_WRITE) as mem: - mem.write(file_bytes) - mem.seek(0) - file_data = bytes(mem) - - file = nextcord.File(io.BytesIO(file_data), file.filename, description=file.description, spoiler=file.is_spoiler()) - return file - except: + return nextcord.File(io.BytesIO(file_bytes), file.filename, description=file.description, spoiler=file.is_spoiler()) + except Exception: return None async def files_computation(deleted_message: nextcord.Message, log_channel: nextcord.TextChannel, log_message: nextcord.Message) -> None: From 8b1477a1246607d9c222d7a87f935472936db705 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:46:10 -0600 Subject: [PATCH 043/109] Fix L15: action-log embed timestamps are aware UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Naive datetime.now() serializes without a timezone and Discord interprets it as UTC, shifting displayed log times by the host's UTC offset. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a plausible issue that was properly resolved **Human Review Notes:** Like a previous commit, this change is good but the container always ran in UTC time so it was never really an issue. This just explicitly specifies UTC, which is good ig. --- src/features/action_logging.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/features/action_logging.py b/src/features/action_logging.py index d3dfa18..4966993 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -202,7 +202,7 @@ async def trigger_edit_log(guild: nextcord.Guild, original_message: nextcord.Mes embed_tasks = [] # Create an embed to edit - embed = nextcord.Embed(title = "Message Edited", description = edited_message.channel.mention, color = nextcord.Color.yellow(), timestamp = datetime.datetime.now(), url = edited_message.jump_url) + embed = nextcord.Embed(title = "Message Edited", description = edited_message.channel.mention, color = nextcord.Color.yellow(), timestamp = datetime.datetime.now(datetime.timezone.utc), url = edited_message.jump_url) # Check that the original message is still cached if not original_message: @@ -403,7 +403,7 @@ async def trigger_delete_log(bot: nextcord.Client, channel: nextcord.TextChannel # Send log information!!! ------------------------------------------------------------------------------------------------------------------------------------------- - embed = nextcord.Embed(title = "Message Deleted", color = nextcord.Color.red(), timestamp = datetime.datetime.now()) + embed = nextcord.Embed(title = "Message Deleted", color = nextcord.Color.red(), timestamp = datetime.datetime.now(datetime.timezone.utc)) embeds = [] code = 1 @@ -499,7 +499,7 @@ async def log_nickname_change(before: nextcord.Member, after: nextcord.Member, e if user else f"{after.mention}'s nickname was changed." ), color=nextcord.Color.blue(), - timestamp=datetime.datetime.now() + timestamp=datetime.datetime.now(datetime.timezone.utc) ) # Add fields for the old and new nicknames @@ -702,7 +702,7 @@ def __repr__(self): else: description = f"Someone modified {after.mention}'s roles." - embed = nextcord.Embed(title="Roles Modified", description=description, color=nextcord.Color.blue(), timestamp=datetime.datetime.now()) + embed = nextcord.Embed(title="Roles Modified", description=description, color=nextcord.Color.blue(), timestamp=datetime.datetime.now(datetime.timezone.utc)) if len(added_roles) > 0: embed.add_field(name="Added", value="\n".join(added_roles.mentions()), inline=True) @@ -755,7 +755,7 @@ async def log_timeout_change(before: nextcord.Member, after: nextcord.Member, en title="Member Timed-Out", description=f"{actor} timed out {after.mention} for about {timeout_time_ui_text}", color=nextcord.Color.orange(), - timestamp=datetime.datetime.now() + timestamp=datetime.datetime.now(datetime.timezone.utc) ) # Add a reason field if the audit log is fresh and a reason is provided @@ -771,7 +771,7 @@ async def log_timeout_change(before: nextcord.Member, after: nextcord.Member, en title="Timeout Revoked", description=f"{actor} revoked {after.mention}'s timeout", color=nextcord.Color.orange(), - timestamp=datetime.datetime.now() + timestamp=datetime.datetime.now(datetime.timezone.utc) ) # Send the embed to the log channel @@ -950,11 +950,11 @@ async def log_member_removal(guild: nextcord.Guild, member: nextcord.abc.User) - reason = entry.reason if entry.action == AuditLogAction.kick: - embed = nextcord.Embed(title = "Member Kicked", description = f"{user} kicked {member}.", color = nextcord.Color.red(), timestamp = datetime.datetime.now()) + embed = nextcord.Embed(title = "Member Kicked", description = f"{user} kicked {member}.", color = nextcord.Color.red(), timestamp = datetime.datetime.now(datetime.timezone.utc)) if reason: embed.add_field(name = "Reason", value = f"{reason}", inline = False) elif entry.action == AuditLogAction.ban: - embed = nextcord.Embed(title = "Member Banned", description = f"{user} banned {member}.", color = nextcord.Color.dark_red(), timestamp = datetime.datetime.now()) + embed = nextcord.Embed(title = "Member Banned", description = f"{user} banned {member}.", color = nextcord.Color.dark_red(), timestamp = datetime.datetime.now(datetime.timezone.utc)) if reason: embed.add_field(name = "Reason", value = f"{reason}", inline = False) else: From 81cb073db653dd30cc01749bfba12ab2024e0042 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:46:58 -0600 Subject: [PATCH 044/109] Fix U3: utils.timeout reports Failure Forbidden when the bot lacks Moderate Members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It returned 'Success revoked', so callers testing startswith('Success') deleted the strike as though the timeout had been applied. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Yeah, why was it like that before? Odd... --- src/components/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/utils.py b/src/components/utils.py index 9cadda2..39dd6bd 100644 --- a/src/components/utils.py +++ b/src/components/utils.py @@ -1147,10 +1147,10 @@ async def timeout(member: nextcord.Member, seconds: int, reason: str = None) -> """ # Check permissions if not member.guild.me.guild_permissions.moderate_members: - await send_error_message_to_server_owner(member.guild, None, - message = "InfiniBot is missing the **Moderate Members** permission which prevents it from timing out members.", + await send_error_message_to_server_owner(member.guild, None, + message = "InfiniBot is missing the **Moderate Members** permission which prevents it from timing out members.", administrator=False) - return "Success revoked" + return "Failure Forbidden" try: if seconds == 0: await member.edit(timeout=None, reason = reason) From 3bfd6493544c7eb28646ce0b9533e7e19393e545 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:47:59 -0600 Subject: [PATCH 045/109] Fix U7: owner-DM failure handling no longer swallows everything silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare except hid every failure (including CancelledError and real bugs) behind a debug message. Forbidden (DMs disabled) stays quiet; other exceptions are logged as warnings. Embed timestamp is aware UTC now too. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/components/utils.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/utils.py b/src/components/utils.py index 39dd6bd..b44df75 100644 --- a/src/components/utils.py +++ b/src/components/utils.py @@ -1056,7 +1056,7 @@ async def send_error_message_to_server_owner( embed = nextcord.Embed(title = f"Missing Permissions in \"{guild.name}\" Server", description = f"{message}", color = nextcord.Color.red()) embed.set_footer(text = "To opt out of dm notifications, use /opt_out_of_dms") - embed.timestamp = datetime.datetime.now() + embed.timestamp = datetime.datetime.now(datetime.timezone.utc) try: dm = await member.create_dm() @@ -1064,9 +1064,10 @@ async def send_error_message_to_server_owner( await dm.send(embed = embed, view = ErrorWhyAdminPrivilegesButton()) else: await dm.send(embed = embed) - except: + except nextcord.errors.Forbidden: logging.debug("Failed to send error message to server owner. This is likely because the owner has DMs disabled for InfiniBot. Skipping...") - pass + except Exception as e: + logging.warning(f"Failed to DM the owner of guild {guild.id}: {e}") # Add to the set of sent messages messages_sent.add((guild.id, permission, message, administrator, channel, guild_permission)) From 8796878f7b007c6982bf0c5594793bbf4ef825d3 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:48:31 -0600 Subject: [PATCH 046/109] Fix O4: BanButton label check matches the capitalized type the view passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_label compared _type == 'message' but OptionsView passes 'Message', so the button always read 'Ban Member' even for messages. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Yeah, I noticed this in prod one day, but I just thought "huh, must be intended behavior", not really thinking much of it. Good change. --- src/features/options_menu/ban_button.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/options_menu/ban_button.py b/src/features/options_menu/ban_button.py index 35cb291..90bf46f 100644 --- a/src/features/options_menu/ban_button.py +++ b/src/features/options_menu/ban_button.py @@ -8,7 +8,8 @@ class BanButton(OptionsButton): def get_label(self) -> str: - return "Ban Message Author" if self._type == "message" else "Ban Member" + # Case-insensitive: the view passes "Message" (capitalized) + return "Ban Message Author" if str(self._type).lower() == "message" else "Ban Member" async def load(self, interaction: Interaction, data: dict): self.message: nextcord.Message = data.get("message", None) From 3871e1a4d6ad23b05b11aad2bc40ac7853721d0b Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:49:10 -0600 Subject: [PATCH 047/109] Fix O7: color selects compare strings with == instead of identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit default=(option is original_color) relied on string interning; the current selection could silently fail to preselect. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** This is a part of python I never really understood well. I'll take the AI's word for it. --- src/features/options_menu/editing/embeds.py | 2 +- src/features/role_messages.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/options_menu/editing/embeds.py b/src/features/options_menu/editing/embeds.py index 84cc9d1..767726b 100644 --- a/src/features/options_menu/editing/embeds.py +++ b/src/features/options_menu/editing/embeds.py @@ -119,7 +119,7 @@ def __init__(self, outer): original_color = utils.get_string_from_discord_color(self.outer.message.embeds[0].color) select_options = [] for option in utils.COLOR_OPTIONS: - select_options.append(nextcord.SelectOption(label=option, value=option, default=(option is original_color))) + select_options.append(nextcord.SelectOption(label=option, value=option, default=(option == original_color))) self.select = nextcord.ui.Select(custom_id="edit_embed_color_select", placeholder="Choose a color", options=select_options) diff --git a/src/features/role_messages.py b/src/features/role_messages.py index cd7b5ea..d85ede1 100644 --- a/src/features/role_messages.py +++ b/src/features/role_messages.py @@ -328,7 +328,7 @@ def __init__(self, outer): original_color = utils.get_string_from_discord_color(self.outer.color) select_options = [] for option in utils.COLOR_OPTIONS: - select_options.append(nextcord.SelectOption(label=option, value=option, default=(option is original_color))) + select_options.append(nextcord.SelectOption(label=option, value=option, default=(option == original_color))) self.select = nextcord.ui.Select(custom_id="role_message_edit_color_select", placeholder="Choose a color", options=select_options) From c97f051dc333d09e95908403177fc1a26067e222 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:49:55 -0600 Subject: [PATCH 048/109] Fix C6: build the default-row DELETE with a proper ' AND ' join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rstrip(' AND') strips the characters space/A/N/D, not the suffix — a default value ending in any of those letters corrupted the query. Also returns 0 early when a table has no column defaults instead of emitting 'DELETE FROM t WHERE '. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/modules/database.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/modules/database.py b/src/modules/database.py index 699bb89..16ca111 100644 --- a/src/modules/database.py +++ b/src/modules/database.py @@ -262,12 +262,12 @@ def remove_extraneous_rows( return 0 column_defaults = self.all_column_defaults[table] + if not column_defaults: + return 0 - # Construct a SQL query to select entries matching the default state - select_query = f"DELETE FROM {table} WHERE " - for col_name, default_val in column_defaults.items(): - select_query += f"{col_name} = {default_val} AND " - select_query = select_query.rstrip(" AND") # Remove the trailing "AND" + # Construct a SQL query to select entries matching the default state. + conditions = " AND ".join(f"{col_name} = {default_val}" for col_name, default_val in column_defaults.items()) + select_query = f"DELETE FROM {table} WHERE {conditions}" # Execute the query return self.execute_query(select_query, commit=True, return_affected_rows=True) From 354a206ce04e2748d20ecc42366c78a8aebcf6c5 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:50:48 -0600 Subject: [PATCH 049/109] Fix C7: execute_query re-raises the original exception instead of wrapping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'raise Exception(e)' erased the exception type, so callers couldn't catch specific SQLAlchemy errors. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/modules/database.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/database.py b/src/modules/database.py index 16ca111..d667130 100644 --- a/src/modules/database.py +++ b/src/modules/database.py @@ -138,10 +138,10 @@ def execute_query( session.commit() return data if multiple_values else self.get_query_first_value(data) - except Exception as e: + except Exception: logging.error(f"Error executing SQL query: {sql}", exc_info=True) session.rollback() - raise Exception(e) + raise # preserve the original exception type for callers def build_database(self, build_file_path: str) -> None: """ From baa4dff07a84f9235c65707b930534d51e8c0844 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:51:08 -0600 Subject: [PATCH 050/109] Fix C12: shard-count math uses ceiling division MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (n // per) + 1 allocated an extra shard whenever the guild count divided evenly (e.g. 1000 guilds / 500 per shard -> 3 shards instead of 2). Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a good change that was properly implemented --- src/core/shard_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/shard_manager.py b/src/core/shard_manager.py index 23d3806..fd16cdb 100644 --- a/src/core/shard_manager.py +++ b/src/core/shard_manager.py @@ -40,7 +40,7 @@ def calculate_shard_count(): return None # Calculate optimal shard count - calculated_shards = max(1, (previous_guild_count // guilds_per_shard) + 1) + calculated_shards = max(1, -(-previous_guild_count // guilds_per_shard)) # ceil division - '// + 1' over-allocated a shard on exact multiples logging.info(f"Calculated {calculated_shards} shards for {previous_guild_count} guilds ({guilds_per_shard} guilds per shard)") return calculated_shards @@ -102,7 +102,7 @@ def log_and_store_shard_distribution(bot: nextcord.Client): # Intelligent recommendations (cached config) if sharding_config["enabled"]: guilds_per_shard = sharding_config["guilds-per-shard"] - optimal_shards = max(1, (total_guilds // guilds_per_shard) + 1) + optimal_shards = max(1, -(-total_guilds // guilds_per_shard)) # ceil division if max_guilds_per_shard > guilds_per_shard * 1.5: # 50% over target logging.warning(f"⚠️ HIGH SHARD LOAD: Some shards have {max_guilds_per_shard} guilds (target: {guilds_per_shard}). Consider restarting - next startup will use {optimal_shards} shards.") From 27bc78a5f2436ac95bfe2a1b124ae6c12f612cb1 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:51:47 -0600 Subject: [PATCH 051/109] Fix C14: LogIfFailure re-raises BaseExceptions like CancelledError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit __exit__ returned suppress for any exception type, swallowing KeyboardInterrupt/SystemExit/asyncio.CancelledError — which breaks shutdown and task cancellation. Non-Exception BaseExceptions now propagate. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** This is why AI is so helpful. I never knew this was a thing, but after looking it up, Claude's right. --- src/core/log_manager.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/core/log_manager.py b/src/core/log_manager.py index 7828d4c..5202e44 100644 --- a/src/core/log_manager.py +++ b/src/core/log_manager.py @@ -46,8 +46,13 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, traceback): if exc_type: + # Never suppress KeyboardInterrupt/SystemExit/CancelledError — swallowing + # them breaks shutdown and task cancellation + if not issubclass(exc_type, Exception): + return False + # If an error occurs, log it and include the UUID - logging.error(f"Error occurred {f"in feature: {self.feature}" if self.feature else ""} with ID {self.error_id}: {exc_value}", + logging.error(f"Error occurred {f"in feature: {self.feature}" if self.feature else ""} with ID {self.error_id}: {exc_value}", exc_info=(exc_type, exc_value, traceback)) return self.suppress From e18ae8e73b2402020a7f6e82c59e85bfd4f7db8e Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:52:26 -0600 Subject: [PATCH 052/109] Fix C16: rename the /set command function so it doesn't shadow the set builtin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Discord-facing command name stays the same (set explicitly via name=); only the module-level Python identifier changes. Technically, only the /set command needed to change, but for consistency all top-level groups were changed. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/core/bot.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/core/bot.py b/src/core/bot.py index 00b1854..9b6361a 100644 --- a/src/core/bot.py +++ b/src/core/bot.py @@ -235,13 +235,13 @@ def decorator(func): # SLASH COMMANDS ============================================================================================================================================================== @bot.slash_command(name="view", description="Requires Infinibot Mod", contexts=[nextcord.InteractionContextType.guild]) -async def view(interaction: Interaction): pass +async def view_group(interaction: Interaction): pass @bot.slash_command(name="set", description="Requires Infinibot Mod", contexts=[nextcord.InteractionContextType.guild]) -async def set(interaction: Interaction): pass +async def set_group(interaction: Interaction): pass @bot.slash_command(name="create", description="Requires Infinibot Mod", contexts=[nextcord.InteractionContextType.guild]) -async def create(interaction: Interaction): pass +async def create_group(interaction: Interaction): pass @bot.slash_command(name="help", description="Get help with InfiniBot") async def help(interaction: Interaction): @@ -260,7 +260,7 @@ async def dashboard_command(interaction: Interaction): async def profile_command(interaction: Interaction): await profile.run_profile_command(interaction) -@create.subcommand(name="infinibot-mod-role", description="Manually trigger InfiniBot to create the Infinibot Mod role") +@create_group.subcommand(name="infinibot-mod-role", description="Manually trigger InfiniBot to create the Infinibot Mod role") async def create_infinibot_mod_role(interaction: Interaction): role_created = await utils.check_server_for_infinibot_mod_role(interaction.guild) @@ -276,19 +276,19 @@ async def create_infinibot_mod_role(interaction: Interaction): await interaction.response.send_message(embed = embed, ephemeral = True, view=ui_components.SupportView()) # Moderation Commands -@view.subcommand(name="my-strikes", description="View your strikes") +@view_group.subcommand(name="my-strikes", description="View your strikes") async def my_strikes(interaction: Interaction): await moderation.run_my_strikes_command(interaction) -@view.subcommand(name="member-strikes", description="View another member's strikes. (Requires Infinibot Mod)") +@view_group.subcommand(name="member-strikes", description="View another member's strikes. (Requires Infinibot Mod)") async def view_member_strikes(interaction: Interaction, member: nextcord.Member): await moderation.run_view_member_strikes_command(interaction, member) -@set.subcommand(name="admin-channel", description="Use this channel to log strikes. Channel should only be viewable by admins. (Requires Infinibot Mod)") +@set_group.subcommand(name="admin-channel", description="Use this channel to log strikes. Channel should only be viewable by admins. (Requires Infinibot Mod)") async def set_admin_channel(interaction: Interaction): await moderation.run_set_admin_channel_command(interaction) -@set.subcommand(name="log-channel", description="Use this channel for logging. Channel should only be viewable by admins. (Requires Infinibot Mod)") +@set_group.subcommand(name="log-channel", description="Use this channel for logging. Channel should only be viewable by admins. (Requires Infinibot Mod)") async def set_log_channel(interaction: Interaction): await action_logging.run_set_log_channel_command(interaction) @@ -297,11 +297,11 @@ async def set_log_channel(interaction: Interaction): async def leaderboard(interaction: Interaction): await leveling.run_leaderboard_command(interaction) -@view.subcommand(name="level", description="View your or someone else's level.") +@view_group.subcommand(name="level", description="View your or someone else's level.") async def view_level(interaction: Interaction, member: nextcord.Member = SlashOption(description="The member to view the level of.", required=False)): await leveling.run_view_level_command(interaction, member) -@set.subcommand(name="level", description="Set levels for any individual (Requires Infinibot Mod)") +@set_group.subcommand(name="level", description="Set levels for any individual (Requires Infinibot Mod)") async def set_level(interaction: Interaction, member: nextcord.Member = SlashOption(description="The member to set the level of.", required=True), level: int = SlashOption(description="The level to set.", required=True)): @@ -309,23 +309,23 @@ async def set_level(interaction: Interaction, # Reaction Role Commands REACTIONROLETYPES = ["Letters", "Numbers", "Custom"] -@create.subcommand(name="reaction-role", description="Legacy: Create a message allowing users to add/remove roles by themselves. (Requires Infinibot Mod)") +@create_group.subcommand(name="reaction-role", description="Legacy: Create a message allowing users to add/remove roles by themselves. (Requires Infinibot Mod)") async def reaction_role_command(interaction: Interaction, type: str = SlashOption(choices=["Letters", "Numbers"]), mention_roles: bool = SlashOption(name="mention-roles", description="Mention the roles with @mention", required=False, default=True)): await reaction_roles.run_reaction_role_command(interaction, type, mention_roles) -@create.subcommand(name="custom-reaction-role", description="Legacy: Create a reaction role with customized emojis. (Requires Infinibot Mod)") +@create_group.subcommand(name="custom-reaction-role", description="Legacy: Create a reaction role with customized emojis. (Requires Infinibot Mod)") async def custom_reaction_role_command(interaction: Interaction, options: str = SlashOption(description="Format: \"👍 = @Member, 🥸 = @Gamer\""), mentionRoles: bool = SlashOption(name="mention_roles", description="Mention the roles with @mention", required = False, default = True)): await reaction_roles.run_custom_reaction_role_command(interaction, options, mentionRoles) # Embed Commands -@create.subcommand(name = "embed", description = "Create a beautiful embed!") +@create_group.subcommand(name = "embed", description = "Create a beautiful embed!") async def create_embed(interaction: Interaction, role: nextcord.Role = SlashOption(description = "Role to Ping", required = False)): await embeds.run_create_embed_command(interaction, role) # Role Message Commands -@create.subcommand(name = "role_message", description = "Create a message allowing users to add/remove roles by themselves. (Requires Infinibot Mod)") +@create_group.subcommand(name = "role_message", description = "Create a message allowing users to add/remove roles by themselves. (Requires Infinibot Mod)") async def create_role_message(interaction: Interaction): await role_messages.run_role_message_command(interaction) From 03d37f03c406801107b7a30ecf94d9bd9a603a62 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:53:58 -0600 Subject: [PATCH 053/109] Fix C3: stop defining __dict__ as a method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defining __dict__ as a method shadows the instance-dict descriptor and breaks vars()/copy/pickle. The db_manager and jokes versions were unused duplicates of to_dict() (removed); JSONFile's is renamed to to_dict(). No call sites existed. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a probable issue that was properly resolved --- src/config/file_manager.py | 5 ++--- src/core/db_manager.py | 7 ------- src/features/jokes.py | 3 --- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/config/file_manager.py b/src/config/file_manager.py index 33745eb..35e2089 100644 --- a/src/config/file_manager.py +++ b/src/config/file_manager.py @@ -327,7 +327,7 @@ def __len__(self) -> int: data = self._get_data() return len(data) - def __dict__(self) -> dict: + def to_dict(self) -> dict: """ Return a dictionary representation of the data in the JSON file. @@ -336,8 +336,7 @@ def __dict__(self) -> dict: :return: A dictionary representation of the data in the JSON file. :rtype: dict """ - data = self._get_data() - return data + return self._get_data(mutable=True) def add_variable(self, key, value) -> None: """ diff --git a/src/core/db_manager.py b/src/core/db_manager.py index 2448519..eae37c0 100644 --- a/src/core/db_manager.py +++ b/src/core/db_manager.py @@ -162,13 +162,6 @@ def to_dict(self) -> dict: return properties - def __dict__(self): - """ - Returns a dictionary representation of the object, including nested objects. - This is useful for serialization or debugging purposes. - """ - return self.to_dict() - def get_column_names_and_types(self): return self.database.all_column_types[self.table_name] diff --git a/src/features/jokes.py b/src/features/jokes.py index 7076d2a..712c412 100644 --- a/src/features/jokes.py +++ b/src/features/jokes.py @@ -52,9 +52,6 @@ def to_dict(self): return values - def __dict__(self): - return self.to_dict() - def __hash__(self): return hash((self.title, self.body, self.punchline)) From 5f3d01dd972ba9a19e4cfc467f10c2b26fa34042 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:54:34 -0600 Subject: [PATCH 054/109] Fix X2: ExpiringSet.add purges expired entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write-mostly instances (adds without reads) grew unboundedly because only the read paths purged. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/modules/custom_types.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/custom_types.py b/src/modules/custom_types.py index 9b19dfa..5e7dd65 100644 --- a/src/modules/custom_types.py +++ b/src/modules/custom_types.py @@ -56,6 +56,9 @@ def add(self, item): with self.lock: # Overwriting the timestamp effectively "renews" the item self.store[item] = time.time() + self.expiration_time + # Purge here too — a write-mostly set (adds without reads) would + # otherwise grow unboundedly until the next read + self._purge_expired() def remove(self, item): """Removes an item if present.""" From 6f702d9e674d4f6438948f62c5c798a2b2184016 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:55:14 -0600 Subject: [PATCH 055/109] Fix reaction-role force-chunk: resolve only the reacting member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_raw_reaction_add chunked the entire guild on every raw reaction in an unchunked guild (same gateway flood as L6); utils.get_member already resolves the single member needed. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/reaction_roles.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/features/reaction_roles.py b/src/features/reaction_roles.py index 7a89a64..ec9594c 100644 --- a/src/features/reaction_roles.py +++ b/src/features/reaction_roles.py @@ -336,9 +336,6 @@ async def run_raw_reaction_add(payload: nextcord.RawReactionActionEvent, bot: ne guild = _guild if guild == None: return - if not guild.chunked: - await guild.chunk() - if not guild.me: return From 7e8466b261af96d444fc9610bd5e32690d27b46b Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:57:09 -0600 Subject: [PATCH 056/109] Fix C11: cheaper existence check and attribute lookup in Simple_TableManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit does_entry_exist fetched entire rows (SELECT *) on every manager instantiation — now SELECT 1 ... LIMIT 1. Property getters checked 'name in dir(self)', building and sorting the full attribute list per read — now hasattr(). Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Good optimization change. --- src/core/db_manager.py | 2 +- src/modules/database.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/core/db_manager.py b/src/core/db_manager.py index eae37c0..753e2a7 100644 --- a/src/core/db_manager.py +++ b/src/core/db_manager.py @@ -224,7 +224,7 @@ def decorator(method): # Getter function for the property def getter(self): # Create the private attribute if it doesn't exist - if private_name not in dir(self): + if not hasattr(self, private_name): setattr(self, private_name, UNSET_VALUE) # If the private attribute doesn't have a value, get it from the SQL database diff --git a/src/modules/database.py b/src/modules/database.py index d667130..398b21c 100644 --- a/src/modules/database.py +++ b/src/modules/database.py @@ -435,11 +435,9 @@ def does_entry_exist(self, table: str, id: int) -> bool: bool: True if entry exists, False otherwise. """ id_sql_name = self.get_id_sql_name(table) - entry = self.execute_query( - f"SELECT * FROM {table} WHERE {id_sql_name} = :id", - args={"id": id}, - multiple_values=True, - ) + entry = self.execute_query(f"SELECT 1 FROM {table} WHERE {id_sql_name} = :id LIMIT 1", + args={"id": id}, + multiple_values=True) return bool(entry) def get_table_unique_entries(self, table: str) -> Generator[int, int, int]: From ed7daaac2ab4ede18bd0833c63a365606a18bb22 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 16:58:01 -0600 Subject: [PATCH 057/109] Fix O8: align the OptionsButton.load contract with its implementations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface docstring claimed the caller adds buttons based on the return value, but every implementation adds itself and the caller ignores returns. Docstring now states the real contract, and EditButton.load returns explicit booleans like its siblings. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** This didn't actually affect any runtime code, but it's definitely a good change for code cleanliness. --- src/features/options_menu/edit_button.py | 7 +++++-- src/features/options_menu/options_btn_interface.py | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/features/options_menu/edit_button.py b/src/features/options_menu/edit_button.py index 021ae90..51c7b0a 100644 --- a/src/features/options_menu/edit_button.py +++ b/src/features/options_menu/edit_button.py @@ -14,10 +14,13 @@ async def load(self, interaction: Interaction, data: dict): self.message_info: dict = data["message_info"] # Check Message Compatibility - if not interaction.guild: return - + if not interaction.guild: return False + if self.determine_editability(interaction): self.outer.add_item(self) + return True + + return False def determine_editability(self, interaction: Interaction): if not interaction.guild: return diff --git a/src/features/options_menu/options_btn_interface.py b/src/features/options_menu/options_btn_interface.py index befa175..5edbc4e 100644 --- a/src/features/options_menu/options_btn_interface.py +++ b/src/features/options_menu/options_btn_interface.py @@ -17,5 +17,6 @@ def get_label(self) -> str: @abstractmethod async def load(self, interaction: Interaction, data: dict) -> bool: - """Load the button with the provided data and return whether it should be added to the view.""" + """Load the button with the provided data. The button adds itself to the view + (self.outer.add_item(self)) when applicable and returns whether it did so.""" pass \ No newline at end of file From c543924a1ae9232e80484565e5a46a73bd384bca Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 17:00:48 -0600 Subject: [PATCH 058/109] docs: add FIX PASS banner to the July 2026 bug hunt report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records what was fixed, the B7 hardening, and the deliberately-unfixed items (false positives, policy decisions, intent-unclear behavior) with reasons. Line references in the report predate the fixes. Co-Authored-By: Claude Fable 5 **Human Notes:** For the supreme source of truth, refer to the commit history to see my notes for each commit—I reverted/modified some changes. --- .../claude-reports/bug_hunt_2026_07.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/documentation/claude-reports/bug_hunt_2026_07.md b/documentation/claude-reports/bug_hunt_2026_07.md index 9e4158e..5c7c63d 100644 --- a/documentation/claude-reports/bug_hunt_2026_07.md +++ b/documentation/claude-reports/bug_hunt_2026_07.md @@ -9,6 +9,42 @@ options menu). Documentation only — no code changes were made in this pass. # InfiniBot Bug Hunt — July 2026 +> ## FIX PASS — July 12, 2026 +> **Applied by:** Anthropic Claude Fable 5 via Claude Code (CLI), on branch `fix/bug-fixes` (one commit per finding; commit subjects reference the IDs). +> **Note:** all `file:line` references below predate the fixes — verify against the current tree. +> +> **Fixed (CONFIRMED findings):** +> B1/B2/B3, B4, B6, B9, B10, B11, B13/B14, C1/C4, C2, C3, C5, C6, C7, C8/C9, C11, C12, C13, +> C14, C15, C16, L1, L2, L3, L4, L5, L6, L7, L8, L9, L10/L12, L11, L13, L15, M1, M3, M4, +> O2, O3, O4, O5/O6, O7, O8, P1, S1, S2, S3, S4, U1, U2, U3, U7, X1, X2, X3, and the +> reaction-roles force-chunk. Test suite: **33/33 passing** (native and containerized). +> +> **Deliberate hardening (beyond CONFIRMED):** +> - **B7** (PLAUSIBLE-UNCERTAIN) — the `typed_property` getter now tolerates malformed/legacy +> stored values, per the verifier's "harden anyway" note. No reachable trigger existed. +> +> **Deliberately NOT fixed, and why:** +> - **O1, L14, U6, M6** — verified FALSE POSITIVE; no code change. The `send_robust` +> int/object-alternating variable (O1) remains fragile-but-working; refactor separately. +> - **B5** (Feb-29 birthdays) — product decision needed (Feb 28 vs Mar 1 fallback vs skip); +> fixing means choosing behavior for users. Decide, then it's a one-line SQL change. +> - **B12** (1-hour timezone cache TTL) — a design choice, not a defect; documented as cosmetic. +> - **LV1** (anti-spam XP multiplier math) — intent unclear vs the documented anti-spam design +> notebook; changing it silently could alter leveling behavior. Needs a decision on intent. +> - **LV2** — largely mitigated by the C8 fix (rank computation now costs one SELECT instead of +> N+1); a further SQL-side rank computation was skipped to preserve the tie-break semantics. +> - **C10** (`_init_regular_entry` default assumptions) — latent only; triggers only if a future +> schema adds a non-defaulted column. Left as-is to keep the fix pass behavior-preserving. +> - **U4** (`get_infinibot_mod_role` creates the role on lookup) — the auto-create side effect +> appears intentional (onboarding relies on it); changing it is a product decision. +> - **U5** (`standardize_str_indention` dead indent logic) — "fixing" would change the visible +> formatting of nearly every message the bot sends; current output is accepted behavior. +> - **S5** (autoban sleeps in `on_member_join`) — the sleeps look like deliberate settle/audit +> timing; they're async (don't block the loop) and removing them risks mistimed bans. +> - Pre-existing latent circular import `components.utils` ↔ `components.ui_components` +> (import-order dependent, works in the app's import order) — out of scope for this pass; +> noted for a future cleanup. + **Branch:** `bug-hunt-2026-07` **Scope:** Every `.py` file under `src/` (54 files, ~25.6k lines). **Method:** Manual read of every module, cross-checked against the installed `nextcord==3.2.0` From 5d250ca76d706518afbea531c52ac1923fa33d5f Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 19:10:51 -0600 Subject: [PATCH 059/109] Fix circular import between components.utils and components.ui_components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit utils.py imported ErrorWhyAdminPrivilegesButton at module level while ui_components.py imports utils, so importing ui_components before utils raised ImportError. The import is now deferred to the one call site. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a plausible issue that was properly resolved **Human Review Notes:** Okay, sure. This is better for code cleanliness. I never experienced a circular import issue, but the change certainly doesn't break anything, so it's fine. --- src/components/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/utils.py b/src/components/utils.py index b44df75..1181b0f 100644 --- a/src/components/utils.py +++ b/src/components/utils.py @@ -7,7 +7,6 @@ import nextcord from nextcord import Interaction -from components.ui_components import ErrorWhyAdminPrivilegesButton from config.global_settings import feature_dependencies, required_permissions, recently_left_guilds, get_global_kill_status, get_bot_load_status from modules.custom_types import ExpiringSet @@ -1058,6 +1057,10 @@ async def send_error_message_to_server_owner( embed.set_footer(text = "To opt out of dm notifications, use /opt_out_of_dms") embed.timestamp = datetime.datetime.now(datetime.timezone.utc) + # Imported here (not at module level) to avoid a circular import with + # components.ui_components, which imports this module. + from components.ui_components import ErrorWhyAdminPrivilegesButton + try: dm = await member.create_dm() if administrator: From 49f85454688047923797048daa2637fe2d878e9d Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 19:14:56 -0600 Subject: [PATCH 060/109] Fix O1: OptionsView always tracks the menu message by id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit send_robust stored the message *object* in this_message_id while the edit branch and disable_all expect an int, and it trusted InteractionResponse.edit_message to return a message — but nextcord resolves that from the local cache, which never holds these ephemeral menus, so message.id raised AttributeError on the ban-error recovery path and disable_all overwrote the id with None. this_message_id is now always an int (or None before the first send), and a None return from edit_message falls back to the id already on hand. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/options_menu/entrypoint_ui.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/features/options_menu/entrypoint_ui.py b/src/features/options_menu/entrypoint_ui.py index 25ddbbe..cd44d9e 100644 --- a/src/features/options_menu/entrypoint_ui.py +++ b/src/features/options_menu/entrypoint_ui.py @@ -77,7 +77,7 @@ async def setup(self, interaction: Interaction): async def show_loading(self, interaction: Interaction): embed = nextcord.Embed(title=f"{self.relevant_object_type} Options", description="Syncing Data. Please Wait...", color=nextcord.Color.blue()) await self.disable_all(interaction, edit=False) - self.this_message_id = await self.send_robust(interaction, embed=embed, view=self) + await self.send_robust(interaction, embed=embed, view=self) async def show_error(self, interaction: Interaction, description: str): embed = nextcord.Embed(title=f"{self.relevant_object_type} Options - Error", description=description, color=nextcord.Color.red()) @@ -87,16 +87,18 @@ async def send_robust(self, interaction: Interaction, **kwargs): if self.this_message_id is not None: try: message = await interaction.response.edit_message(**kwargs) - message_id = message.id + # edit_message returns the message only if it's in the local cache; + # ephemeral menus never are, so fall back to the id we already have. + message_id = message.id if message is not None else self.this_message_id except nextcord.errors.InteractionResponded: message = await interaction.followup.edit_message(self.this_message_id, **kwargs) message_id = message.id - + else: # Ensure **kwargs has ephemeral set to True, unless explicitly set to False if "ephemeral" not in kwargs: kwargs["ephemeral"] = True - + try: message = await interaction.response.send_message(**kwargs) message_id = (await message.fetch()).id @@ -104,7 +106,9 @@ async def send_robust(self, interaction: Interaction, **kwargs): message = await interaction.followup.send(**kwargs) message_id = message.id - self.this_message_id = message + # Always store the id (never the message object) so followup.edit_message + # and disable_all get the int they expect. + self.this_message_id = message_id return message_id async def show_options(self, interaction: Interaction): @@ -120,9 +124,9 @@ async def disable_all(self, interaction: Interaction, edit=True): if self.this_message_id is None: return try: - self.this_message_id = await interaction.response.edit_message(view=self, delete_after=0.0) + await interaction.response.edit_message(view=self, delete_after=0.0) except nextcord.errors.InteractionResponded: - await interaction.followup.edit_message(self.this_message_id.id, view=self, delete_after=0.0) + await interaction.followup.edit_message(self.this_message_id, view=self, delete_after=0.0) class MessageCommandOptionsView(OptionsView): From feeb209baeba4e99e12b68e94c643307f814e936 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 19:17:37 -0600 Subject: [PATCH 061/109] Fix L14: make the persistent log/moderation views safe as post-restart singletons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FALSE POSITIVE retraction held for live per-message instances but missed the orphan path: pre-restart messages all dispatch to the one init_views singleton (message_id=None fallback), Button.refresh_state is a no-op, and ViewStore purges finished views on every dispatch. - IncorrectButtonView no longer calls stop() (one "member no longer exists" click was permanently killing every other pre-restart button) and that path now responds with the edit so the disabled button renders. - ShowMoreButton derives its expand/collapse state from the message's rendered component label instead of button.label, which leaked between orphan messages and could strip a real embed via the collapse branch. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** I didn't know this was a thing. Thanks, Claude. --- src/features/action_logging.py | 15 ++++++++++++--- src/features/moderation.py | 6 ++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/features/action_logging.py b/src/features/action_logging.py index 4966993..8ebcda2 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -34,15 +34,24 @@ async def event(self, button: nextcord.ui.Button, interaction: nextcord.Interact await interaction.response.pong() return - if button.label == "Show More": + # Read the toggle state from the message itself, not from button.label: after a + # restart this view is a shared singleton handling every pre-restart message, so + # instance state leaks between messages and can strip a real embed. + rendered_label = "Show More" + for row in interaction.message.components: + for component in getattr(row, "children", []): + if getattr(component, "custom_id", None) == "show_more" and component.label: + rendered_label = component.label + + if rendered_label == "Show More": # Show more embed = interaction.message.embeds[0] code = embed.footer.text.split(" ")[-1] index = int(code) - 1 - + info_embed = nextcord.Embed(title = "More Information", color = nextcord.Color.red()) info_embed.add_field(name = self.possible_embeds[index][0], value = self.possible_embeds[index][1]) - + # Change the Name of the button button.label = "Show Less" diff --git a/src/features/moderation.py b/src/features/moderation.py index 83c26ac..fe4423d 100644 --- a/src/features/moderation.py +++ b/src/features/moderation.py @@ -82,10 +82,12 @@ async def event(self, button:nextcord.ui.Button, interaction: nextcord.Interacti return else: + # Respond with the edit so the disabled button actually renders; and never + # stop() here — after a restart this view is a shared singleton handling + # every pre-restart message, so stopping it would kill all of them. button.label = "Member no longer exists" button.disabled = True - - self.stop() + await interaction.response.edit_message(view=self) async def check_profanity_moderation_enabled_and_warn_if_not(interaction: nextcord.Interaction) -> bool: """ From 7a6c2716a5131e7cb66e16fd2cf120904fa8e8ab Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 19:18:06 -0600 Subject: [PATCH 062/109] Fix M2: timeout-revoke freshness check used .seconds (wraps at 24h) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a mod-log message older than a day, (now - created_at).seconds wraps and can pass the timeout_seconds comparison, taking the revoke-timeout branch instead of "No Available Actions". Use total_seconds(). Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/moderation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/moderation.py b/src/features/moderation.py index fe4423d..35781b6 100644 --- a/src/features/moderation.py +++ b/src/features/moderation.py @@ -64,7 +64,7 @@ async def event(self, button:nextcord.ui.Button, interaction: nextcord.Interacti # Has it been past the time that the timeout would have been? current_time = datetime.datetime.now(datetime.timezone.utc) message_time = interaction.message.created_at - delta_seconds = (current_time - message_time).seconds + delta_seconds = (current_time - message_time).total_seconds() if delta_seconds <= server.profanity_moderation_profile.timeout_seconds: # We can revoke the timeout await utils.timeout(member = member, seconds = 0, reason = "Revoking Profanity Moderation Timeout") From 3be87c00024ac90fb7b1fbeeb4d4d57f3fa3016c Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 19:20:39 -0600 Subject: [PATCH 063/109] Fix M6/L10 residual: treat legacy naive last_updated rows as UTC on read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows upserted via CURRENT_TIMESTAMP before the L10 fix are stored naive; fromisoformat() hands them to aware-datetime arithmetic in the edit/delete logging paths, which raises TypeError until those rows age out. Normalize at the two DB read sites (legacy values were already UTC). Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a possible issue that was properly resolved **Human Review Notes:** I think, because the container was in UTC anyway that this was never really an issue, but this code can't hurt. --- src/config/messages/stored_messages.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/config/messages/stored_messages.py b/src/config/messages/stored_messages.py index b6016a5..72c984c 100644 --- a/src/config/messages/stored_messages.py +++ b/src/config/messages/stored_messages.py @@ -7,6 +7,15 @@ from config.global_settings import get_configs +def _parse_last_updated(value: str) -> datetime.datetime: + """Parse a stored last_updated value, treating legacy naive rows + (written by SQLite's CURRENT_TIMESTAMP before the L10 fix) as UTC.""" + parsed = datetime.datetime.fromisoformat(value) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=datetime.timezone.utc) + return parsed + + def store_message_in_db(message: nextcord.Message | MessageRecord, override_checks=False) -> bool: """ Store a message in the database. @@ -68,7 +77,7 @@ def get_message_from_db(message_id: int) -> MessageRecord | None: channel_id=result[2], author_id=result[3], content=result[4], - last_updated=datetime.datetime.fromisoformat(result[5]) + last_updated=_parse_last_updated(result[5]) ) @@ -104,7 +113,7 @@ def get_all_messages_from_db(guild:nextcord.Guild=None, guild_id=None) -> list[M channel_id=message_data[2], author_id=message_data[3], content=message_data[4], - last_updated=datetime.datetime.fromisoformat(message_data[5]) + last_updated=_parse_last_updated(message_data[5]) ) messages.append(message) From 94361a632267b803a2a3e1f8309b83367ca5de14 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 19:22:37 -0600 Subject: [PATCH 064/109] Fix B5: celebrate Feb-29 birthdays on Feb 28 in non-leap years MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The month-day match was exact, so leap-day birthdays never fired three years out of four. Policy chosen: Feb 28 (the common convention). The SQL match includes 02-29 on Feb 28 of non-leap years, and calculate_age counts that day as the completed birthday so [age] is correct. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** This is tight code. I do approve. --- src/features/birthdays.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/features/birthdays.py b/src/features/birthdays.py index 84f3677..1c3708a 100644 --- a/src/features/birthdays.py +++ b/src/features/birthdays.py @@ -1,3 +1,4 @@ +import calendar import datetime import logging import re @@ -28,9 +29,14 @@ def calculate_age(local_datetime: datetime.datetime, birth_date: datetime.date | local_date = local_datetime.date() + # Feb-29 birthdays are celebrated on Feb 28 in non-leap years + birth_month_day = (birth_date.month, birth_date.day) + if birth_month_day == (2, 29) and not calendar.isleap(local_date.year): + birth_month_day = (2, 28) + # Calculate age by comparing year, month, and day age = local_date.year - birth_date.year - ( - (local_date.month, local_date.day) < (birth_date.month, birth_date.day) + (local_date.month, local_date.day) < birth_month_day ) return age @@ -111,19 +117,25 @@ async def check_and_run_birthday_actions( zoneinfo.ZoneInfo(server.infinibot_settings_profile.timezone or "UTC") ) - # Find members with birthdays (month-day match) + # Find members with birthdays (month-day match). + # On Feb 28 of a non-leap year, Feb-29 birthdays are celebrated too. + month_days = [local_datetime.strftime("%m-%d")] + if month_days[0] == "02-28" and not calendar.isleap(local_datetime.year): + month_days.append("02-29") + # Based on IntegratedList_TableManager's get_matching() and _get_all_matching_indexes() + placeholders = ", ".join(f":month_day_{i}" for i in range(len(month_days))) query = ( f"SELECT {server.birthdays.secondary_key_sql_name} " f"FROM {server.birthdays.table_name} " f"WHERE {server.birthdays.primary_key_sql_name} = :primary_key_value " - f"AND strftime('%m-%d', birth_date) = :month_day" + f"AND strftime('%m-%d', birth_date) IN ({placeholders})" ) raw_values = server.birthdays.database.execute_query( query, { "primary_key_value": server.birthdays.primary_key_value, - "month_day": local_datetime.strftime("%m-%d"), + **{f"month_day_{i}": month_day for i, month_day in enumerate(month_days)}, }, multiple_values=True, ) From 480b491ee4705e512971829a7a691b5dc5953402 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 19:26:02 -0600 Subject: [PATCH 065/109] Fix B3 residual: window-match all exact-minute gates in the scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The birthday fix (B1/B3) replaced exact-minute equality with window matching, but the scheduler's own daily/hourly gates kept minute == 0: a tick firing over a minute late (the loop-starvation misfire case B3 documented) silently skipped that day's log rotation, per-guild 3am maintenance, 9am DB maintenance, the hourly memory stats, and the cache cleanup. Each gate now accepts the first tick inside its window; ticks never fire early, so nothing can run twice. Co-Authored-By: Claude Fable 5 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/core/scheduling.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/core/scheduling.py b/src/core/scheduling.py index bca3374..30ef997 100644 --- a/src/core/scheduling.py +++ b/src/core/scheduling.py @@ -45,8 +45,9 @@ async def run_scheduled_tasks() -> None: logging.debug("Skipping progress/summary logging for this run (only 1 in 7 runs logs them).") try: - # Start new log if it is a new day - if current_time_utc.hour == 0 and current_time_utc.minute == 0: + # Start new log if it is a new day. Window match (not minute == 0) so a tick + # that fires late — the misfire case from B3 — still rotates the log. + if current_time_utc.hour == 0 and current_time_utc.minute < INTERVAL_MINUTES: logging.warning("New day, generating new log file") # Preserve the configured log level — the default (INFO) would silently # revert a DEBUG deployment every midnight @@ -104,7 +105,8 @@ async def run_scheduled_tasks() -> None: # Daily maintenance # 3am local time is a good time to run daily maintenance since it's a low traffic hour - if current_time_local.hour == 3 and current_time_local.minute == 0: + # (window match so a late-firing tick doesn't skip the whole day) + if current_time_local.hour == 3 and current_time_local.minute < INTERVAL_MINUTES: await daily_leveling_maintenance(bot, guild) await daily_moderation_maintenance(bot, guild) @@ -127,25 +129,24 @@ async def run_scheduled_tasks() -> None: f"Mem: {psutil.virtual_memory().percent}%" ) - if (current_time_utc.hour == 9 and current_time_utc.minute == 0): # 9am utc time = LOW TRAFFIC HOUR GLOBALLY + if (current_time_utc.hour == 9 and current_time_utc.minute < INTERVAL_MINUTES): # 9am utc time = LOW TRAFFIC HOUR GLOBALLY try: await daily_database_maintenance(bot) except Exception as e: logging.error(f"Daily database maintenance failed: {e}", exc_info=True) - # Memory monitoring (every hour at the top of the hour) - if current_time_utc.minute == 0: + # Memory monitoring (first tick of each hour) + if current_time_utc.minute < INTERVAL_MINUTES: try: log_memory_stats() except Exception as e: logging.error(f"Memory profiling failed: {e}", exc_info=True) - # Message cache cleanup (every 15 minutes) - if current_time_utc.minute % 15 == 0: - try: - cleanup_stale_channels() - except Exception as e: - logging.error(f"Message cache cleanup failed: {e}", exc_info=True) + # Message cache cleanup (every run — the ticks are the 15-minute cadence) + try: + cleanup_stale_channels() + except Exception as e: + logging.error(f"Message cache cleanup failed: {e}", exc_info=True) # Final monitoring report if log_on_correct_behavior: From d780b3e175a9da135a835ad48e4d0abd6b6bda44 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 12 Jul 2026 19:27:36 -0600 Subject: [PATCH 066/109] docs: record the July 13 re-review of deliberately-unfixed findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 **Human Notes:** Same thing: for the supreme source of truth, refer to the commit history to see my notes for each commit—I reverted/modified some changes. --- .../claude-reports/bug_hunt_2026_07.md | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/documentation/claude-reports/bug_hunt_2026_07.md b/documentation/claude-reports/bug_hunt_2026_07.md index 5c7c63d..d4b8d3f 100644 --- a/documentation/claude-reports/bug_hunt_2026_07.md +++ b/documentation/claude-reports/bug_hunt_2026_07.md @@ -23,27 +23,40 @@ options menu). Documentation only — no code changes were made in this pass. > - **B7** (PLAUSIBLE-UNCERTAIN) — the `typed_property` getter now tolerates malformed/legacy > stored values, per the verifier's "harden anyway" note. No reachable trigger existed. > -> **Deliberately NOT fixed, and why:** -> - **O1, L14, U6, M6** — verified FALSE POSITIVE; no code change. The `send_robust` -> int/object-alternating variable (O1) remains fragile-but-working; refactor separately. -> - **B5** (Feb-29 birthdays) — product decision needed (Feb 28 vs Mar 1 fallback vs skip); -> fixing means choosing behavior for users. Decide, then it's a one-line SQL change. -> - **B12** (1-hour timezone cache TTL) — a design choice, not a defect; documented as cosmetic. -> - **LV1** (anti-spam XP multiplier math) — intent unclear vs the documented anti-spam design -> notebook; changing it silently could alter leveling behavior. Needs a decision on intent. +> **Re-review pass — July 13, 2026** (every deliberately-unfixed item re-verified from scratch): +> - **O1** — the FALSE POSITIVE retraction was right about the standard-flow hang, but +> `InteractionResponse.edit_message` returns the message only from the local cache (None for +> these ephemeral menus), so `message.id` crashed the ban-error recovery path and `disable_all` +> stored None. **Fixed**: `this_message_id` is now always an int. +> - **L14** — FALSE POSITIVE for live per-message views, wrong for the post-restart singleton: +> `stop()` on it killed every pre-restart "Mark As Incorrect" button, and ShowMoreButton's +> label-based toggle leaked state between orphan messages (could strip a real embed). **Fixed.** +> - **M2** — only existed as a cross-cutting-theme mention; the `.seconds` wraparound in the +> timeout-revoke freshness check was real. **Fixed** (`total_seconds()`). +> - **M6** — FALSE POSITIVE verdict stands for the fallback, but legacy naive `CURRENT_TIMESTAMP` +> rows (pre-L10) still TypeError'd aware arithmetic at read time. **Fixed**: DB reads normalize +> naive `last_updated` to UTC. +> - **B5** (Feb-29 birthdays) — **fixed**; policy chosen: celebrate on Feb 28 in non-leap years +> (SQL match + `calculate_age`). +> - **B3 residual** — the scheduler's own `minute == 0` gates (log rotation, 3am/9am maintenance, +> memory stats, cache cleanup) had the same misfire fragility B3 documented. **Fixed** with +> window matching. +> - Circular import `components.utils` ↔ `components.ui_components` — **fixed** (lazy import at +> the one call site). +> - **U6** — FALSE POSITIVE verdict re-confirmed (all 27 features define a non-empty +> `global_kill`, so `key` is always bound). +> - **LV1** — resolved as NOT a bug: the design notebook's `interact` slider sets +> `forgiveness=3` as the chosen value, matching the code exactly. Behavior is as designed. +> - **B12** (1-hour timezone cache TTL) — re-confirmed a design choice, not a defect. +> - **C10** (`_init_regular_entry` default assumptions) — re-confirmed latent only; triggers only +> if a future schema adds a non-defaulted column. +> - **U4** — re-confirmed intentional (auto-create documented in both docstrings); the +> `guild.roles` "in-place mutation" is harmless — nextcord builds a fresh list per access. +> - **U5** — re-confirmed: the second loop is provably dead (all lines already lstripped) and the +> flattened output is accepted behavior; left untouched. +> - **S5** (autoban sleeps) — re-confirmed deliberate settle timing; async, doesn't block the loop. > - **LV2** — largely mitigated by the C8 fix (rank computation now costs one SELECT instead of > N+1); a further SQL-side rank computation was skipped to preserve the tie-break semantics. -> - **C10** (`_init_regular_entry` default assumptions) — latent only; triggers only if a future -> schema adds a non-defaulted column. Left as-is to keep the fix pass behavior-preserving. -> - **U4** (`get_infinibot_mod_role` creates the role on lookup) — the auto-create side effect -> appears intentional (onboarding relies on it); changing it is a product decision. -> - **U5** (`standardize_str_indention` dead indent logic) — "fixing" would change the visible -> formatting of nearly every message the bot sends; current output is accepted behavior. -> - **S5** (autoban sleeps in `on_member_join`) — the sleeps look like deliberate settle/audit -> timing; they're async (don't block the loop) and removing them risks mistimed bans. -> - Pre-existing latent circular import `components.utils` ↔ `components.ui_components` -> (import-order dependent, works in the app's import order) — out of scope for this pass; -> noted for a future cleanup. **Branch:** `bug-hunt-2026-07` **Scope:** Every `.py` file under `src/` (54 files, ~25.6k lines). From 68d9c810504765d5773ddd33121cf7897c397740 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 22:23:55 -0600 Subject: [PATCH 067/109] docs: document member-cache limitations for logging and generic replacements Nickname/role/timeout logging and the @joindate/@owner/@accountage placeholders all depend on nextcord's member cache, which InfiniBot doesn't proactively populate for large guilds. Document the best-effort behavior on the docs site and in the relevant docstrings so it's discoverable from both the user and developer sides. Co-Authored-By: Claude Opus 5 --- github-pages-site/docs/core-features/logging.md | 17 ++++++++++++----- .../docs/messaging/generic-replacements.md | 14 +++++++++++--- src/components/utils.py | 6 ++++++ src/core/bot.py | 6 +++++- src/features/action_logging.py | 9 +++++++++ 5 files changed, 43 insertions(+), 9 deletions(-) diff --git a/github-pages-site/docs/core-features/logging.md b/github-pages-site/docs/core-features/logging.md index d1e14eb..7bc761b 100644 --- a/github-pages-site/docs/core-features/logging.md +++ b/github-pages-site/docs/core-features/logging.md @@ -37,16 +37,16 @@ InfiniBot logs the following activities, categorized into three main types: ### Member Events -| Event Type | Description | -|--------------------|--------------------------------------| -| Nickname Changes | Tracks changes to member nicknames. | -| Role Changes | Logs updates to member roles. | +| Event Type | Description | +|--------------------|---------------------------------------------------| +| Nickname Changes | Tracks changes to member nicknames. (best-effort — see below) | +| Role Changes | Logs updates to member roles. (best-effort — see below) | ### Moderation Events | Event Type | Description | |--------------------|--------------------------------------| -| Member Timeouts | Logs when a member is timed out. | +| Member Timeouts | Logs when a member is timed out. (best-effort — see below) | | Member Bans | Tracks bans and unbans of members. | | Member Kicks | Logs when a member is kicked. | @@ -93,6 +93,13 @@ Removing message data from InfiniBot's database does NOT delete the message from For more information on InfiniBot's data handling and retention policies, please refer to our [Privacy Policy]({% link docs/legal/privacy-policy.md %}). +## Member Event Limitations + +Nickname changes, role changes, and timeouts can only be logged for members InfiniBot currently has cached. InfiniBot does not proactively load the full member lists, so a member who hasn't recently interacted with the server may not be cached, and a change to that member will silently go unlogged. + +After a member interacts with the server or updates their information (nickname, roles, etc.), they will be cached by InfiniBot, and subsequent changes to that member can be logged. + + ## Tips for Effective Logging - Set up a dedicated logging channel separate from your main admin channel and disable notifications for this channel to minimize distractions. diff --git a/github-pages-site/docs/messaging/generic-replacements.md b/github-pages-site/docs/messaging/generic-replacements.md index f7f1dfc..ca3a24c 100644 --- a/github-pages-site/docs/messaging/generic-replacements.md +++ b/github-pages-site/docs/messaging/generic-replacements.md @@ -22,15 +22,15 @@ Generic replacements can be used in embeds, join/leave messages, role messages, - `@username` - Shows the member's username - `@displayname` - Shows the member's display name - `@id` - Shows the member's Discord ID -- `@joindate` - Shows when the member joined the server -- `@accountage` - Shows how old the member's Discord account is +- `@joindate` - Shows when the member joined the server (best-effort; see below) +- `@accountage` - Shows how old the member's Discord account is (best-effort; see below) ## Server Replacements - `@server` - Shows the server name - `@serverid` - Shows the server's Discord ID - `@membercount` - Shows the total number of members in the server -- `@owner` - Shows the server owner's display name +- `@owner` - Shows the server owner's display name (best-effort; see below) ## Time and Date Replacements @@ -55,8 +55,16 @@ This would appear as: Welcome @discord-user to Awesome Server! Please check out #rules to get started! ``` +## Best-Effort Replacements + +{: .warning } +A few replacements depend on information Discord may not have handed InfiniBot at the moment the message is sent. When that information isn't available, the placeholder is replaced with the literal text **`Unknown`** rather than being left in place. + +This behavior is a deliberate trade-off: InfiniBot avoids requesting full member lists for large servers, since doing so is slow and rate-limited by Discord. In practice these replacements resolve correctly the majority of the time in smaller and moderately sized servers, where the entire member list is cached. For large servers, these fields frequently can't be resolved. See also the [Logging]({% link docs/core-features/logging.md %}) page, which has the same underlying limitation. + ## Usage Notes - These replacements work in most text-based messages sent by InfiniBot - Replacements are case-sensitive +- The user replacements (`@mention`, `@username`, `@displayname`, `@id`, `@joindate`, `@accountage`) only apply where a specific user is associated with the message. In contexts with no such user (for example, a role message or reaction role embed) these placeholders are left untouched and will appear literally in the message - If a replacement can't be processed, it will remain as plain text or appear as "Unknown" \ No newline at end of file diff --git a/src/components/utils.py b/src/components/utils.py index 1181b0f..669813f 100644 --- a/src/components/utils.py +++ b/src/components/utils.py @@ -329,6 +329,12 @@ def apply_generic_replacements( :type skip_placeholder_replacement: bool :return: The modified embed with replaced placeholders. :rtype: nextcord.Embed + + Best-effort fields: @joindate, @owner, and @accountage resolve to the literal + string "Unknown" when the underlying data isn't available (most commonly + @joindate on a leave message, or @owner when the guild owner isn't cached — + see get_guild_owner). See "Best-Effort Replacements" in + github-pages-site/docs/messaging/generic-replacements.md. """ def _apply_text_transform(transform, include_url: bool = True) -> None: # nextcord's .footer/.fields accessors return throwaway EmbedProxy objects, diff --git a/src/core/bot.py b/src/core/bot.py index 9b6361a..3e55e8a 100644 --- a/src/core/bot.py +++ b/src/core/bot.py @@ -644,6 +644,10 @@ async def on_member_update(before: nextcord.Member, after: nextcord.Member) -> N """ Handles member update events. + Note: nextcord only dispatches this event for members it already has cached + (there's no raw/uncached variant of this event) — see the caching-limitation + note on action_logging.log_member_update for what that means for logging. + :param before: The member before the update. :type before: nextcord.Member :param after: The member after the update. @@ -651,7 +655,7 @@ async def on_member_update(before: nextcord.Member, after: nextcord.Member) -> N :return: None :rtype: None """ - + # Log the update with LogIfFailure(feature="action_logging.log_member_update"): await action_logging.log_member_update(before, after) diff --git a/src/features/action_logging.py b/src/features/action_logging.py index 8ebcda2..b9201c6 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -860,6 +860,15 @@ async def log_member_update(before: nextcord.Member, after: nextcord.Member) -> Logs a member update event. + Caching limitation: this is only ever called for members nextcord already has + cached (see the on_member_update note below). Nextcord never evicts a cached + member for inactivity — only on guild leave — so once a member is cached + (message sent, command run, button clicked, etc., or the guild was chunked via + /dashboard or a purge) they stay logged for the rest of the process's life. + But a member who hasn't done any of that since the bot's last restart is + uncached, and their nickname/role/timeout changes go silently unlogged. See + "Member Event Limitations" in github-pages-site/docs/core-features/logging.md. + :param before: The member before the update. :type before: nextcord.Member :param after: The member after the update. From aa57dd7dc91ac7ffdc541c45ef7ffa3d5b4c37ad Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 20:44:59 -0600 Subject: [PATCH 068/109] docs: fresh-eyes bug hunt report (2026-07b), 27 findings pre-verification Co-Authored-By: Claude Fable 5 --- .../claude-reports/bug_hunt_2026-07b.md | 327 ++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 documentation/claude-reports/bug_hunt_2026-07b.md diff --git a/documentation/claude-reports/bug_hunt_2026-07b.md b/documentation/claude-reports/bug_hunt_2026-07b.md new file mode 100644 index 0000000..a8b2f0c --- /dev/null +++ b/documentation/claude-reports/bug_hunt_2026-07b.md @@ -0,0 +1,327 @@ + + +# InfiniBot Bug Hunt — July 2026 (second pass, "fresh eyes") + +**Branch:** `bug-hunt-2026-07b` (from `fix/bug-fixes` @ `8cdbb7f`) +**Scope:** 54 Python files (~29.2k lines) under `src/`, plus `resources/*.sql`, all +top-level `.bash` scripts, `.devcontainer/` (Dockerfile, compose, entrypoint), and +`pyproject.toml`. +**Method:** Every in-scope module was read in full. Library semantics were cross-checked +against the *installed* packages: nextcord **3.2.0** and SQLAlchemy **2.0.46** in +`.venv/` (e.g. verified that nextcord dispatches the `close` event, that SQLAlchemy's +pysqlite dialect sets `check_same_thread=False` for file DBs, and Session-per-call +connection semantics in `modules/database.py`). Per the user's request, the July 2026 +first-pass report was **not** read, to keep this scan uncorrupted. +Documentation only — **no code was changed.** + +**Reported symptoms:** none — this was a general sweep, not symptom-driven. + +## Severity legend + +- **CRITICAL** — data loss / security / breaks the bot broadly +- **HIGH** — breaks a common user-facing path +- **MEDIUM** — edge case with real user impact, or perf that compounds at scale +- **LOW** — cosmetic, latent, or rare-path + +--- + +## CRITICAL + +### D1 — Orphaned-guild cleanup's SQLite TEMP table does not survive across pooled connections; a stale empty copy can trigger mass deletion +**src/core/db_manager.py:1195-1273**, **src/modules/database.py:124-144** + +`Database.execute_query()` opens a **new Session (and pool connection) per call** +(`with self.Session() as session:`). `cleanup_orphaned_guild_entries()` executes +`CREATE TEMPORARY TABLE temp_valid_guilds` in one call, then INSERTs and per-table +`DELETE ... LEFT JOIN temp_valid_guilds` in *separate* calls. SQLite TEMP tables are +**per-connection**. The engine is a `QueuePool(pool_size=5)`; a second physical +connection is created whenever a checkout overlaps (which the surrounding +`daily_database_maintenance()` makes likely: its earlier phases run in +`asyncio.to_thread` worker threads while the event loop keeps serving interactions +that also call `execute_query`). Once ≥2 connections exist in the FIFO pool, +sequential `execute_query` calls alternate connections, so: +1. The `CREATE TEMP TABLE` and the `INSERT`/`DELETE` statements can land on different + connections → `no such table: temp_valid_guilds` → the whole cleanup aborts + (caught, logged) — benign-ish failure mode; **but also** +2. The `finally: DROP TABLE IF EXISTS temp_valid_guilds` runs on only *one* + connection. A populated-then-abandoned or **empty** `temp_valid_guilds` can + persist on another pooled connection indefinitely (pool connections are + long-lived). On a later run, a `DELETE ... LEFT JOIN temp_valid_guilds` that lands + on the connection holding a stale **empty** temp table matches **every row** → + deletion of all rows in every `#remove-if-guild-invalid` table (all per-guild + settings, strikes, levels, birthdays, message logs). +The code comment ("needs serial use of the pooled connection") shows awareness, but +nothing actually pins the statements to one connection. +**Fix direction:** run the whole cleanup on a single explicit connection +(`engine.connect()` held for the duration), or replace the temp table with +`DELETE ... WHERE guild_col NOT IN (SELECT ...)` using a real (non-TEMP) scratch table, +or batched `NOT IN` parameter lists. + +--- + +## HIGH + +### M1 — Nickname-profanity path DMs the member unguarded; a closed DM aborts the admin-channel report, and the DM ignores the /opt_out_of_dms setting +**src/features/moderation.py:213-241** (`check_and_punish_nickname_for_profanity`) + +`await member.send(embed=embed)` (line 221) is not wrapped in try/except and is not +gated on `Member(member.id).direct_messages_enabled`. Members with DMs closed are +common; `member.send` then raises `nextcord.Forbidden`, which propagates to the +caller's `LogIfFailure` — so the *admin channel* report (lines 223-241) is silently +skipped every time. Additionally, the embed's own footer says "To opt out of dm +notifications, use /opt_out_of_dms", but unlike the message-profanity path +(moderation.py:533-534), the opt-out flag is never checked here — opted-out users +still get this DM. +**Fix direction:** check `direct_messages_enabled` and wrap the send in +try/except Forbidden, mirroring `check_and_trigger_profanity_moderation_for_message`. + +### H1 — "Manage Members" strike list shows "no members with strikes" whenever a server has more than the display limit +**src/features/dashboard.py:790-813** (`ManageMembersView.get_members`) + +When the member count exceeds `limit` (25), the code appends the "N more…" row and +then executes a bare `return` — returning **None** instead of `returned_data`. The +caller (`setup`, line 776-780) does `if self.members:` → falsy → renders +"Your server doesn't have any members with strikes yet." So exactly the servers with +*many* strikes see an empty list. +**Fix direction:** `return returned_data` after appending the "more" row (or `break`). + +--- + +## MEDIUM + +### R1 — "Numbers" reaction roles: the 10th role gets emoji 1️⃣ again, making it unobtainable +**src/features/reaction_roles.py:218-224**, **src/components/utils.py:15-56** + +For `Numbers` type, `utils.asci_to_emoji(count)` is called with `count` up to 10 +(the select allows 10 roles). `asci_to_emoji("10")` matches nothing and recurses on +the default fallback `"1"` → returns 1️⃣, which is already used by role #1. The +duplicate reaction is a no-op on Discord (already present), and the reaction handler +matches only the first line with that emoji — so the 10th role can never be granted. +The same fallback logic affects `editing/reaction_roles.py` (`json_data["type"] == 1`, +line 262). +**Fix direction:** support 🔟 for 10 (or cap Numbers-type at 9 selections). + +### R2 — Role-message select options break Discord's 100-char value limit when an option grants ≥6 roles +**src/features/role_messages.py:48, 139** + +`SelectOption(value="|".join(roles))` packs role IDs (≈19 chars each) into the +option's `value`, which Discord caps at 100 characters. The creation wizard permits +up to ~40 roles per option (limited only by the 1024-char field text, +role_messages.py:809), so any option with 6+ roles produces `value` > 100 chars — +the "Get Role"/"Get Roles" button then fails (HTTP 400 / validation error) every +time it is clicked, permanently breaking that role message. +**Fix direction:** use the field index as the option value and resolve role IDs from +the embed field at callback time. + +### M2 — `communication_disabled_until is not None` mistakes an *expired* timeout for an active one +**src/features/moderation.py:529** (`check_and_trigger_profanity_moderation_for_message`) + +Discord leaves `communication_disabled_until` set to a **past** timestamp after a +timeout elapses (the field isn't reliably nulled on the cached member). The check +`timed_out = message.author.communication_disabled_until is not None` therefore +reports "timed out" for members whose old timeout expired, producing a wrong DM +("You were timed out for …") and wrong admin-channel text when they only received a +strike — and, when the strike record doesn't exist, the `strikes` lookup at line 549 +raises KeyError inside the DM try-block. +**Fix direction:** compare against `utcnow()` +(`cdu is not None and cdu > now`). + +### C1 — Log rotation leaks a file descriptor every midnight (removed handlers are never closed) +**src/core/log_manager.py:194-196** (`setup_logging`) + +The scheduler re-runs `setup_logging()` at every midnight tick (scheduling.py:50-54). +`setup_logging` does `logging.root.removeHandler(handler)` on the old handlers but +never calls `handler.close()`, so the old log file's descriptor stays open forever. +After N days of uptime the process holds N leaked FDs, and when +`organize_log_files()` deletes old date folders, the disk space of deleted-but-open +log files is not reclaimed until restart. +**Fix direction:** `handler.close()` after `removeHandler`. + +### J1 — Joke submission: missing `return` after `self.stop()` when the support server is unavailable → AttributeError +**src/features/jokes.py:336-368** (`SubmitJokeModal.callback`) + +`if server is None: self.stop()` falls through; `server.get_channel(...)` then +raises `AttributeError: 'NoneType' object has no attribute 'get_channel'`. Also +`channel` itself is never None-checked, so a mis-configured +`submission-channel-id` (valid int, wrong channel) crashes at `channel.send`. +The user gets the generic error embed instead of a useful message. +**Fix direction:** `return` after `stop()`; guard `channel is None`. + +### J2 — Joke deny flow responds to the same interaction twice → InteractionResponded error on every deny-with-DM +**src/features/jokes.py:450, 485** (`ConfirmView.confirm`) + +`interaction.response.edit_message(delete_after=0.0)` is called at line 450, and +again at line 485 ("Remove the message") after the DM block. The second call always +raises `nextcord.errors.InteractionResponded`, which surfaces through the view error +handler as a logged error + error embed to the moderator (after the deny actually +succeeded). +**Fix direction:** delete the duplicate response at line 485. + +### E1 — /create embed: crashes on empty color selection and on view/modal timeout +**src/features/embeds.py:79-81, 120-126, 144-146** + +(a) `EmbedColorView.create_callback` does `self.selection = self.select.values[0]` +*before* the emptiness check (`if self.selection == []` can never be true) — clicking +"Create" without selecting a color raises IndexError. +(b) If the info view or color view times out (5-min `VIEW_TIMEOUT`), `view.wait()` +returns and the code accesses `info_view.return_modal.title_value` / +`view.selection`, attributes that were never set → AttributeError. The user sees the +generic error embed. +**Fix direction:** check `self.select.values` before indexing; after `wait()`, +bail out when the modal/view produced no result. + +### C2 — Missing (unset) DISCORD_AUTH_TOKEN crashes with AttributeError instead of the intended fatal message +**src/main.py:35-53** (`get_token`) + +The existence check only logs a warning; the next line unconditionally does +`os.environ.get(var[0]).lower()` — when the variable is truly absent, `.get()` +returns None and `.lower()` raises AttributeError, so the friendly +"FATAL ERROR … EXITING" path never runs. +**Fix direction:** `(os.environ.get(var[0]) or "").lower()` or fold missing vars +into `unset_vars`. + +### M3 — Profanity regexes are recompiled for every message (and every nickname change) +**src/features/moderation.py:330** (`str_is_profane`) + +Each call compiles `len(filtered_words)` regex patterns (default list is large) — +per message, per guild. `filtered_words` itself is re-read through a fresh `Server` +per message. At thousands of messages/minute this is a measurable hot-path cost that +compounds with guild count. +**Fix direction:** cache compiled pattern lists keyed by (guild_id, words-list hash) +with TTL/invalidation on word edits. + +### R4 — Every reaction anywhere triggers member/channel/message resolution before checking whether the message is the bot's +**src/features/reaction_roles.py:332-355** (`run_raw_reaction_add`) + +For *every* `on_raw_reaction_add` in every guild, the handler resolves the member +(REST fetch on cache miss, 1-minute negative cache), the channel, and the message +(REST fetch — successful message fetches are not cached beyond nextcord's 1000-entry +cache) — and only *then* checks `message.author.id == bot.application_id`. On a busy +bot this multiplies REST traffic for reactions on messages InfiniBot could never act +on. Also `message.remove_reaction(...)` at line 410 is outside any try/except — +missing Manage Messages raises Forbidden into the default event error handler. +**Fix direction:** fetch the message first and early-exit on author; guard +`remove_reaction`; consider an LRU of known non-reaction-role message IDs. + +### B1 — Merely opening Dashboard → Birthdays silently activates birthdays at midnight UTC +**src/features/dashboard.py:3429, 3456** (`BirthdaysView.setup`) + +`server.birthdays_profile.runtime = server.birthdays_profile.runtime or "00:00:00"` +writes a runtime the moment an admin *views* the page. The scheduler treats any +non-UNSET runtime as armed (birthdays.py:99), so birthdays start firing at 00:00 UTC +even though the admin never picked a time — and the "⚠️ You must set a message time" +warning at line 3456 is dead code (runtime was just set two lines earlier). +**Fix direction:** keep runtime UNSET until the user actually sets a time; make the +warning check the pre-assignment value. + +### X1 — A failed autoban (missing permission) permanently deletes the autoban entry +**src/features/autobans.py:24-40** (`check_and_run_autoban_for_member`) + +When `member.ban()` raises Forbidden, the code still falls through to +`server.autobans.delete(member.id)` and returns True. The autoban silently vanishes +after a single failed attempt, and the join is treated as "autobanned" (welcome +message and default roles are skipped) even though the member stayed. +**Fix direction:** only delete the entry when the ban succeeded (or keep it and +notify the owner it will retry). + +--- + +## LOW + +### DASH2 — Strike-edit dropdown never preselects the member's current strike count +**src/features/dashboard.py:867-872** — `server.moderation_strikes.get(member_id, 0)` +returns a row *dataclass* (or 0), which is compared to `int` levels +(`default=(level==self.member_strikes)`) — never equal for members with strikes, so +no option is preselected. Should compare `.strikes`. + +### DASH3 — Dashboard strike edits store naive `datetime.now()` for `last_strike` +**src/features/dashboard.py:897, 902** — everywhere else `last_strike` is stored +UTC-aware. `parse_datetime_string` assumes naive = UTC on read, so on a host whose +local TZ ≠ UTC the expiry math shifts by the offset. (Container runs UTC → latent.) +Related cosmetic naive timestamps: join/leave embed timestamps +(join_leave_messages.py:52, 112) and moderation embed timestamps +(moderation.py:563, 633). + +### DASH4 — Channel-select pages crash when no channel is selectable +**src/features/dashboard.py:616-639, 1791-1822** and **src/components/ui_components.py:331-333** +— `SelectView` raises ValueError on an empty options list. If InfiniBot cannot +send/view in *any* text channel, opening the profanity Admin-Channel page (which is +force-redirected to when the channel is UNSET) or Logging channel page throws the +generic error embed instead of an actionable message. + +### DASH5 — Level-reward "Choose Level" modal crashes on non-numeric input +**src/features/dashboard.py:2260-2262** — `level = int(self.input.value)` has no +validation (unlike its sibling modals) → ValueError → generic modal error. + +### L1 — Daily leveling maintenance deletes zero-point rows one day late +**src/features/leveling.py:250-255** — the cleanup checks the *pre-decrement* value +(`member_level_info.points == 0`) instead of the freshly computed `_points`, so a +member who just hit 0 keeps their row until the next day's run. Harmless but +presumably not the intent. + +### M4 — Strikes of members who left the guild are never cleaned up +**src/features/moderation.py:977-981** — `daily_moderation_maintenance` skips rows +whose member is no longer resolvable (`continue`), unlike leveling maintenance which +deletes them (leveling.py:233-237). Rows for departed members persist until the +guild itself is orphaned. + +### A1 — Delete-log "Show More" can push the message over the 10-embed limit +**src/features/action_logging.py:453-475** + **:58-59** — a deleted message carrying +exactly 9 embeds produces a 10-embed log message; clicking "Show More" appends an +11th embed → HTTP 400 on `edit_message`, surfaced as the generic view error. + +### J3 — /joke crashes when the jokes list is empty +**src/features/jokes.py:718-724** — `random.choice(all_jokes)` raises IndexError on +an empty list (e.g. jokes.json failed to load); the `if joke is None` guard after it +can never fire. + +### J4 — Deny flow stores per-interaction state on the shared persistent view +**src/features/jokes.py:509-513** — `self.member_id` is written to the +`JokeVerificationView` singleton registered in `init_views`; two moderators denying +different submissions concurrently can cross their member lookups (an `await` sits +between write and use). + +### R3 — Custom reaction-role parsing depends on exactly one space around `=` +**src/features/reaction_roles.py:159, 248** — `option.split("=")[1][4:-1]` assumes +the value is ` <@&ID>` (one leading space). `👍=<@&123>` (no space) slices off a +digit and fails; two spaces mis-slices too. Falls into the generic "You formatted +that wrong" path even for reasonable input. A regex (`<@&(\d+)>`) would be robust. + +### X2 — /opt_out_of_dms embeds reference slash commands with dashes that don't exist +**src/features/dm_commands.py:57, 89** — text says `/opt-into-dms` / +`/opt-out-of-dms`; the registered commands are `/opt_into_dms` / `/opt_out_of_dms`. + +### C3 — `on_ready` re-runs registration work on session re-establishment +**src/core/bot.py:115-188** — nextcord can dispatch `on_ready` again after an +invalidated session resume. `start_scheduler()` handles restart, but `init_views` +re-registers all 10 persistent views (view-store growth), the emoji cache refetches +(fine), and the "STARTUP COMPLETE" log reuses the original `startup_time`, reporting +nonsense durations. Latent memory/log noise, not a crash. + +### X3 — Autoban add-modal passes a string user ID to `utils.get_member` +**src/features/dashboard.py:4640-4656** — `user_id` stays a `str`; the +`guild.get_member(user_id)` cache lookup always misses (dict keyed by int), forcing +a REST fetch on every check. Works, but defeats the cache; `int(user_id)` before +the call fixes it. + +--- + +## Infra notes (no defects found worth an ID) + +- `build.bash` references an undefined `buildx_cache_args` array — harmless empty + expansion (no `set -u`), just cruft alongside the also-unused `buildx_cache=""`. +- `entrypoint.sh` runs `python3 -OO`; no `assert` statements exist in `src/` outside + tests, so nothing is silently stripped. +- `db_build.sql`: most CREATE TABLE statements lack trailing semicolons, but + `build_database()` splits on blank lines and executes chunks individually, so + each parses fine. The `#optimize`/`#remove-if-guild-invalid` tag comments are + correctly confined to the first line of each statement. From a8bf5f79bbba93cb6684351008fc193fa13b1902 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 20:52:03 -0600 Subject: [PATCH 069/109] docs: annotate 2026-07b bug hunt with verification verdicts (26 confirmed, 2 false positive) Co-Authored-By: Claude Fable 5 --- .../claude-reports/bug_hunt_2026-07b.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/documentation/claude-reports/bug_hunt_2026-07b.md b/documentation/claude-reports/bug_hunt_2026-07b.md index a8b2f0c..2e3e45d 100644 --- a/documentation/claude-reports/bug_hunt_2026-07b.md +++ b/documentation/claude-reports/bug_hunt_2026-07b.md @@ -25,6 +25,42 @@ Documentation only — **no code was changed.** **Reported symptoms:** none — this was a general sweep, not symptom-driven. +## Verification status + +All 28 findings were independently re-checked by bug-verifier subagents (6 batches, +static tracing against the repo plus installed nextcord 3.2.0 / SQLAlchemy 2.0.46 +source). Result: **26 CONFIRMED, 2 FALSE POSITIVE, 0 PLAUSIBLE-UNCERTAIN.** + +Exceptions and corrections found by the verifiers: + +- **M2 — FALSE POSITIVE.** nextcord 3.2.0's `Member.communication_disabled_until` + is a computed property that returns `None` once the timeout is in the past + (nextcord/member.py:658-671), so an expired timeout can never read as active. + The finding's premise is impossible; retained below for the record. +- **J4 — FALSE POSITIVE.** There is no `await` between the shared-state write + (jokes.py:510) and its use as a call argument (jokes.py:513) — coroutine + arguments are evaluated synchronously before any suspension — so the claimed + race window does not exist. Retained below for the record. +- **C3 — CONFIRMED in part.** The `on_ready` re-fire and stale `startup_time` + logging are confirmed against nextcord's `AutoShardedConnectionState.parse_ready`. + The view-store-accumulation sub-claim is **refuted**: `ViewStore` keys entries by + `(component_type, message_id, custom_id)`, so repeated `add_view` overwrites + rather than accumulates. +- **M3 — CONFIRMED with corrections.** The uncached per-message recompilation is + real (up to 150 patterns for servers with large custom lists), but two supporting + details were wrong: the *default* word list is small (~10 entries), and the + "re module 512-pattern cache thrashing" point is moot because explicit + `re.compile` bypasses that cache entirely. +- **D1 — CONFIRMED root cause; worst case not independently reproduced.** The + verifier confirmed Session-per-call checkout, FIFO `QueuePool` (default + `use_lifo=False` in SQLAlchemy 2.0.46), and that nothing enforces the + "serial use of the pooled connection" assumption. The mass-delete outcome is a + plausible consequence of the confirmed root cause, requiring a specific + multi-run sequence that static tracing cannot prove end-to-end. +- **R2 — CONFIRMED mechanism.** The verifier could not establish whether the + failure surfaces as a client-side ValueError or a Discord-side 400, but the + 100-char overflow with 6+ roles per option is confirmed reachable. + ## Severity legend - **CRITICAL** — data loss / security / breaks the bot broadly @@ -37,6 +73,7 @@ Documentation only — **no code was changed.** ## CRITICAL ### D1 — Orphaned-guild cleanup's SQLite TEMP table does not survive across pooled connections; a stale empty copy can trigger mass deletion +**[VERIFIED: CONFIRMED — root cause verified against SQLAlchemy 2.0.46 source; worst-case mass delete is a plausible consequence, not independently reproduced]** **src/core/db_manager.py:1195-1273**, **src/modules/database.py:124-144** `Database.execute_query()` opens a **new Session (and pool connection) per call** @@ -71,6 +108,7 @@ or batched `NOT IN` parameter lists. ## HIGH ### M1 — Nickname-profanity path DMs the member unguarded; a closed DM aborts the admin-channel report, and the DM ignores the /opt_out_of_dms setting +**[VERIFIED: CONFIRMED]** **src/features/moderation.py:213-241** (`check_and_punish_nickname_for_profanity`) `await member.send(embed=embed)` (line 221) is not wrapped in try/except and is not @@ -85,6 +123,7 @@ still get this DM. try/except Forbidden, mirroring `check_and_trigger_profanity_moderation_for_message`. ### H1 — "Manage Members" strike list shows "no members with strikes" whenever a server has more than the display limit +**[VERIFIED: CONFIRMED]** **src/features/dashboard.py:790-813** (`ManageMembersView.get_members`) When the member count exceeds `limit` (25), the code appends the "N more…" row and @@ -99,6 +138,7 @@ caller (`setup`, line 776-780) does `if self.members:` → falsy → renders ## MEDIUM ### R1 — "Numbers" reaction roles: the 10th role gets emoji 1️⃣ again, making it unobtainable +**[VERIFIED: CONFIRMED]** **src/features/reaction_roles.py:218-224**, **src/components/utils.py:15-56** For `Numbers` type, `utils.asci_to_emoji(count)` is called with `count` up to 10 @@ -111,6 +151,7 @@ line 262). **Fix direction:** support 🔟 for 10 (or cap Numbers-type at 9 selections). ### R2 — Role-message select options break Discord's 100-char value limit when an option grants ≥6 roles +**[VERIFIED: CONFIRMED — overflow mechanism confirmed; client-side vs Discord-side failure mode undetermined]** **src/features/role_messages.py:48, 139** `SelectOption(value="|".join(roles))` packs role IDs (≈19 chars each) into the @@ -123,6 +164,7 @@ time it is clicked, permanently breaking that role message. the embed field at callback time. ### M2 — `communication_disabled_until is not None` mistakes an *expired* timeout for an active one +**[VERIFIED: FALSE POSITIVE — nextcord computes communication_disabled_until freshness at access time (member.py:658-671); premise impossible]** **src/features/moderation.py:529** (`check_and_trigger_profanity_moderation_for_message`) Discord leaves `communication_disabled_until` set to a **past** timestamp after a @@ -136,6 +178,7 @@ raises KeyError inside the DM try-block. (`cdu is not None and cdu > now`). ### C1 — Log rotation leaks a file descriptor every midnight (removed handlers are never closed) +**[VERIFIED: CONFIRMED]** **src/core/log_manager.py:194-196** (`setup_logging`) The scheduler re-runs `setup_logging()` at every midnight tick (scheduling.py:50-54). @@ -147,6 +190,7 @@ log files is not reclaimed until restart. **Fix direction:** `handler.close()` after `removeHandler`. ### J1 — Joke submission: missing `return` after `self.stop()` when the support server is unavailable → AttributeError +**[VERIFIED: CONFIRMED]** **src/features/jokes.py:336-368** (`SubmitJokeModal.callback`) `if server is None: self.stop()` falls through; `server.get_channel(...)` then @@ -157,6 +201,7 @@ The user gets the generic error embed instead of a useful message. **Fix direction:** `return` after `stop()`; guard `channel is None`. ### J2 — Joke deny flow responds to the same interaction twice → InteractionResponded error on every deny-with-DM +**[VERIFIED: CONFIRMED — second edit_message always raises InteractionResponded when a member was resolved]** **src/features/jokes.py:450, 485** (`ConfirmView.confirm`) `interaction.response.edit_message(delete_after=0.0)` is called at line 450, and @@ -167,6 +212,7 @@ succeeded). **Fix direction:** delete the duplicate response at line 485. ### E1 — /create embed: crashes on empty color selection and on view/modal timeout +**[VERIFIED: CONFIRMED — both sub-claims]** **src/features/embeds.py:79-81, 120-126, 144-146** (a) `EmbedColorView.create_callback` does `self.selection = self.select.values[0]` @@ -180,6 +226,7 @@ generic error embed. bail out when the modal/view produced no result. ### C2 — Missing (unset) DISCORD_AUTH_TOKEN crashes with AttributeError instead of the intended fatal message +**[VERIFIED: CONFIRMED]** **src/main.py:35-53** (`get_token`) The existence check only logs a warning; the next line unconditionally does @@ -190,6 +237,7 @@ returns None and `.lower()` raises AttributeError, so the friendly into `unset_vars`. ### M3 — Profanity regexes are recompiled for every message (and every nickname change) +**[VERIFIED: CONFIRMED — mechanism real; see Verification status for two corrected details]** **src/features/moderation.py:330** (`str_is_profane`) Each call compiles `len(filtered_words)` regex patterns (default list is large) — @@ -200,6 +248,7 @@ compounds with guild count. with TTL/invalidation on word edits. ### R4 — Every reaction anywhere triggers member/channel/message resolution before checking whether the message is the bot's +**[VERIFIED: CONFIRMED — both sub-claims]** **src/features/reaction_roles.py:332-355** (`run_raw_reaction_add`) For *every* `on_raw_reaction_add` in every guild, the handler resolves the member @@ -213,6 +262,7 @@ missing Manage Messages raises Forbidden into the default event error handler. `remove_reaction`; consider an LRU of known non-reaction-role message IDs. ### B1 — Merely opening Dashboard → Birthdays silently activates birthdays at midnight UTC +**[VERIFIED: CONFIRMED — all three sub-claims]** **src/features/dashboard.py:3429, 3456** (`BirthdaysView.setup`) `server.birthdays_profile.runtime = server.birthdays_profile.runtime or "00:00:00"` @@ -224,6 +274,7 @@ warning at line 3456 is dead code (runtime was just set two lines earlier). warning check the pre-assignment value. ### X1 — A failed autoban (missing permission) permanently deletes the autoban entry +**[VERIFIED: CONFIRMED]** **src/features/autobans.py:24-40** (`check_and_run_autoban_for_member`) When `member.ban()` raises Forbidden, the code still falls through to @@ -238,12 +289,14 @@ notify the owner it will retry). ## LOW ### DASH2 — Strike-edit dropdown never preselects the member's current strike count +**[VERIFIED: CONFIRMED]** **src/features/dashboard.py:867-872** — `server.moderation_strikes.get(member_id, 0)` returns a row *dataclass* (or 0), which is compared to `int` levels (`default=(level==self.member_strikes)`) — never equal for members with strikes, so no option is preselected. Should compare `.strikes`. ### DASH3 — Dashboard strike edits store naive `datetime.now()` for `last_strike` +**[VERIFIED: CONFIRMED]** **src/features/dashboard.py:897, 902** — everywhere else `last_strike` is stored UTC-aware. `parse_datetime_string` assumes naive = UTC on read, so on a host whose local TZ ≠ UTC the expiry math shifts by the offset. (Container runs UTC → latent.) @@ -252,6 +305,7 @@ Related cosmetic naive timestamps: join/leave embed timestamps (moderation.py:563, 633). ### DASH4 — Channel-select pages crash when no channel is selectable +**[VERIFIED: CONFIRMED]** **src/features/dashboard.py:616-639, 1791-1822** and **src/components/ui_components.py:331-333** — `SelectView` raises ValueError on an empty options list. If InfiniBot cannot send/view in *any* text channel, opening the profanity Admin-Channel page (which is @@ -259,48 +313,57 @@ force-redirected to when the channel is UNSET) or Logging channel page throws th generic error embed instead of an actionable message. ### DASH5 — Level-reward "Choose Level" modal crashes on non-numeric input +**[VERIFIED: CONFIRMED]** **src/features/dashboard.py:2260-2262** — `level = int(self.input.value)` has no validation (unlike its sibling modals) → ValueError → generic modal error. ### L1 — Daily leveling maintenance deletes zero-point rows one day late +**[VERIFIED: CONFIRMED]** **src/features/leveling.py:250-255** — the cleanup checks the *pre-decrement* value (`member_level_info.points == 0`) instead of the freshly computed `_points`, so a member who just hit 0 keeps their row until the next day's run. Harmless but presumably not the intent. ### M4 — Strikes of members who left the guild are never cleaned up +**[VERIFIED: CONFIRMED]** **src/features/moderation.py:977-981** — `daily_moderation_maintenance` skips rows whose member is no longer resolvable (`continue`), unlike leveling maintenance which deletes them (leveling.py:233-237). Rows for departed members persist until the guild itself is orphaned. ### A1 — Delete-log "Show More" can push the message over the 10-embed limit +**[VERIFIED: CONFIRMED]** **src/features/action_logging.py:453-475** + **:58-59** — a deleted message carrying exactly 9 embeds produces a 10-embed log message; clicking "Show More" appends an 11th embed → HTTP 400 on `edit_message`, surfaced as the generic view error. ### J3 — /joke crashes when the jokes list is empty +**[VERIFIED: CONFIRMED]** **src/features/jokes.py:718-724** — `random.choice(all_jokes)` raises IndexError on an empty list (e.g. jokes.json failed to load); the `if joke is None` guard after it can never fire. ### J4 — Deny flow stores per-interaction state on the shared persistent view +**[VERIFIED: FALSE POSITIVE — no await between write (jokes.py:510) and use (jokes.py:513); no race window]** **src/features/jokes.py:509-513** — `self.member_id` is written to the `JokeVerificationView` singleton registered in `init_views`; two moderators denying different submissions concurrently can cross their member lookups (an `await` sits between write and use). ### R3 — Custom reaction-role parsing depends on exactly one space around `=` +**[VERIFIED: CONFIRMED — slice arithmetic verified for all three spacing variants]** **src/features/reaction_roles.py:159, 248** — `option.split("=")[1][4:-1]` assumes the value is ` <@&ID>` (one leading space). `👍=<@&123>` (no space) slices off a digit and fails; two spaces mis-slices too. Falls into the generic "You formatted that wrong" path even for reasonable input. A regex (`<@&(\d+)>`) would be robust. ### X2 — /opt_out_of_dms embeds reference slash commands with dashes that don't exist +**[VERIFIED: CONFIRMED]** **src/features/dm_commands.py:57, 89** — text says `/opt-into-dms` / `/opt-out-of-dms`; the registered commands are `/opt_into_dms` / `/opt_out_of_dms`. ### C3 — `on_ready` re-runs registration work on session re-establishment +**[VERIFIED: CONFIRMED in part — re-fire and stale startup_time confirmed; view-store accumulation sub-claim refuted (ViewStore overwrites by key)]** **src/core/bot.py:115-188** — nextcord can dispatch `on_ready` again after an invalidated session resume. `start_scheduler()` handles restart, but `init_views` re-registers all 10 persistent views (view-store growth), the emoji cache refetches @@ -308,6 +371,7 @@ re-registers all 10 persistent views (view-store growth), the emoji cache refetc nonsense durations. Latent memory/log noise, not a crash. ### X3 — Autoban add-modal passes a string user ID to `utils.get_member` +**[VERIFIED: CONFIRMED]** **src/features/dashboard.py:4640-4656** — `user_id` stays a `str`; the `guild.get_member(user_id)` cache lookup always misses (dict keyed by int), forcing a REST fetch on every check. Works, but defeats the cache; `int(user_id)` before From c24aeceb2dfa597f4c41aed1402cc3b6a219a502 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:19:18 -0600 Subject: [PATCH 070/109] docs: Updated bug report to include my notes --- .../claude-reports/bug_hunt_2026-07b.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/documentation/claude-reports/bug_hunt_2026-07b.md b/documentation/claude-reports/bug_hunt_2026-07b.md index 2e3e45d..fd85c3a 100644 --- a/documentation/claude-reports/bug_hunt_2026-07b.md +++ b/documentation/claude-reports/bug_hunt_2026-07b.md @@ -148,7 +148,7 @@ duplicate reaction is a no-op on Discord (already present), and the reaction han matches only the first line with that emoji — so the 10th role can never be granted. The same fallback logic affects `editing/reaction_roles.py` (`json_data["type"] == 1`, line 262). -**Fix direction:** support 🔟 for 10 (or cap Numbers-type at 9 selections). +**Fix direction:** support 🔟 for 10. ### R2 — Role-message select options break Discord's 100-char value limit when an option grants ≥6 roles **[VERIFIED: CONFIRMED — overflow mechanism confirmed; client-side vs Discord-side failure mode undetermined]** @@ -162,6 +162,7 @@ the "Get Role"/"Get Roles" button then fails (HTTP 400 / validation error) every time it is clicked, permanently breaking that role message. **Fix direction:** use the field index as the option value and resolve role IDs from the embed field at callback time. +Note: This change MUST ensure backwards compatibility with existing role messages. ### M2 — `communication_disabled_until is not None` mistakes an *expired* timeout for an active one **[VERIFIED: FALSE POSITIVE — nextcord computes communication_disabled_until freshness at access time (member.py:658-671); premise impossible]** @@ -236,6 +237,8 @@ returns None and `.lower()` raises AttributeError, so the friendly **Fix direction:** `(os.environ.get(var[0]) or "").lower()` or fold missing vars into `unset_vars`. +Human notes: This deserves another look, because the "FATAL ERROR ... EXITING" path *does* run. This may be a false issue, and should be investigated before "fixing". + ### M3 — Profanity regexes are recompiled for every message (and every nickname change) **[VERIFIED: CONFIRMED — mechanism real; see Verification status for two corrected details]** **src/features/moderation.py:330** (`str_is_profane`) @@ -270,8 +273,7 @@ writes a runtime the moment an admin *views* the page. The scheduler treats any non-UNSET runtime as armed (birthdays.py:99), so birthdays start firing at 00:00 UTC even though the admin never picked a time — and the "⚠️ You must set a message time" warning at line 3456 is dead code (runtime was just set two lines earlier). -**Fix direction:** keep runtime UNSET until the user actually sets a time; make the -warning check the pre-assignment value. +**Fix direction:** keep runtime UNSET until the user actually sets a time. ### X1 — A failed autoban (missing permission) permanently deletes the autoban entry **[VERIFIED: CONFIRMED]** @@ -324,18 +326,23 @@ validation (unlike its sibling modals) → ValueError → generic modal error. member who just hit 0 keeps their row until the next day's run. Harmless but presumably not the intent. +Human edit: Actually, even more than that, currently I don't think it's updating the actual member levels at all. Am I right? If so, that needs to be fixed too. + ### M4 — Strikes of members who left the guild are never cleaned up -**[VERIFIED: CONFIRMED]** +**[VERIFIED: CONFIRMED] — SKIP: INTENDED BEHAVIOR** **src/features/moderation.py:977-981** — `daily_moderation_maintenance` skips rows whose member is no longer resolvable (`continue`), unlike leveling maintenance which deletes them (leveling.py:233-237). Rows for departed members persist until the guild itself is orphaned. +Human edit: This is intended behavior. The member leaving the guild shouldn't be a way to cheat their strike count and remove it. + ### A1 — Delete-log "Show More" can push the message over the 10-embed limit **[VERIFIED: CONFIRMED]** **src/features/action_logging.py:453-475** + **:58-59** — a deleted message carrying exactly 9 embeds produces a 10-embed log message; clicking "Show More" appends an 11th embed → HTTP 400 on `edit_message`, surfaced as the generic view error. +Solution: hide show more button when the message already has 10 embeds to prevent exceeding the limit. ### J3 — /joke crashes when the jokes list is empty **[VERIFIED: CONFIRMED]** @@ -377,9 +384,11 @@ nonsense durations. Latent memory/log noise, not a crash. a REST fetch on every check. Works, but defeats the cache; `int(user_id)` before the call fixes it. +Human notes: While fixing this, also make get_member more robust by ensuring it can handle both string and integer IDs gracefully, converting as necessary to avoid cache misses. + --- -## Infra notes (no defects found worth an ID) +## Infra notes (no defects found worth an ID). DO NOT "FIX" THESE. - `build.bash` references an undefined `buildx_cache_args` array — harmless empty expansion (no `set -u`), just cruft alongside the also-unused `buildx_cache=""`. From 5f3d8463f9ad7edab63a48ec2561a232ff736d0d Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:41:13 -0600 Subject: [PATCH 071/109] fix(db): pin orphaned-guild cleanup to a single connection (D1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cleanup_orphaned_guild_entries()` builds a SQLite TEMPORARY table and then issues INSERTs and per-table `DELETE ... LEFT JOIN` against it through `Database.execute_query()`, which checks out an arbitrary pooled connection per call. TEMP tables are per-connection, so once the QueuePool held more than one physical connection the statements could straddle connections: at best the cleanup aborted with "no such table", at worst a stale *empty* temp table left behind on another pooled connection made a later `LEFT JOIN` match every row, deleting all per- guild data (settings, strikes, levels, birthdays, logs). Adds `Database.pinned_connection()`, yielding a `PinnedConnection` backed by a held `engine.connect()` rather than a Session — committing a Session releases its connection back to the pool, which would reintroduce the same straddling. The query-execution body is factored into `_execute_on()` and shared by both paths, so Session and pinned execution keep identical semantics. Also adds two guards, since the failure mode here is silent mass deletion: - refuse to run at all when the valid-guild set is empty - verify the temp table's row count matches the expected guild count before any DELETE runs, aborting otherwise Covered by `TestPinnedConnection` in src/tests.py (9 tests): that a TEMP table stays visible across statements and across commits, that it is private to the pinned connection and does not outlive the block, that writes persist, that return-value shapes match `execute_query` (multiple_values, affected rows, empty result), that a failed query re- raises and leaves the connection usable, that the connection is released when the body raises, and that the cleanup declines an empty guild set. Two notes on making those tests actually bite: - They use a file-backed SQLite database, not `sqlite://`. Every connection to an in-memory database is a separate empty database, so the multi- connection assertions would be meaningless there. - `test_temp_table_survives_commit` warms the pool with several idle connections first. Without that it passes even against the buggy Session-backed implementation: with a single connection in the pool, one released on commit is handed straight back on the next statement and the bug stays invisible. QueuePool is FIFO, so only with older connections queued ahead does a released connection fail to come back. Verified by reintroducing the Session-backed version — that test fails, and passes again on `engine.connect()`. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/core/db_manager.py | 133 +++++++++++++----------- src/modules/database.py | 115 +++++++++++++++++---- src/tests.py | 221 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 389 insertions(+), 80 deletions(-) diff --git a/src/core/db_manager.py b/src/core/db_manager.py index 753e2a7..184524e 100644 --- a/src/core/db_manager.py +++ b/src/core/db_manager.py @@ -1168,8 +1168,6 @@ async def daily_database_maintenance(bot: nextcord.Client): logging.info(f"Scanning tables for orphaned guild entries...") orphaned_guild_entries_cleanup_start = datetime.datetime.now(datetime.timezone.utc) - # Runs on the loop (not to_thread): it relies on a per-connection SQLite - # TEMP table across queries, which needs serial use of the pooled connection. valid_guild_ids = set([int(guild.id) for guild in bot.guilds]) total_deleted += cleanup_orphaned_guild_entries(valid_guild_ids, get_database()) @@ -1207,69 +1205,84 @@ def cleanup_orphaned_guild_entries(all_guild_ids:set, database:Database=None): if database is None: database = get_database() - try: - # Create temporary table for valid guild IDs - much more efficient than large IN clauses - database.execute_query("DROP TABLE IF EXISTS temp_valid_guilds") - database.execute_query("CREATE TEMPORARY TABLE temp_valid_guilds (guild_id INTEGER PRIMARY KEY)") + if not all_guild_ids: + logging.warning("Refusing to run orphaned-guild cleanup with an empty valid-guild set.") + return 0 - # Insert all valid guild IDs in batches to avoid parameter limits - batch_size = 500 # SQLite parameter limit is typically 999 - guild_ids_list = list(all_guild_ids) - - for i in range(0, len(guild_ids_list), batch_size): - batch = guild_ids_list[i:i + batch_size] - query_values = ",".join(f"({int(gid)})" for gid in batch) - database.execute_query( - f"INSERT INTO temp_valid_guilds (guild_id) VALUES {query_values}", - commit=True - ) - - for table in database.tables: - try: - table_start = datetime.datetime.now(datetime.timezone.utc) + # The temp table is per-connection state, so every statement below must run on + # the same physical connection. + with database.pinned_connection() as connection: + try: + # Temporary table for valid guild IDs - much more efficient than large IN clauses + connection.execute_query("DROP TABLE IF EXISTS temp_valid_guilds") + connection.execute_query("CREATE TEMPORARY TABLE temp_valid_guilds (guild_id INTEGER PRIMARY KEY)") + + # Insert all valid guild IDs in batches to avoid parameter limits + batch_size = 500 # SQLite parameter limit is typically 999 + guild_ids_list = list(all_guild_ids) + + for i in range(0, len(guild_ids_list), batch_size): + batch = guild_ids_list[i:i + batch_size] + query_values = ",".join(f"({int(gid)})" for gid in batch) + connection.execute_query( + f"INSERT INTO temp_valid_guilds (guild_id) VALUES {query_values}", + commit=True + ) - # Get the guild row - if table not in database.tags: continue - if "remove-if-guild-invalid" not in database.tags[table]: continue - guild_row = database.tags[table]["remove-if-guild-invalid"] + # Guard against deleting everything if the inserts silently failed. + inserted_count = connection.execute_query("SELECT COUNT(*) FROM temp_valid_guilds")[0] + if inserted_count != len(all_guild_ids): + raise RuntimeError( + f"temp_valid_guilds holds {inserted_count} rows but {len(all_guild_ids)} guilds were expected; " + "aborting cleanup rather than risk deleting live guild data." + ) - if guild_row not in database.all_column_names[table]: - logging.warning(f"Table {table} does not have the reported guild column '{guild_row}'. Skipping.") - continue + for table in database.tables: + try: + table_start = datetime.datetime.now(datetime.timezone.utc) + + # Get the guild row + if table not in database.tags: continue + if "remove-if-guild-invalid" not in database.tags[table]: continue + guild_row = database.tags[table]["remove-if-guild-invalid"] + + if guild_row not in database.all_column_names[table]: + logging.warning(f"Table {table} does not have the reported guild column '{guild_row}'. Skipping.") + continue + + table_primary_key = database.all_primary_keys[table] + if table_primary_key is None: + logging.warning(f"Table {table} does not have a primary key. Skipping.") + continue + + logging.info(f"Processing table {table} with guild column '{guild_row}'") + + # Delete orphaned entries using LEFT JOIN - much more efficient than large IN clause + # SQLite will tell us how many rows were affected, so no need to count first + deleted_count = connection.execute_query( + f"""DELETE FROM {table} + WHERE {table}.{table_primary_key} IN ( + SELECT {table}.{table_primary_key} FROM {table} + LEFT JOIN temp_valid_guilds ON {table}.{guild_row} = temp_valid_guilds.guild_id + WHERE temp_valid_guilds.guild_id IS NULL + )""", + commit=True, + return_affected_rows=True + ) - table_primary_key = database.all_primary_keys[table] - if table_primary_key is None: - logging.warning(f"Table {table} does not have a primary key. Skipping.") - continue - - logging.info(f"Processing table {table} with guild column '{guild_row}'") - - # Delete orphaned entries using LEFT JOIN - much more efficient than large IN clause - # SQLite will tell us how many rows were affected, so no need to count first - deleted_count = database.execute_query( - f"""DELETE FROM {table} - WHERE {table}.{table_primary_key} IN ( - SELECT {table}.{table_primary_key} FROM {table} - LEFT JOIN temp_valid_guilds ON {table}.{guild_row} = temp_valid_guilds.guild_id - WHERE temp_valid_guilds.guild_id IS NULL - )""", - commit=True, - return_affected_rows=True - ) - - if deleted_count > 0: - total_deleted += deleted_count - logging.info(f"Deleted {deleted_count} orphaned entries from {table}") - else: - logging.info(f"No orphaned entries found in {table}") - - logging.info(f"Completed {table} in {datetime.datetime.now(datetime.timezone.utc) - table_start}") + if deleted_count > 0: + total_deleted += deleted_count + logging.info(f"Deleted {deleted_count} orphaned entries from {table}") + else: + logging.info(f"No orphaned entries found in {table}") - except Exception as table_err: - logging.error(f"Table {table} processing failed: {table_err}", exc_info=True) + logging.info(f"Completed {table} in {datetime.datetime.now(datetime.timezone.utc) - table_start}") - finally: - # Cleanup temporary table - database.execute_query("DROP TABLE IF EXISTS temp_valid_guilds") + except Exception as table_err: + logging.error(f"Table {table} processing failed: {table_err}", exc_info=True) + + finally: + # Cleanup temporary table + connection.execute_query("DROP TABLE IF EXISTS temp_valid_guilds") return total_deleted \ No newline at end of file diff --git a/src/modules/database.py b/src/modules/database.py index 398b21c..c9d28a5 100644 --- a/src/modules/database.py +++ b/src/modules/database.py @@ -1,4 +1,5 @@ import atexit +import contextlib import logging import os import re @@ -33,6 +34,46 @@ def __exit__(self, exc_type, exc_value, exc_traceback): ) +class PinnedConnection: + """ + A query executor bound to one physical database connection. + + Statements that depend on per-connection state (SQLite ``TEMPORARY`` tables, + for example) must all run here rather than through + :meth:`Database.execute_query`, which checks out an arbitrary pooled + connection per call. + + Obtain one via :meth:`Database.pinned_connection`. + """ + + def __init__(self, database: "Database", connection): + self._database = database + self._connection = connection + + def execute_query( + self, + sql: str, + args: dict = {}, + commit: bool = False, + multiple_values: bool = False, + return_affected_rows: bool = False, + **kwargs, + ): + """ + Execute an SQL query on the pinned connection. + + Mirrors :meth:`Database.execute_query`; see it for argument semantics. + """ + return self._database._execute_on( + self._connection, + sql, + args=args, + commit=commit, + multiple_values=multiple_values, + return_affected_rows=return_affected_rows, + ) + + class Database: """ A class for interacting with an SQLite database. @@ -122,26 +163,60 @@ def execute_query( multiple_values = True: All query results wrapped in a list. """ with self.Session() as session: - try: - result = session.execute(text(sql), args) - - if return_affected_rows: - # For INSERT/UPDATE/DELETE operations, return the number of affected rows - affected_count = result.rowcount - if commit: - session.commit() - return affected_count - else: - # For SELECT operations, return the query results - data = result.fetchall() if result.returns_rows else None - if commit: - session.commit() - return data if multiple_values else self.get_query_first_value(data) - - except Exception: - logging.error(f"Error executing SQL query: {sql}", exc_info=True) - session.rollback() - raise # preserve the original exception type for callers + return self._execute_on( + session, + sql, + args=args, + commit=commit, + multiple_values=multiple_values, + return_affected_rows=return_affected_rows, + ) + + def _execute_on( + self, + executor, + sql: str, + args: dict = {}, + commit: bool = False, + multiple_values: bool = False, + return_affected_rows: bool = False, + ): + """ + Run a query on an open Session or Connection. See :meth:`execute_query`. + """ + try: + result = executor.execute(text(sql), args) + + if return_affected_rows: + # For INSERT/UPDATE/DELETE operations, return the number of affected rows + affected_count = result.rowcount + if commit: + executor.commit() + return affected_count + else: + # For SELECT operations, return the query results + data = result.fetchall() if result.returns_rows else None + if commit: + executor.commit() + return data if multiple_values else self.get_query_first_value(data) + + except Exception: + logging.error(f"Error executing SQL query: {sql}", exc_info=True) + executor.rollback() + raise # preserve the original exception type for callers + + @contextlib.contextmanager + def pinned_connection(self) -> Generator["PinnedConnection", None, None]: + """ + Yield a :class:`PinnedConnection` whose queries all run on one physical + connection, so per-connection state (SQLite ``TEMPORARY`` tables) stays + visible for the lifetime of the block. + + A ``Session`` is unsuitable here: committing one releases its connection + back to the pool, so the next statement could land on a different one. + """ + with self.engine.connect() as connection: + yield PinnedConnection(self, connection) def build_database(self, build_file_path: str) -> None: """ diff --git a/src/tests.py b/src/tests.py index 8e71cec..ef9ec28 100644 --- a/src/tests.py +++ b/src/tests.py @@ -423,6 +423,227 @@ def step10_test_get_id_sql_name(self) -> None: self.cleanup(database) +class TestPinnedConnection(unittest.TestCase): + """ + Tests for `Database.pinned_connection()`. + + A pinned connection exists so that statements depending on per-connection state + (SQLite ``TEMPORARY`` tables) all land on the same physical connection, which + `Database.execute_query()` cannot promise — it checks out a pooled connection + per call. + + These use a file-backed database rather than `sqlite://`, because every + connection to an in-memory database is a separate, empty database; the tests + below need two connections that see the same data. + """ + + def setUp(self) -> None: + self.db_path = f"./generated/test-files/files/pinned_{uuid.uuid4().hex}.db" + self.database = Database(f"sqlite:///{self.db_path}", "resources/test_db_build.sql") + + def tearDown(self) -> None: + self.database.cleanup() + if os.path.exists(self.db_path): + os.remove(self.db_path) + + def warm_pool(self, count: int = 3) -> None: + """ + Leave several distinct connections idle in the pool. + + Without this the pool holds a single connection, so a connection released + mid-block is immediately handed back on the next statement and a + release-on-commit bug stays invisible. QueuePool is FIFO by default + (`use_lifo=False`), so with older idle connections queued ahead of it, a + released connection is *not* the one returned next. + + :param count: How many connections to leave idle. + :type count: int + """ + connections = [self.database.engine.connect() for _ in range(count)] + for connection in connections: + connection.close() + + def test_temp_table_visible_to_later_queries(self) -> None: + """ + The whole point: a TEMP table created on a pinned connection is still + visible to subsequent statements on that connection. + """ + logging.info("Testing that a TEMP table stays visible across pinned queries...") + with self.database.pinned_connection() as connection: + connection.execute_query("CREATE TEMPORARY TABLE temp_ids (id INTEGER PRIMARY KEY)") + connection.execute_query("INSERT INTO temp_ids (id) VALUES (1), (2), (3)", commit=True) + + count = connection.execute_query("SELECT COUNT(*) FROM temp_ids")[0] + self.assertEqual(count, 3) + + connection.execute_query("DROP TABLE IF EXISTS temp_ids") + + def test_temp_table_survives_commit(self) -> None: + """ + Regression test for D1. A Session releases its connection back to the pool on + commit, so a committing write would move later statements onto a different + connection and lose the TEMP table. A pinned connection must not do that. + + The warm pool is what makes this bite: with other connections queued ahead, + a released connection is not the one handed back next. That is the real + condition D1 described — maintenance committing while other checkouts are + live. + """ + logging.info("Testing that a TEMP table survives a commit on a pinned connection...") + self.warm_pool() + + with self.database.pinned_connection() as connection: + connection.execute_query("CREATE TEMPORARY TABLE temp_ids (id INTEGER PRIMARY KEY)") + + # Several committing writes interleaved with reads: each commit is a chance + # for the connection to be swapped out from under us. + for value in range(5): + connection.execute_query(f"INSERT INTO temp_ids (id) VALUES ({value})", commit=True) + running_total = connection.execute_query("SELECT COUNT(*) FROM temp_ids")[0] + self.assertEqual(running_total, value + 1) + + connection.execute_query("DROP TABLE IF EXISTS temp_ids") + + def test_temp_table_is_not_visible_to_other_connections(self) -> None: + """ + The TEMP table must be private to the pinned connection. This is the failure + D1 hinged on: statements that leak onto another pooled connection either don't + see the table at all, or see a stale copy of it. + """ + logging.info("Testing that a pinned TEMP table is invisible to other connections...") + self.warm_pool() + + with self.database.pinned_connection() as connection: + connection.execute_query("CREATE TEMPORARY TABLE temp_ids (id INTEGER PRIMARY KEY)") + connection.execute_query("INSERT INTO temp_ids (id) VALUES (1)", commit=True) + + # The pinned connection is still checked out, so this must take a different + # one from the pool, which has no such table. + with self.assertRaises(Exception): + self.database.execute_query("SELECT COUNT(*) FROM temp_ids") + + connection.execute_query("DROP TABLE IF EXISTS temp_ids") + + def test_dropped_temp_table_does_not_outlive_the_block(self) -> None: + """ + Pooled connections are long-lived, so a TEMP table dropped inside the block + must really be gone once that connection is handed back out. + """ + logging.info("Testing that a dropped TEMP table does not persist in the pool...") + with self.database.pinned_connection() as connection: + connection.execute_query("CREATE TEMPORARY TABLE temp_ids (id INTEGER PRIMARY KEY)") + connection.execute_query("INSERT INTO temp_ids (id) VALUES (1)", commit=True) + connection.execute_query("DROP TABLE IF EXISTS temp_ids") + + # Reuse the same connection by pinning again; the pool hands back the one just + # released, so a leaked table would still be there. + with self.database.pinned_connection() as connection: + with self.assertRaises(Exception): + connection.execute_query("SELECT COUNT(*) FROM temp_ids") + + def test_writes_persist_to_the_real_database(self) -> None: + """ + A committed write on a pinned connection is visible afterwards through the + ordinary pooled path. + """ + logging.info("Testing that pinned writes persist to the database...") + with self.database.pinned_connection() as connection: + connection.execute_query( + "INSERT INTO table_1 (primary_key, example_integer) VALUES (55, 777)", + commit=True + ) + + result = self.database.execute_query("SELECT example_integer FROM table_1 WHERE primary_key = 55") + self.assertEqual(result[0], 777) + + def test_return_value_semantics_match_execute_query(self) -> None: + """ + `PinnedConnection.execute_query` shares its implementation with + `Database.execute_query`, so the return shapes must agree. + """ + logging.info("Testing that pinned queries return the same shapes as execute_query...") + self.database.execute_query( + "INSERT INTO table_1 (primary_key, example_integer) VALUES (1, 10), (2, 20)", + commit=True + ) + + with self.database.pinned_connection() as connection: + # multiple_values=True returns every row + self.assertEqual( + connection.execute_query("SELECT example_integer FROM table_1 ORDER BY primary_key", multiple_values=True), + self.database.execute_query("SELECT example_integer FROM table_1 ORDER BY primary_key", multiple_values=True) + ) + + # multiple_values=False collapses to the first row + self.assertEqual( + connection.execute_query("SELECT example_integer FROM table_1 ORDER BY primary_key"), + self.database.execute_query("SELECT example_integer FROM table_1 ORDER BY primary_key") + ) + + # No rows at all returns None either way + self.assertIsNone(connection.execute_query("SELECT example_integer FROM table_1 WHERE primary_key = 999")) + + # return_affected_rows reports the row count of a write + affected = connection.execute_query( + "UPDATE table_1 SET example_integer = 99", + commit=True, + return_affected_rows=True + ) + self.assertEqual(affected, 2) + + def test_failed_query_reraises_and_leaves_connection_usable(self) -> None: + """ + A failing statement must propagate (callers depend on the original exception + type) and must roll back, leaving the pinned connection usable. + """ + logging.info("Testing pinned connection error handling...") + with self.database.pinned_connection() as connection: + with self.assertRaises(Exception): + connection.execute_query("SELECT * FROM a_table_that_does_not_exist") + + # The rollback should leave the connection healthy for further work. + connection.execute_query( + "INSERT INTO table_1 (primary_key, example_integer) VALUES (7, 70)", + commit=True + ) + self.assertEqual(connection.execute_query("SELECT example_integer FROM table_1 WHERE primary_key = 7")[0], 70) + + def test_connection_is_released_on_exception(self) -> None: + """ + The context manager must hand its connection back to the pool even when the + body raises, or repeated failures would exhaust the pool. + """ + logging.info("Testing that a pinned connection is released when the body raises...") + + class ExpectedError(Exception): + pass + + for _ in range(10): # more iterations than the pool size (5) + with self.assertRaises(ExpectedError): + with self.database.pinned_connection() as connection: + connection.execute_query("SELECT 1") + raise ExpectedError() + + # The pool is not exhausted: ordinary queries still work. + self.assertEqual(self.database.execute_query("SELECT 1")[0], 1) + + def test_cleanup_orphaned_guild_entries_refuses_an_empty_guild_set(self) -> None: + """ + Guard from the same fix: with no valid guilds, every row looks orphaned. The + cleanup must decline rather than empty the tables. + """ + logging.info("Testing that orphan cleanup refuses an empty guild set...") + self.database.execute_query( + "INSERT INTO table_1 (primary_key, example_integer) VALUES (1, 111), (2, 222)", + commit=True + ) + + deleted = db_manager.cleanup_orphaned_guild_entries(set(), self.database) + + self.assertEqual(deleted, 0) + remaining = self.database.execute_query("SELECT COUNT(*) FROM table_1")[0] + self.assertEqual(remaining, 2) + class TestStoredMessages(unittest.TestCase): def _steps(self): for name in dir(self): # dir() result is implicitly sorted From 337805b5d29ee6f3e702934de9e760451731364c Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:41:33 -0600 Subject: [PATCH 072/109] fix(moderation): guard the nickname-profanity DM (M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nickname path sent its notification DM with a bare `await member.send(...)`, outside any try/except. Members with DMs closed are common, so `nextcord.Forbidden` propagated out to the caller's `LogIfFailure` and silently skipped the admin-channel report that follows — the moderators never heard about the flagged nickname. The embed's footer also advertised /opt_out_of_dms while the path never checked `direct_messages_enabled`, so opted-out members were DMed anyway. Both now mirror `check_and_trigger_profanity_moderation_for_message`: gate on the member's DM setting, and swallow Forbidden/HTTPException so delivery failure can't abort the admin report. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/moderation.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/features/moderation.py b/src/features/moderation.py index 35781b6..9916bb2 100644 --- a/src/features/moderation.py +++ b/src/features/moderation.py @@ -216,10 +216,17 @@ async def check_and_punish_nickname_for_profanity(bot: nextcord.Client, guild: n if strikes == 0: strikes_info = f"\n\nYou were timed out for {timeout_time}" else: strikes_info = f"\n\nYou are now at strike {strikes} / {server.profanity_moderation_profile.max_strikes}" - embed = nextcord.Embed(title = "Profanity Detected", description = f"You were flagged for your nickname.\n\n**Server**: {guild.name}\n**Nickname:** {nickname}{strikes_info}", color = nextcord.Color.dark_red()) - embed.set_footer(text = "To opt out of dm notifications, use /opt_out_of_dms") - await member.send(embed = embed) - + if Member(member.id).direct_messages_enabled: + try: + embed = nextcord.Embed(title = "Profanity Detected", description = f"You were flagged for your nickname.\n\n**Server**: {guild.name}\n**Nickname:** {nickname}{strikes_info}", color = nextcord.Color.dark_red()) + embed.set_footer(text = "To opt out of dm notifications, use /opt_out_of_dms") + await member.send(embed = embed) + except (nextcord.Forbidden, nextcord.HTTPException): + pass # The user has dms turned off. It's not a big deal, they just don't get notified. + except Exception as e: + logging.error(f"Error sending nickname profanity DM to {member}: {e}", exc_info=True) + + # Send a message to the admin channel admin_channel_id = server.profanity_moderation_profile.channel if admin_channel_id == UNSET_VALUE: return From 81df305d8f545f9aabf9797821ebc5b22557264e Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:41:59 -0600 Subject: [PATCH 073/109] fix(dashboard): return the strike list when it exceeds the display limit (H1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ManageMembersView.get_members` appended the "N more…" summary row and then hit a bare `return`, handing back None instead of `returned_data`. The caller's `if self.members:` saw a falsy value and rendered "Your server doesn't have any members with strikes yet." — so precisely the servers with more than 25 struck members were shown an empty list. Swapped to `break` so the accumulated rows fall through to the existing `return`. The overflow row carries None for member_id/strike-count and, now that it actually reaches the renderer, would have formatted as "None - N more…". The join now renders that row as its summary text alone. The Edit flow is unaffected: it selects members through a UserSelect, not through this list. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/dashboard.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard.py b/src/features/dashboard.py index 0a57904..47e338a 100644 --- a/src/features/dashboard.py +++ b/src/features/dashboard.py @@ -775,7 +775,11 @@ async def setup(self, interaction: Interaction): self.members = await self.get_members(interaction, limit=25) if self.members: - members_string = "\n".join([f"{item[2]} - {item[0]}" for item in self.members]) + # The overflow row (member_id None) carries only a summary line. + members_string = "\n".join([ + item[0] if item[1] is None else f"{item[2]} - {item[0]}" + for item in self.members + ]) else: members_string = "Your server doesn't have any members with strikes yet." @@ -806,7 +810,7 @@ async def get_members(self, interaction: Interaction, limit: int = None): for i, item in enumerate(data): if limit and i >= limit: returned_data.append([f"{len(data) - limit} more. Use */get_strikes* to view specific member strikes", None, None]) - return + break # EX: [member_mention, member_id, strike#] returned_data.append([item[0], item[1], item[2]]) From 2529184a9852ce36b684f8998d1283c4a70865f8 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:42:26 -0600 Subject: [PATCH 074/109] fix(reaction-roles): give the 10th numbered role its own emoji (R1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Numbers"-type reaction roles emoji their options via `asci_to_emoji(count)`. The table only mapped single digits, so `asci_to_emoji("10")` fell through to the `fallback_letter="1"` recursion and returned 1️⃣ — already taken by role #1. Discord treats the duplicate reaction as a no-op and the handler matches only the first line bearing that emoji, so the 10th role was permanently ungrantable. Adds the "10" → 🔟 mapping. Reaction-role selects cap at 10 options (reaction_roles.py:45-50, 142), so this closes the range. The fix lands in `asci_to_emoji` itself, which also covers the editing path (options_menu/editing/reaction_roles.py:262). Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/components/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/utils.py b/src/components/utils.py index 669813f..c08f3ce 100644 --- a/src/components/utils.py +++ b/src/components/utils.py @@ -52,6 +52,7 @@ def asci_to_emoji(letter, fallback_letter = "1"): if letter == "8": return "8️⃣", "8" if letter == "9": return "9️⃣", "9" if letter == "0": return "0️⃣", "0" + if letter == "10": return "🔟", "10" return asci_to_emoji(fallback_letter) From 3c6c435d51d2132059d74c096dd4a43eb33551ea Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:43:27 -0600 Subject: [PATCH 075/109] fix(role-messages): stop packing role IDs into the select option value (R2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both role-message views built their select options with `value="|".join(roles)`. Role IDs run ~19 chars, and Discord caps an option's value at 100, so any option granting 6 or more roles overflowed and the "Get Role"/"Get Roles" button failed on every click — permanently bricking that role message. The creation wizard allows roughly 40 roles per option, bounded only by the 1024-char field text, so this was reachable well within normal use. Options now carry their embed field index as the value, with the view holding an index -> role-ID mapping that the callback resolves against. Backwards compatible by construction: the option values were never persisted. Both views are rebuilt from `message.embeds[0].fields` each time the button is clicked, so existing role messages — whose roles live in the embed fields, which are untouched — pick up the new scheme on their next click with no migration. Renamed the field loop variable to `field_index`; the pre-existing inner role-enumeration loop already binds `index`. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/role_messages.py | 50 ++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/features/role_messages.py b/src/features/role_messages.py index d85ede1..9111fde 100644 --- a/src/features/role_messages.py +++ b/src/features/role_messages.py @@ -20,12 +20,15 @@ def __init__(self, user: nextcord.Member, message: nextcord.Message): options_selected = False options = [] - for field in self.message.embeds[0].fields: + # Option values are field indices; role IDs would overflow Discord's + # 100-char value cap once an option grants 6+ roles. + self.option_roles: dict[str, list[int]] = {} + for field_index, field in enumerate(self.message.embeds[0].fields): name = field.name description = "\n".join(field.value.split("\n")[:-1]) roles = self.extract_ids(field.value.split("\n")[-1]) self.add_available_roles(roles) - + # Check if the user has the roles selected = False for index, role in enumerate(roles): @@ -37,15 +40,17 @@ def __init__(self, user: nextcord.Member, message: nextcord.Message): selected = True options_selected = True break - + # Truncate name and description if too long if len(name) > 100: name = name[:97] + "..." - + if len(description) > 100: description = description[:97] + "..." - - options.append(nextcord.SelectOption(label=name, description=description, value="|".join(roles), default=selected)) + + value = str(field_index) + self.option_roles[value] = [int(role) for role in roles] + options.append(nextcord.SelectOption(label=name, description=description, value=value, default=selected)) self.select = nextcord.ui.Select(custom_id="role_message_single_select", placeholder="Choose a Role", min_values=0, options=options) self.select.callback = self.select_callback @@ -68,9 +73,8 @@ async def select_callback(self, interaction: Interaction): selection = self.select.values selected_roles = [] for option in selection: - for role in option.split("|"): - selected_roles.append(int(role)) - + selected_roles.extend(self.option_roles.get(option, [])) + # Get the roles roles_add = [] roles_remove = [] @@ -82,19 +86,19 @@ async def select_callback(self, interaction: Interaction): roles_add.append(role_obj) else: roles_remove.append(role_obj) - + error = False try: await interaction.user.add_roles(*roles_add) await interaction.user.remove_roles(*roles_remove) except nextcord.errors.Forbidden: error = True - + embed = nextcord.Embed(title = "Modified Roles", color = nextcord.Color.green()) if error: embed.description = "Warning: An error occurred with one or more roles. Please notify server admins." - + await interaction.response.edit_message(embed=embed, view=None, delete_after=2.0) - + @nextcord.ui.button(label = "Get Role", style = nextcord.ButtonStyle.blurple, custom_id = "get_role") async def event(self, button: nextcord.ui.Button, interaction: nextcord.Interaction): message = interaction.message @@ -113,12 +117,15 @@ def __init__(self, user: nextcord.Member, message: nextcord.Message): user_role_ids = [role.id for role in user.roles if role.name != "@everyone"] options = [] - for field in self.message.embeds[0].fields: + # Option values are field indices; role IDs would overflow Discord's + # 100-char value cap once an option grants 6+ roles. + self.option_roles: dict[str, list[int]] = {} + for field_index, field in enumerate(self.message.embeds[0].fields): name = field.name description = "\n".join(field.value.split("\n")[:-1]) roles = self.extract_ids(field.value.split("\n")[-1]) self.add_available_roles(roles) - + # Check if the user has the roles selected = False for index, role in enumerate(roles): @@ -128,15 +135,17 @@ def __init__(self, user: nextcord.Member, message: nextcord.Message): if index == (len(roles) - 1): # If this is the last role to check selected = True break - + # Truncate name and description if too long if len(name) > 100: name = name[:97] + "..." if len(description) > 100: description = description[:97] + "..." - - options.append(nextcord.SelectOption(label=name, description=description, value="|".join(roles), default=selected)) + + value = str(field_index) + self.option_roles[value] = [int(role) for role in roles] + options.append(nextcord.SelectOption(label=name, description=description, value=value, default=selected)) self.select = nextcord.ui.Select(custom_id="role_message_multi_select", placeholder="Choose Roles", min_values=0, max_values=len(options), options=options) self.select.callback = self.select_callback @@ -159,9 +168,8 @@ async def select_callback(self, interaction: Interaction): selection = self.select.values selected_roles = [] for option in selection: - for role in option.split("|"): - selected_roles.append(int(role)) - + selected_roles.extend(self.option_roles.get(option, [])) + # Get the roles roles_add = [] roles_remove = [] From dbe0e1e49e0eed8e39c4d525be6f68476ac9e6df Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:44:32 -0600 Subject: [PATCH 076/109] fix(logging): close rotated-out log handlers explicitly (C1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setup_logging()` re-runs on every midnight tick and dropped the previous handlers with `removeHandler` alone, never calling `close()`. Now closes each handler after removing it. CORRECTION TO THE REPORT: C1 describes this as leaking one FD per day, with deleted log files' disk space unreclaimable until restart. That is not reproducible on CPython. `logging._handlerList` holds only weak references and nothing else retains the removed handler, so refcounting collects it and closes the stream immediately — an fd count across six rotations stays flat at 1 both before and after this change. (An earlier test appearing to show the leak was measuring a reference my own test harness was holding.) Keeping the change regardless: it makes teardown explicit rather than dependent on refcounting semantics that aren't guaranteed by the language, and it flushes buffered records deterministically at rotation instead of at collection time. The severity in the report should be read as LOW/cosmetic, not MEDIUM. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/core/log_manager.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/core/log_manager.py b/src/core/log_manager.py index 5202e44..85181f8 100644 --- a/src/core/log_manager.py +++ b/src/core/log_manager.py @@ -191,9 +191,14 @@ def setup_logging(level: int = logging.INFO) -> None: logfile_path, organization_messages = generate_logging_file_name() logfile_path = os.path.abspath(logfile_path) - # Remove all existing handlers to avoid conflicts + # Remove all existing handlers to avoid conflicts. Closing them releases the + # previous log file's descriptor; setup_logging re-runs on every midnight tick. for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) + try: + handler.close() + except Exception: + pass # Custom formatter class to handle microseconds class CustomFormatter(logging.Formatter): From 30f552393405008e25425aab9fe7d7891131d5f2 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:44:53 -0600 Subject: [PATCH 077/109] fix(jokes): handle unreachable support server and channel on submission (J1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SubmitJokeModal.callback` did `if server is None: self.stop()` with no `return`, so execution fell through to `server.get_channel(...)` and raised `AttributeError: 'NoneType' object has no attribute 'get_channel'`. The submitter saw the generic error embed instead of anything actionable. `channel` was likewise never None-checked, so a submission-channel-id that is a valid int but not a real channel crashed at `channel.send`. Both cases now log the specific failure and reply with a targeted message, matching the existing handling for an unset submission-channel- id. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a potential issue (in some environments, not prod though) that was properly resolved --- src/features/jokes.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/features/jokes.py b/src/features/jokes.py index 712c412..95b7493 100644 --- a/src/features/jokes.py +++ b/src/features/jokes.py @@ -336,7 +336,21 @@ def __init__(self): async def callback(self, interaction: Interaction): # Try to post a message to the submission channel on the InfiniBot Support Server server = _get_infinibot_support_server() - if server is None: self.stop() + if server is None: + logging.error("Could not resolve the InfiniBot support server. Cannot send joke submission.") + await interaction.response.send_message( + embed=nextcord.Embed( + title="Joke Submission Failed", + description=( + "InfiniBot could not reach the support server. " + "Please try again later, or contact the developers of InfiniBot if this persists." + ), + color=nextcord.Color.red() + ), + ephemeral=True + ) + self.stop() + return # Get the submission channel ID submission_channel_id = get_configs()['support-server']['submission-channel-id'] @@ -365,6 +379,21 @@ async def callback(self, interaction: Interaction): ) channel = server.get_channel(submission_channel_id) + if channel is None: + logging.error(f"Joke submission channel {submission_channel_id} could not be resolved. Cannot send joke submission.") + await interaction.response.send_message( + embed=nextcord.Embed( + title="Joke Submission Failed", + description=( + "The joke submission channel is misconfigured. " + "Please contact the developers of InfiniBot to fix this issue." + ), + color=nextcord.Color.red() + ), + ephemeral=True + ) + return + await channel.send(embed=embed, view=JokeVerificationView()) # Inform the user that it was sent From d78fee39cca7748bdea810f84e2dc40e34725f13 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:45:04 -0600 Subject: [PATCH 078/109] fix(jokes): stop responding twice to the deny interaction (J2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ConfirmView.confirm` already consumes the interaction with `interaction.response.edit_message(delete_after=0.0)` before the DM block, then called it a second time afterwards. The second call always raised `nextcord.errors.InteractionResponded`, which the view error handler logged and surfaced to the moderator as an error embed — after the deny had already gone through. Removed the duplicate; the early `return` on the no-member path was already relying on the first call to clean up the message. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/jokes.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/features/jokes.py b/src/features/jokes.py index 95b7493..cb1f332 100644 --- a/src/features/jokes.py +++ b/src/features/jokes.py @@ -510,9 +510,6 @@ async def confirm(self, button: nextcord.ui.Button, interaction: Interaction): logging.warning(f"Could not send dm to {self.member} ({self.member.id}). " "User has DMs disabled or blocked the bot.") - # Remove the message - await interaction.response.edit_message(delete_after=0.0) - # Get confirmation that we will be able to dm the user more_info = "\n\nThis will **not** send the submitter a dm." if _get_infinibot_support_server(): From d29b011a65ae31c6e5d77f4eaa61b503fb28610d Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:45:51 -0600 Subject: [PATCH 079/109] fix(embeds): survive empty color selection and view/modal timeouts (E1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two crash paths in /create embed, both surfacing as the generic error embed: (a) `EmbedColorView.create_callback` indexed `self.select.values[0]` before its emptiness check, so `if self.selection == []` could never be true and clicking "Create" without picking a color raised IndexError. Now checks `select.values` first and tells the user to pick a color instead of failing silently. (b) `title_value`/`description_value` on EmbedModal and `selection` on EmbedColorView were only ever assigned in their callbacks. When either timed out (5-min VIEW_TIMEOUT), `wait()` returned and the command read attributes that had never been set -> AttributeError. All three now default to None in `__init__`, and the command bails out after each `wait()` when no result was produced. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/embeds.py | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/features/embeds.py b/src/features/embeds.py index ce69be3..fc47802 100644 --- a/src/features/embeds.py +++ b/src/features/embeds.py @@ -8,7 +8,11 @@ class EmbedModal(CustomModal): def __init__(self): super().__init__(title = "Create Embed") - + + # Stay None until the modal is submitted, so a timeout is detectable. + self.title_value = None + self.description_value = None + self.title_text_input = nextcord.ui.TextInput(label="Title", style=nextcord.TextInputStyle.short, required=True, max_length=256) self.description_text_input = nextcord.ui.TextInput(label="Description", style=nextcord.TextInputStyle.paragraph, required=True, max_length=4000) @@ -63,7 +67,10 @@ async def continue_callback(self, interaction: Interaction): class EmbedColorView(CustomView): def __init__(self): super().__init__() - + + # Stays None until "Create" is pressed with a color chosen. + self.selection = None + select_options = [] for option in utils.COLOR_OPTIONS: select_options.append(nextcord.SelectOption(label=option, value=option)) @@ -77,9 +84,19 @@ def __init__(self): self.add_item(self.button) async def create_callback(self, interaction: Interaction): + if not self.select.values: + await interaction.response.send_message( + embed=nextcord.Embed( + title="No Color Chosen", + description="Choose a color from the dropdown before clicking Create.", + color=nextcord.Color.red() + ), + ephemeral=True + ) + return + self.selection = self.select.values[0] - if self.selection == []: return - + self.select.disabled = True self.button.disabled = True @@ -119,10 +136,10 @@ async def run_create_embed_command(interaction: Interaction, role: nextcord.Role await interaction.response.send_message(embed=info_embed, view=info_view, ephemeral=True) await info_view.wait() - if info_view.return_modal is None: - # User cancelled the modal + if info_view.return_modal is None or info_view.return_modal.title_value is None: + # The user never continued, or the modal timed out unsubmitted return - + embed_title = info_view.return_modal.title_value embed_description = info_view.return_modal.description_value @@ -142,9 +159,13 @@ async def run_create_embed_command(interaction: Interaction, role: nextcord.Role await interaction.edit_original_message(embed=embed, view=view) await view.wait() - + color = view.selection - + if color is None: + # The color view timed out without a selection + return + + # Translate the color to a discord color discord_color = utils.get_discord_color_from_string(color) From 557a2dfe57d25e0066a64aa87b0795075caa2078 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:46:57 -0600 Subject: [PATCH 080/109] fix(main): treat an absent required env var as unset, not a crash (C2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigated per the human note on C2 ("the FATAL ERROR path *does* run"), and the note is correct — for the deployment case, but not for all of them. Both behaviours reproduced against the real `create_environment()`: DISCORD_AUTH_TOKEN absent entirely -> AttributeError: 'NoneType' has no 'lower' DISCORD_AUTH_TOKEN present-but-empty -> clean exit() via the FATAL ERROR path InfiniBot normally runs under `docker run --env-file ./.env`, and Docker sets a variable declared without a value to the empty string. So in the deployment the human is describing the var is present-but-blank, `.lower()` is safe, and the friendly path runs exactly as intended. The AttributeError only surfaces when the variable is missing from the environment altogether — running outside Docker, or with a .env that omits the line — and nothing up the stack catches it, so that case exits on a bare traceback rather than the intended message. So C2 is real but narrower than the report implies: it is not "the friendly path never runs", it is "the friendly path is skipped only when the var is wholly absent". Fixed by coercing the lookup with `or ""`, which folds absent and blank into the same unset case. Verified all three inputs (absent / empty / valid). Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/main.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 7cdb08e..b072e7d 100644 --- a/src/main.py +++ b/src/main.py @@ -37,7 +37,11 @@ def get_token(): error_msg = f"Some environment variables don't exist: {', '.join(missing_vars)}" logging.warning(error_msg) - unset_vars = [var[0] for var in environment_vars if var[1] and os.environ.get(var[0]).lower() in ["", "none", "missing"]] + # A required var counts as unset whether it is absent entirely or present-but-blank. + unset_vars = [ + var[0] for var in environment_vars + if var[1] and (os.environ.get(var[0]) or "").lower() in ["", "none", "missing"] + ] if unset_vars: error_msg = "Some required environment variables are unset or missing." logging.critical("Missing required environment variables: " + ", ".join(unset_vars)) From 4b810f1b1de47641bda19062ddb66cccefd15526 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:48:26 -0600 Subject: [PATCH 081/109] perf(moderation): cache compiled profanity patterns per word list (M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `str_is_profane` rebuilt every pattern string and called `re.compile` on each one for every message and every nickname change, in every guild. The pattern generator is now a module-level function and compilation goes through an `lru_cache(maxsize=256)` keyed on the word list as a tuple — so an edit to a server's filtered words produces a different key and recompiles naturally, with no TTL or manual invalidation needed, and all servers on the default list share one entry. CORRECTION TO THE REPORT: the verification note claims the "re module 512-pattern cache thrashing" point is moot because "explicit re.compile bypasses that cache entirely." That is not correct — `re.compile` routes through `re._compile`, which populates and hits `re._cache` (verified: a repeated `re.compile` of the same pattern returns the identical object, `_MAXCACHE` 512). The thrashing concern is therefore the *dominant* cost here, not a moot one. Measured, 150-pattern lists: single guild, patterns stay under the global cache -> 0.39s to 0.25s (1.6x) 6 guilds interleaved, 900 distinct patterns, cache thrashes -> 6.64s to 0.25s (26x) The second case is the realistic one at scale and is what this fix actually addresses. Behaviour is unchanged; verified against wildcard (`*`, `?`), quoted exact-match, and non-matching inputs. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** This is a really great way to solve this problem. Well done, Claude Opus. --- src/features/moderation.py | 80 +++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/src/features/moderation.py b/src/features/moderation.py index 9916bb2..3928e6a 100644 --- a/src/features/moderation.py +++ b/src/features/moderation.py @@ -1,6 +1,7 @@ import asyncio from collections import defaultdict import datetime +import functools import humanfriendly import logging import math @@ -286,6 +287,52 @@ def remove_urls_from_text(text: str) -> str: return cleaned_text +def _generate_profanity_regex_pattern(word: str) -> str: + """ + Generates a regular expression pattern from a given string. + + This function generates a regular expression pattern from a given string, + which is used to match profane words or phrases in a given string. + + :param word: The string to generate a regular expression pattern for. + :type word: str + :return: The generated regular expression pattern. + :rtype: str + """ + word = word.lower() + # Handle required wildcards (* = exactly 1 character) + word = word.replace("*", ".") + # Add optional wildcards (? = 0 or 1 characters) + word = word.replace("?", ".?") + + # Boundary logic + if not word.startswith("\""): + word = r"\w*" + word + else: + word = r"\b" + word[1:] + + if not word.endswith("\""): + word = word + r"\w*" + else: + word = word[:-1] + r"\b" + + return word + +@functools.lru_cache(maxsize=256) +def _get_compiled_profanity_patterns(words: tuple[str, ...]) -> tuple[re.Pattern, ...]: + """ + Compile (and cache) the regex patterns for a word list. + + Keyed on the word list itself, so an edit to a server's filtered words yields + a different key and recompiles. Servers on the default list share one entry. + + :param words: The filtered-word list, as a hashable tuple. + :type words: tuple[str, ...] + :return: The compiled patterns, in order. + :rtype: tuple[re.Pattern, ...] + """ + return tuple(re.compile(_generate_profanity_regex_pattern(word)) for word in words) + def str_is_profane(message: str, database: list[str]) -> str | None: """ Checks if a given string contains any profanity. @@ -303,38 +350,7 @@ def str_is_profane(message: str, database: list[str]) -> str | None: :rtype: str | None """ - def generate_regex_pattern(word: str): - """ - Generates a regular expression pattern from a given string. - - This function generates a regular expression pattern from a given string, - which is used to match profane words or phrases in a given string. - - :param word: The string to generate a regular expression pattern for. - :type word: str - :return: The generated regular expression pattern. - :rtype: str - """ - word = word.lower() - # Handle required wildcards (* = exactly 1 character) - word = word.replace("*", ".") - # Add optional wildcards (? = 0 or 1 characters) - word = word.replace("?", ".?") # NEW - - # Existing boundary logic - if not word.startswith("\""): - word = r"\w*" + word - else: - word = r"\b" + word[1:] - - if not word.endswith("\""): - word = word + r"\w*" - else: - word = word[:-1] + r"\b" - - return word - - regex_patterns = [re.compile(generate_regex_pattern(pattern)) for pattern in database] + regex_patterns = _get_compiled_profanity_patterns(tuple(database)) message = message.lower() # Check the pattern From 8ba2ce4d1dd19edff7702b864821fc9debe5c5bb Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:49:31 -0600 Subject: [PATCH 082/109] fix(reaction-roles): check the message before resolving the member (R4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run_raw_reaction_add` resolved the reacting member (REST fetch on cache miss), the channel, and the message for *every* reaction in *every* guild, and only then asked whether the message was even InfiniBot's. On a busy bot that multiplied REST traffic for reactions the bot could never act on. Reordered so every disqualifying check runs first: the message is fetched, then author, embed presence, and the "react"-prefixed field are checked, and only a message that really is a reaction role goes on to resolve the member. Also added a cheap up-front skip for the bot's own reactions — InfiniBot seeds its own reaction-role messages, so those events were previously paying for a full resolution every time. The nested conditions the checks replace are flattened out; control flow is equivalent, since a non-matching message previously just fell through to `if action:` with action None. The unguarded `message.remove_reaction(...)` on the success path is now wrapped in try/except Forbidden, matching the guarded call already present on the role-missing path; without Manage Messages it was raising into the default event error handler. Not doing the suggested LRU of known non-reaction-role message IDs: the message fetch is now the only pre-filter cost, and a cache of that shape would need invalidation when a reaction role is created or deleted. The DB-backed check at the top of the function stays commented out — per the existing note, it would break pre-database-storage reaction roles. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/reaction_roles.py | 118 +++++++++++++++++++-------------- 1 file changed, 69 insertions(+), 49 deletions(-) diff --git a/src/features/reaction_roles.py b/src/features/reaction_roles.py index ec9594c..a1b9811 100644 --- a/src/features/reaction_roles.py +++ b/src/features/reaction_roles.py @@ -339,6 +339,32 @@ async def run_raw_reaction_add(payload: nextcord.RawReactionActionEvent, bot: ne if not guild.me: return + # InfiniBot seeds its own reaction-role messages with reactions; ignore those + # before doing any lookups. + if bot.user is not None and payload.user_id == bot.user.id: + return + + # Get the message. Everything that can disqualify the message is checked before + # the member is resolved, so reactions on messages InfiniBot could never act on + # cost no member lookup (and no REST fetch on a cache miss). + channel = await utils.get_channel(payload.channel_id) + if channel is None: + return # If the channel doesn't exist, we can't do anything + message = await utils.get_message(channel, payload.message_id) + if message is None: + return # Message doesn't exist or was recently not found + + # Not our message, so it cannot be a reaction role + if message.author.id != bot.application_id: + return + + if not message.embeds: + return + + # Check to see if this is actually a reaction role + if not (len(message.embeds[0].fields) >= 1 and message.embeds[0].fields[0].name.lower().startswith("react")): + return + # Get the user user = await utils.get_member(guild, payload.user_id) if user is None: @@ -346,13 +372,8 @@ async def run_raw_reaction_add(payload: nextcord.RawReactionActionEvent, bot: ne logging.warning(f"User {payload.user_id} not found in guild {guild.id}. Ignoring reaction.") return - # Get the message - channel = await utils.get_channel(payload.channel_id) - if channel is None: - return # If the channel doesn't exist, we can't do anything - message = await utils.get_message(channel, payload.message_id) - if message is None: - return # Message doesn't exist or was recently not found + if user.bot: + return # Declare some functions def get_role(string: str): @@ -376,48 +397,47 @@ async def send_no_permissions_error(role: nextcord.Role, user: nextcord.Member): action = None - # If it was our message and it was not a bot reacting, - if message.author.id == bot.application_id and not user.bot: - if message.embeds: - # Check to see if this is actually a reaction role - if len(message.embeds[0].fields) >= 1 and message.embeds[0].fields[0].name.lower().startswith("react"): # This message IS a reaction role - # Get all options - info = message.embeds[0].fields[0].value - info = info.split("\n") - - # For each option - for line in info: - line_split = line.split(" ") - if str(line_split[0]) == str(emoji): # Ensure that this is a real option - # Get the discord role - discord_role = get_role(" ".join(line_split[1:])) - if discord_role: # If it exists - # Check the user's roles - user_role = nextcord.utils.get(user.roles, id=discord_role.id) - # Give / Take the role - try: - if user_role: - await user.remove_roles(discord_role) - action = "removed" - else: - await user.add_roles(discord_role) - action = "added" - except nextcord.errors.Forbidden: - # No permissions. Send an error - await send_no_permissions_error(discord_role, user) - - # Remove their reaction - await message.remove_reaction(emoji, user) - else: - # If the discord role does not exist, send an error - await send_no_role_error() - # Try to remove their reaction. If we can't, it's fine - try: - await message.remove_reaction(emoji, user) - except nextcord.errors.Forbidden: - pass - return - + # Get all options + info = message.embeds[0].fields[0].value + info = info.split("\n") + + # For each option + for line in info: + line_split = line.split(" ") + if str(line_split[0]) == str(emoji): # Ensure that this is a real option + # Get the discord role + discord_role = get_role(" ".join(line_split[1:])) + if discord_role: # If it exists + # Check the user's roles + user_role = nextcord.utils.get(user.roles, id=discord_role.id) + # Give / Take the role + try: + if user_role: + await user.remove_roles(discord_role) + action = "removed" + else: + await user.add_roles(discord_role) + action = "added" + except nextcord.errors.Forbidden: + # No permissions. Send an error + await send_no_permissions_error(discord_role, user) + + # Remove their reaction. If we can't, it's fine + try: + await message.remove_reaction(emoji, user) + except nextcord.errors.Forbidden: + pass + else: + # If the discord role does not exist, send an error + await send_no_role_error() + # Try to remove their reaction. If we can't, it's fine + try: + await message.remove_reaction(emoji, user) + except nextcord.errors.Forbidden: + pass + return + + # Send embed notifying the user of the action if action: embed = nextcord.Embed( From c8824d8e87c5be79dae04d529a724823d5ebce52 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:50:22 -0600 Subject: [PATCH 083/109] fix(dashboard): stop arming birthdays just by viewing the page (B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BirthdaysView.setup` ran `server.birthdays_profile.runtime = server.birthdays_profile.runtime or "00:00:00"`, and UNSET_VALUE is falsy, so merely *opening* Dashboard -> Birthdays persisted a runtime of midnight. The scheduler arms birthdays for any runtime that is not UNSET (birthdays.py:99), so an admin who only looked at the page started getting birthday messages fired at 00:00 UTC without ever choosing a time. The "You must set a message time" warning two lines below was consequently dead — the value it tested had just been assigned. The page now reads the runtime without writing it, renders "Not set" when it is unset, and the warning tests that same value so it displays as intended. Setting a time through the Set Message Time modal still persists normally, and that modal already tolerates an unset runtime via its existing except branch. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/dashboard.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/features/dashboard.py b/src/features/dashboard.py index 47e338a..150621c 100644 --- a/src/features/dashboard.py +++ b/src/features/dashboard.py @@ -3429,19 +3429,20 @@ async def setup(self, interaction: Interaction): birthday_channel = interaction.guild.get_channel(server.birthdays_profile.channel) birthday_channel_ui_text = birthday_channel.mention if birthday_channel else "#unknown" - # Ensure runtime is set - server.birthdays_profile.runtime = server.birthdays_profile.runtime or "00:00:00" - # Convert stored UTC time to server's timezone for display - try: - tz = ZoneInfo(server.infinibot_settings_profile.timezone or "UTC") - utc_time = datetime.datetime.strptime(server.birthdays_profile.runtime, "%H:%M:%S").time() - utc_datetime = datetime.datetime.combine(datetime.date.today(), utc_time).replace(tzinfo=datetime.timezone.utc) - local_datetime = utc_datetime.astimezone(tz) - message_time_ui_text = f"{local_datetime.strftime('%H:%M')} ({tz})" - except Exception as e: - logging.error(f"Birthdays time display error: {str(e)}") + runtime = server.birthdays_profile.runtime + if not runtime: message_time_ui_text = "Not set" + else: + try: + tz = ZoneInfo(server.infinibot_settings_profile.timezone or "UTC") + utc_time = datetime.datetime.strptime(str(runtime), "%H:%M:%S").time() + utc_datetime = datetime.datetime.combine(datetime.date.today(), utc_time).replace(tzinfo=datetime.timezone.utc) + local_datetime = utc_datetime.astimezone(tz) + message_time_ui_text = f"{local_datetime.strftime('%H:%M')} ({tz})" + except Exception as e: + logging.error(f"Birthdays time display error: {str(e)}") + message_time_ui_text = "Not set" description = f""" Celebrate birthdays with InfiniBot's personalized messages. @@ -3457,7 +3458,7 @@ async def setup(self, interaction: Interaction): Utilize InfiniBot's [Generic Replacements](https://cypress-exe.github.io/InfiniBot/docs/messaging/generic-replacements/) to customize your birthday message. View the [help docs](https://cypress-exe.github.io/InfiniBot/docs/messaging/birthdays/) for more information. - """ + ("" if server.birthdays_profile.runtime else "\n⚠️ **You must set a message time before birthdays will work!**") + """ + ("" if runtime else "\n⚠️ **You must set a message time before birthdays will work!**") description = utils.standardize_str_indention(description) From ad3b58038c35b07bef479b7526cee25ef9791fa3 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:50:47 -0600 Subject: [PATCH 084/109] fix(autobans): keep the entry when the ban fails (X1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check_and_run_autoban_for_member` caught Forbidden (and every other exception) from `member.ban()` and then fell straight through to `server.autobans.delete(member.id)`, returning True. So a single attempt made without Ban Members permission destroyed the autoban entry permanently — the member stayed in the guild and would never be autobanned again, even once the permission was granted. The True return also told `on_member_join` the member had been removed, so their welcome message and default roles were skipped despite them still being present. Now tracks whether the ban actually succeeded: on failure the entry is retained so the autoban retries on the next join, and the function returns False so the join is processed as a normal one. The owner notification for the missing permission is unchanged. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/autobans.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/features/autobans.py b/src/features/autobans.py index 2cac5e8..84ff287 100644 --- a/src/features/autobans.py +++ b/src/features/autobans.py @@ -22,8 +22,10 @@ async def check_and_run_autoban_for_member(member: nextcord.Member) -> bool: await asyncio.sleep(1) # Now proceed with the autoban + banned = False try: await member.ban(reason="Autoban triggered") + banned = True logging.info(f"Autobanned member {member.id} ({member.name}) in guild {member.guild.id}") except nextcord.Forbidden: logging.error(f"Failed to autoban member {member.id} ({member.name}) in guild {member.guild.id}: Forbidden") @@ -31,8 +33,13 @@ async def check_and_run_autoban_for_member(member: nextcord.Member) -> bool: except Exception as e: logging.error(f"Failed to autoban member {member.id} ({member.name}) in guild {member.guild.id}: {e}") + if not banned: + # Keep the entry so the autoban retries once the permission is granted. + # The member is still here, so treat the join as a normal one. + return False + await asyncio.sleep(5) # Ensure the ban is processed before removing autoban entry - + # Remove autoban entry after banning server = Server(member.guild.id) server.autobans.delete(member.id) From 944774147b315f618d9ce3985e7470a19ae68a89 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:51:28 -0600 Subject: [PATCH 085/109] fix(dashboard): preselect the member's actual strike count (DASH2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `server.moderation_strikes.get(member_id, 0)` returns the row dataclass, not an int, so `default=(level == self.member_strikes)` compared an int against a dataclass and was never true. The strike-level dropdown opened with nothing preselected for exactly the members who had strikes — the only members it is ever opened for. Now reads `.strikes` off the record, falling back to 0 when the member has no row. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Good catch. --- src/features/dashboard.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard.py b/src/features/dashboard.py index 150621c..c19cee6 100644 --- a/src/features/dashboard.py +++ b/src/features/dashboard.py @@ -868,8 +868,10 @@ def __init__(self, outer, guild: nextcord.Guild, user_selection: nextcord.User): self.member_id = user_selection.id server = Server(guild.id) - self.member_strikes = server.moderation_strikes.get(self.member_id, 0) - + strike_record = server.moderation_strikes.get(self.member_id) + self.member_strikes = strike_record.strikes if strike_record else 0 + + strike_levels = [ nextcord.SelectOption(label=str(level), default=(level==self.member_strikes)) for level in range(server.profanity_moderation_profile.max_strikes + 1) From 8797414e01d47f44b33e10cbdd5c24216ae9ecd6 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:52:10 -0600 Subject: [PATCH 086/109] fix(datetime): store UTC-aware timestamps in the dashboard strike editor (DASH3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard strike edits wrote `last_strike=datetime.datetime.now()` — naive local time — while every other writer (moderation.py:389, 398, 1022) stores UTC-aware. `parse_datetime_string` assumes a naive value is already UTC, so on a host whose local timezone is not UTC the strike- expiry math shifted by the offset. Demoed locally: a naive "2026-07-18 23:51" was read back as 23:51 UTC when the true UTC instant was 05:51 the next day, a 6-hour error. Latent in production, where the container runs UTC, but wrong anywhere else. Also switched the cosmetic embed timestamps the report lists alongside it: join/leave messages (join_leave_messages.py:52, 112) and the two moderation embeds (moderation.py:586, 656). Verified the UTC-aware string round-trips through `parse_datetime_string` unchanged. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a consistency error that was properly resolved **Human Review Notes:** Again, would never manifest because the container is UTC, but it's still a good change. --- src/features/dashboard.py | 4 ++-- src/features/join_leave_messages.py | 4 ++-- src/features/moderation.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/features/dashboard.py b/src/features/dashboard.py index c19cee6..d2dc3e0 100644 --- a/src/features/dashboard.py +++ b/src/features/dashboard.py @@ -900,12 +900,12 @@ async def confirm_selection(self, interaction: Interaction): if self.member_id in server.moderation_strikes: if strikes != 0: - server.moderation_strikes.edit(self.member_id, strikes=strikes, last_strike=datetime.datetime.now()) + server.moderation_strikes.edit(self.member_id, strikes=strikes, last_strike=datetime.datetime.now(datetime.timezone.utc)) else: server.moderation_strikes.delete(self.member_id) else: if strikes != 0: - server.moderation_strikes.add(member_id=self.member_id, strikes=strikes, last_strike=datetime.datetime.now()) + server.moderation_strikes.add(member_id=self.member_id, strikes=strikes, last_strike=datetime.datetime.now(datetime.timezone.utc)) await self.outer.setup(interaction) diff --git a/src/features/join_leave_messages.py b/src/features/join_leave_messages.py index eda3190..a9bd46a 100644 --- a/src/features/join_leave_messages.py +++ b/src/features/join_leave_messages.py @@ -49,7 +49,7 @@ async def trigger_join_message(member: nextcord.Member) -> None: # Send message join_message_embed: nextcord.Embed = server.join_message_profile.embed.to_embed() join_message_embed = utils.apply_generic_replacements(join_message_embed, member, member.guild) - join_message_embed.timestamp = datetime.datetime.now() + join_message_embed.timestamp = datetime.datetime.now(datetime.timezone.utc) embeds = [join_message_embed] # Join Card (If enabled) @@ -109,6 +109,6 @@ async def trigger_leave_message(guild: nextcord.Guild, member: nextcord.abc.User # Send message leave_message_embed: nextcord.Embed = server.leave_message_profile.embed.to_embed() leave_message_embed = utils.apply_generic_replacements(leave_message_embed, member, guild) - leave_message_embed.timestamp = datetime.datetime.now() + leave_message_embed.timestamp = datetime.datetime.now(datetime.timezone.utc) await channel.send(embed = leave_message_embed) \ No newline at end of file diff --git a/src/features/moderation.py b/src/features/moderation.py index 3928e6a..5cca1bd 100644 --- a/src/features/moderation.py +++ b/src/features/moderation.py @@ -583,7 +583,7 @@ async def check_and_trigger_profanity_moderation_for_message( description += "\nYou're getting this message because your server has enabled Profanity Moderation for messages. If you believe this was a mistake, please contact a server admin, and they can [disable it](https://cypress-exe.github.io/InfiniBot/docs/core-features/moderation/profanity/#how-to-disable-profanity-moderation)." description = utils.standardize_str_indention(description) - embed = nextcord.Embed(title="Profanity Log", description=description, color=nextcord.Color.red(), timestamp=datetime.datetime.now()) + embed = nextcord.Embed(title="Profanity Log", description=description, color=nextcord.Color.red(), timestamp=datetime.datetime.now(datetime.timezone.utc)) embed.set_footer(text="To opt out of dm notifications, use /opt_out_of_dms") await message.author.send(embed=embed) @@ -653,7 +653,7 @@ async def check_and_trigger_profanity_moderation_for_message( description = utils.standardize_str_indention(description) - embed = nextcord.Embed(title="Profanity Detected", description=description, color=nextcord.Color.dark_red(), timestamp=datetime.datetime.now()) + embed = nextcord.Embed(title="Profanity Detected", description=description, color=nextcord.Color.dark_red(), timestamp=datetime.datetime.now(datetime.timezone.utc)) embed.set_footer(text=f"Member ID: {str(message.author.id)}") await admin_channel.send(view=view, embed=embed) From bb8834db10568fe5038293b1ccff14e63a94435a Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:53:23 -0600 Subject: [PATCH 087/109] fix(dashboard): explain the problem instead of crashing when no channel is selectable (DASH4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SelectView` raises ValueError on an empty options list. Both channel- select pages build their options by filtering on `utils.check_text_channel_permissions`, so a guild where InfiniBot cannot view/send in any text channel produced an empty list and the generic error embed. The profanity Admin-Channel page is force- redirected to whenever the channel is UNSET, so an admin in that state could hit the error immediately on opening the dashboard, with nothing telling them what was wrong. Adds `show_no_selectable_channels`, which renders the specific cause and the fix (grant View Channel + Send Messages somewhere) with a Back button routed through the existing cancel path. Both pages now check for empty options before constructing the SelectView. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** I moved the show_no_selectable_channels function into components/ui_components. Claude initially put it in features/dashboard.py. --- src/components/ui_components.py | 29 +++++++++++++++++++++++++++++ src/features/dashboard.py | 24 ++++++++++++++++++++---- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/components/ui_components.py b/src/components/ui_components.py index da3b39c..44d4d26 100644 --- a/src/components/ui_components.py +++ b/src/components/ui_components.py @@ -139,6 +139,35 @@ async def chunk_guild_with_feedback(interaction: Interaction) -> int: ) return None +async def show_no_selectable_channels(interaction: Interaction, title: str, back_callback): + """ + Render an actionable message for channel-select pages when InfiniBot cannot use + any text channel. SelectView rejects an empty options list, so these pages must + not be opened at all in that state. + + :param interaction: The interaction to respond to. + :type interaction: Interaction + :param title: The title to show, matching the page the user was headed to. + :type title: str + :param back_callback: Coroutine function taking the interaction, used by the Back button. + """ + description = """ + InfiniBot can't view and send messages in any of your text channels, so there are no channels to choose from. + + **How to fix this** + Give InfiniBot both *View Channel* and *Send Messages* permission in at least one text channel, then return to this page. + """ + embed = nextcord.Embed(title=title, + description=utils.standardize_str_indention(description), + color=nextcord.Color.red()) + + view = CustomView() + back_btn = nextcord.ui.Button(label="Back", style=nextcord.ButtonStyle.danger) + back_btn.callback = back_callback + view.add_item(back_btn) + + await interaction.response.edit_message(embed=embed, view=view) + # Components def get_colors_available_ui_component(): description = "" diff --git a/src/features/dashboard.py b/src/features/dashboard.py index d2dc3e0..6de24a7 100644 --- a/src/features/dashboard.py +++ b/src/features/dashboard.py @@ -11,7 +11,7 @@ from nextcord import Interaction from components import ui_components, utils -from components.ui_components import CustomView, CustomModal, chunk_guild_with_feedback +from components.ui_components import CustomView, CustomModal, chunk_guild_with_feedback, show_no_selectable_channels from config.global_settings import ShardLoadedStatus # Leave import from config.server import Server from features.action_logging import get_logging_channel @@ -622,7 +622,14 @@ async def callback(self, interaction: Interaction, skipped = False): if self.skipped: title = "Dashboard - Moderation - Profanity" else: title = "Dashboard - Moderation - Profanity - Admin Channel" - + + if not select_options: + async def go_back(interaction: Interaction): + await self.select_view_callback(interaction, None) + await show_no_selectable_channels(interaction, title, go_back) + return + + description = """ Where should InfiniBot send moderation reports? @@ -1818,9 +1825,18 @@ async def callback(self, interaction: Interaction, skipped = False): """ description = utils.standardize_str_indention(description) - embed = nextcord.Embed(title = ("Dashboard - Logging" if self.skipped else "Dashboard - Logging - Log Channel"), + title = ("Dashboard - Logging" if self.skipped else "Dashboard - Logging - Log Channel") + + if not select_options: + async def go_back(interaction: Interaction): + await self.select_view_callback(interaction, None) + await show_no_selectable_channels(interaction, title, go_back) + return + + embed = nextcord.Embed(title = title, description = description, color = nextcord.Color.blue()) - + + await ui_components.SelectView(embed, select_options, self.select_view_callback, From e4f1dd6737118ca08962df6cdd2a91ab0c7b660f Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:53:48 -0600 Subject: [PATCH 088/109] fix(dashboard): validate the level-reward "Choose Level" input (DASH5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LevelRewardsView`'s LevelModal called `int(self.input.value)` with no validation, unlike its sibling modals (MaxStrikes, StrikeExpireTime, and the other Choose Level modal at :2133), so any non-numeric entry raised ValueError and surfaced as the generic modal error. Now rejects non-digits and enforces the 1-9999 range the field's own placeholder advertises, reporting it the same way the sibling modal does. Verified against empty, whitespace, alphabetic, decimal, negative, zero, and out-of-range inputs. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/dashboard.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard.py b/src/features/dashboard.py index 6de24a7..a4b4e40 100644 --- a/src/features/dashboard.py +++ b/src/features/dashboard.py @@ -2280,9 +2280,14 @@ def __init__(self, outer, role_id): self.add_item(self.input) async def callback(self, interaction: Interaction): - self.role_id + # Check + if (not self.input.value.strip().isdigit()) or not (1 <= int(self.input.value) <= 9999): + embed = nextcord.Embed(title = "Invalid Level", description = "The level needs to be a number between 1 and 9999.", color = nextcord.Color.red()) + await interaction.response.send_message(embed = embed, ephemeral = True) + return + level = int(self.input.value) - + server = Server(interaction.guild.id) server.level_rewards.add(role_id = self.role_id, level = level) From 60089e172254ac89bf9d420bdca5788410db59b0 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:55:04 -0600 Subject: [PATCH 089/109] fix(leveling): delete zero-point rows on the run that zeroes them (L1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daily cleanup tested `member_level_info.points`, the pre-decrement value, so a member whose points reached 0 on this run kept their row until the next day's run. Now tests the freshly computed `_points`. Investigated the human note asking whether member levels are being updated at all. They are — this was checked by running `daily_leveling_maintenance` against a real SQLite database with three members (well above zero, exactly reaching zero, already at zero): before run 1: {1: 100, 2: 5, 3: 0} after run 1: {1: 95, 2: 0} <- member 2 zeroed but row retained after run 2: {1: 90} <- removed a day late Points are decremented and persisted correctly on every run, and `server.member_levels.edit(...)` writes through as expected, so the "not updating at all" concern does not hold. The only defect is the delayed row deletion. With the fix the same scenario gives {1: 95} after run 1 — member 2 is removed on the run that zeroes them, and the already-zero member 3 is still removed as before. Row deletion stays ordered after `process_level_change`, so level rewards are still revoked using the member's real 0-point state before the row goes away. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Duh, yeah. Of course it decrements. I missed that somehow. --- src/features/leveling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/leveling.py b/src/features/leveling.py index 64ce38a..3e4d5eb 100644 --- a/src/features/leveling.py +++ b/src/features/leveling.py @@ -251,7 +251,7 @@ async def daily_leveling_maintenance(bot: nextcord.Client, guild: nextcord.Guild # This is purely a database optimization to reduce the number of rows in # the member_levels table. Members without records are assumed to have 0 # points elsewhere in the codebase. - if member_level_info.points == 0: + if _points == 0: server.member_levels.delete(member_level_info.member_id) except Exception as err: From 6d43cfe3a76ade8e7fb36f046a9a7b003e6e0ffd Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:55:56 -0600 Subject: [PATCH 090/109] fix(logging): don't offer "Show More" when the log is already at 10 embeds (A1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deleted message carrying exactly 9 embeds produced a 10-embed log message (1 log embed + 9 attached). Clicking "Show More" appended an 11th, so `edit_message` returned HTTP 400 and the moderator got the generic view error. Per the report's stated solution, the button is now withheld when the log message would already be at the limit. Traced across attachment counts 0-12: only the 9-embed case withholds it, and no case can reach 11. The callback is also guarded. `ShowMoreButton` is a persistent view keyed on the `show_more` custom_id, so log messages sent before this change still carry a live button; those now get an ephemeral explanation instead of a 400. Introduces `MAX_EMBEDS_PER_MESSAGE` rather than repeating the bare 10. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was mostly properly resolved **Human Review Notes:** I agree with the later report down the line that says that MAX_EMBEDS_PER_MESSAGE should be abstracted everywhere. Other than that, I like this change. --- src/features/action_logging.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/features/action_logging.py b/src/features/action_logging.py index b9201c6..6d4cd5c 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -15,6 +15,9 @@ from modules.custom_types import UNSET_VALUE, ExpiringSet +MAX_EMBEDS_PER_MESSAGE = 10 +"""Discord's hard limit on embeds in a single message.""" + class ShowMoreButton(ui_components.CustomView): """ A View that will be used for the Action Logging feature. @@ -44,6 +47,18 @@ async def event(self, button: nextcord.ui.Button, interaction: nextcord.Interact rendered_label = component.label if rendered_label == "Show More": + # Log messages sent before the button was gated can still be at the limit. + if len(interaction.message.embeds) >= MAX_EMBEDS_PER_MESSAGE: + await interaction.response.send_message( + embed = nextcord.Embed( + title = "No Room to Show More", + description = f"This log message already carries the maximum of {MAX_EMBEDS_PER_MESSAGE} embeds, so the extra information can't be attached to it.", + color = nextcord.Color.red() + ), + ephemeral = True + ) + return + # Show more embed = interaction.message.embeds[0] code = embed.footer.text.split(" ")[-1] @@ -471,8 +486,12 @@ async def trigger_delete_log(bot: nextcord.Client, channel: nextcord.TextChannel embed.set_footer(text = f"Message ID: {message_id}\nCode: {code}") # Actually send the embed - view = ShowMoreButton() - log_message = await log_channel.send(view = view, embeds = ([embed] + (embeds[0:8] if len(embeds) >= 10 else embeds))) + all_embeds = [embed] + (embeds[0:8] if len(embeds) >= 10 else embeds) + + # "Show More" appends an 11th embed, which Discord rejects. Only offer it when + # the log message leaves room. + view = ShowMoreButton() if len(all_embeds) < MAX_EMBEDS_PER_MESSAGE else None + log_message = await log_channel.send(view = view, embeds = all_embeds) if message and message.attachments != []: await files_computation(message, log_channel, log_message) From fbf0140d4cc0244e59a2f28f1b312ed23884b2df Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:56:11 -0600 Subject: [PATCH 091/109] fix(jokes): report an empty joke list instead of crashing (J3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `random.choice(all_jokes)` raises IndexError on an empty list — which happens when jokes.json fails to load — and the `if joke is None` guard sitting after it could never fire, since `random.choice` either returns an element or raises. Replaced with an emptiness check before the call, reporting the condition to the user ephemerally and logging the cause. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/jokes.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/features/jokes.py b/src/features/jokes.py index cb1f332..9361c5a 100644 --- a/src/features/jokes.py +++ b/src/features/jokes.py @@ -742,13 +742,25 @@ async def run_joke_command(interaction: Interaction): return all_jokes = JokeManager().jokes - - joke = random.choice(all_jokes) - if joke is None: - logging.error("Joke is None") + if not all_jokes: + logging.error("No jokes are available. The jokes file is empty or failed to load.") + await interaction.response.send_message( + embed=nextcord.Embed( + title="No Jokes Available", + description=( + "InfiniBot couldn't load its jokes. " + "Please try again later, or contact the developers of InfiniBot if this persists." + ), + color=nextcord.Color.red() + ), + ephemeral=True + ) return - + + joke = random.choice(all_jokes) + + embed = _format_joke_embed(joke) await interaction.response.send_message(embed=embed, view=JokeView()) \ No newline at end of file From 2a1446f8c277daf0cea725be278bf718b2dccb13 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:57:04 -0600 Subject: [PATCH 092/109] fix(reaction-roles): parse custom role mentions independent of spacing (R3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `option.split("=")[1][4:-1]` assumed the value was exactly " <@&ID>" with one leading space. Replaced both call sites with a `<@&(\d+)>` regex via a new `extract_role_id` helper. Worth noting the failure was worse than the report states. Two spaces raises and lands in the generic "You formatted that wrong" path as described, but *no* space does not fail — the slice silently drops the leading digit and returns a different, valid-looking role ID: "👍=<@&123456789012345678>" -> 23456789012345678 That only surfaced as an error because the truncated ID matched no role in the guild. Had it collided with a real role, the reaction role would have been created against the wrong one, silently. The regex now returns the same ID for every spacing variant, and returns None on genuinely malformed input, which routes into the existing error message. Also switched the emoji side to `split("=", 1)` with a strip, so a role mention containing "=" can't split incorrectly and extra spacing doesn't leak into the emoji. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/reaction_roles.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/features/reaction_roles.py b/src/features/reaction_roles.py index a1b9811..d7caf6b 100644 --- a/src/features/reaction_roles.py +++ b/src/features/reaction_roles.py @@ -5,7 +5,21 @@ import json from config.server import Server -from components import ui_components, utils +from components import ui_components, utils + +ROLE_MENTION_PATTERN = re.compile(r"<@&(\d+)>") + +def extract_role_id(text: str) -> int | None: + """ + Pull a role ID out of a role mention, tolerating any surrounding whitespace. + + :param text: Text containing a role mention, e.g. " <@&123>". + :type text: str + :return: The role ID, or None if no role mention is present. + :rtype: int | None + """ + match = ROLE_MENTION_PATTERN.search(text) + return int(match.group(1)) if match else None class ReactionRoleModal(ui_components.CustomModal): def __init__(self): @@ -156,7 +170,9 @@ async def run_custom_reaction_role_command(interaction: Interaction, options: st description="Every emoji has to be unique.", color=nextcord.Color.red()), ephemeral=True) return - role_ID = int(option.split("=")[1][4:-1].strip()) + role_ID = extract_role_id(option.split("=", 1)[1]) + if role_ID is None: + raise Exception # Trigger the error message at end of try block role = [role for role in interaction.guild.roles if role.id == role_ID][0] if role_ID in role_IDs: await interaction.response.send_message(embed = nextcord.Embed(title="Error: You can't use the same role twice", description="Every role has to be unique.", @@ -245,9 +261,9 @@ async def create_reaction_role(interaction: Interaction, title: str, message: st roles: list[nextcord.Role] = [role for role_name in roles_str for role in interaction.guild.roles if role.name == role_name] emojis = None else: - new_roles_str = [int(role.split("=")[1][4:-1].strip()) for role in roles_str] + new_roles_str = [extract_role_id(role.split("=", 1)[1]) for role in roles_str] roles: list[nextcord.Role] = [role for role_ID in new_roles_str for role in interaction.guild.roles if role.id == role_ID] - emojis: list[str] = [emoji.split("=")[0] for emoji in roles_str] + emojis: list[str] = [emoji.split("=", 1)[0].strip() for emoji in roles_str] # Ensure that these roles are grantable for role in roles: From b332accc1442b6bf32e8f709955e26bf4bbf3d88 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:57:21 -0600 Subject: [PATCH 093/109] fix(dm-commands): reference the real slash command names (X2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opt-in/opt-out confirmations told users to run `/opt-into-dms` and `/opt-out-of-dms`, but the commands are registered as `opt_into_dms` and `opt_out_of_dms` (bot.py:362, 374), so neither name existed and the instruction was a dead end. These two strings were the only dashed references; every other mention across the codebase already used the underscore form. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/dm_commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/dm_commands.py b/src/features/dm_commands.py index 3fa1b10..8760385 100644 --- a/src/features/dm_commands.py +++ b/src/features/dm_commands.py @@ -54,7 +54,7 @@ async def run_opt_out_of_dms_command(interaction: nextcord.Interaction) -> None: embed = nextcord.Embed( title="Opted Out of DMs", - description=f"You opted out of DMs from InfiniBot. You will no longer recieve permission errors, birthday updates, or strike notices. To re-opt-into this feature, use `/opt-into-dms`", + description=f"You opted out of DMs from InfiniBot. You will no longer recieve permission errors, birthday updates, or strike notices. To re-opt-into this feature, use `/opt_into_dms`", color=nextcord.Color.green() ) await interaction.response.send_message(embed=embed) @@ -86,7 +86,7 @@ async def run_opt_into_dms_command(interaction: nextcord.Interaction) -> None: embed = nextcord.Embed( title="Opted Into DMs", - description=f"You opted into DMs from InfiniBot. You will now recieve permission errors, birthday updates, and strike notices. To re-opt-out of this feature, use `/opt-out-of-dms`", + description=f"You opted into DMs from InfiniBot. You will now recieve permission errors, birthday updates, and strike notices. To re-opt-out of this feature, use `/opt_out_of_dms`", color=nextcord.Color.green() ) await interaction.response.send_message(embed=embed) \ No newline at end of file From 68f14f82adf4a121955aeed47484ea440743c601 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:58:25 -0600 Subject: [PATCH 094/109] fix(bot): don't repeat one-time startup work when on_ready re-fires (C3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nextcord dispatches `on_ready` again whenever a session is invalidated and re-established, and the handler had no notion of having already run. Every re-fire re-ran `init_views`, refetched the emoji cache, re-sent the "InfiniBot is online!" startup notification, and logged a STARTUP COMPLETE line whose duration was measured against the *original* `startup_time`, so the reported elapsed time and guilds/sec were nonsense. Adds a `startup_completed` flag. `start_scheduler()` still runs on every fire (it already handles restart); everything after it is now skipped on a re-fire, with a log line marking the reconnect. Confirmed against the installed nextcord 3.2.0 that skipping `init_views` is safe: `ConnectionState.parse_ready` calls `self.clear(views=False)` (state.py:1265) and the AutoSharded `parse_ready` doesn't clear at all, so the persistent view store survives a re-ready. This also matches the verifier's correction that the view-store *accumulation* sub-claim was refuted — ViewStore keys by (component_type, message_id, custom_id) and overwrites — so the re-registration was wasteful rather than leaky. The stale-duration logging and the duplicate startup notification are the substantive fixes here. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Good change. This was an issue I've experienced in prod. --- src/core/bot.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/core/bot.py b/src/core/bot.py index 3e55e8a..b0f4af5 100644 --- a/src/core/bot.py +++ b/src/core/bot.py @@ -48,6 +48,8 @@ import humanfriendly startup_time = None +startup_completed = False +"""Set once on_ready has finished its one-time work, so a re-fire can skip it.""" # INIT BOT ============================================================================================================================================================== intents = nextcord.Intents.default() @@ -122,8 +124,8 @@ async def on_ready() -> None: # Bot load :return: None :rtype: None """ - global startup_time - + global startup_time, startup_completed + await bot.wait_until_ready() logging.info(f"============================== Logged in as: {bot.user.name} with all shards ready. ==============================") logging.info(f"Bot is running with {bot.shard_count} shards across {len(bot.guilds)} guilds.") @@ -132,6 +134,14 @@ async def on_ready() -> None: # Bot load global_settings.set_bot_load_status(True) start_scheduler() + if startup_completed: + # nextcord dispatches on_ready again when a session is re-established. + # Persistent views survive that (parse_ready clears with views=False), so the + # one-time work below must not repeat — and the startup duration measured + # against the original startup_time would be meaningless. + logging.info("Session re-established. Skipping one-time startup work.") + return + # Initialize the bot's view cache init_views(bot) @@ -187,6 +197,8 @@ async def send_startup_notification(): formatted_time = humanfriendly.format_timespan(round(total_startup_time)) logging.info(f"🚀 STARTUP COMPLETE: Total time {formatted_time} for {len(bot.guilds)} guilds ({len(bot.guilds)/total_startup_time:.1f} guilds/sec)") + startup_completed = True + @bot.event async def on_shard_ready(shard_id: int) -> None: From 1beb69f9fa468c3013148d434d5bca9773de5628 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sat, 18 Jul 2026 23:59:28 -0600 Subject: [PATCH 095/109] fix(utils): make get_member tolerate string IDs; pass an int from the autoban modal (X3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autoban add-modal read `self.id_input.value` and passed the `str` straight to `utils.get_member`. `Guild.get_member` is a `self._members.get(user_id)` dict lookup keyed by int, so the cache lookup always missed and every check paid a REST fetch. The negative `failed_member_fetches` cache is keyed by `(guild.id, user_id)` and missed for the same reason, so even repeat lookups of a nonexistent user re-fetched. Per the human note, the hardening went into `get_member` itself rather than only the call site: it now coerces `user_id` with `int()` up front, so a string ID hits both caches normally, and returns None with a warning for input that isn't a valid ID at all instead of failing deeper in. Verified that both `123` and `"123"` resolve from the member cache without any REST fetch, and that `"abc"`/`None` return None. The call site converts once now, so the downstream `int(user_id)` calls in the ban-list comparison and the autoban record are dropped as redundant. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/components/utils.py | 12 ++++++++++-- src/features/dashboard.py | 6 ++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/utils.py b/src/components/utils.py index c08f3ce..3e440c3 100644 --- a/src/components/utils.py +++ b/src/components/utils.py @@ -780,11 +780,19 @@ async def get_member(guild: nextcord.Guild, user_id: int, override_failed_cache: :rtype: nextcord.Member | None """ global failed_member_fetches - + if not guild or guild.unavailable: logging.warning(f"Guild {guild} is unavailable or None. Cannot get member.") return None - + + # guild._members and the failed-fetch cache are both keyed by int; a str id would + # miss both and force a REST fetch every call. + try: + user_id = int(user_id) + except (TypeError, ValueError): + logging.warning(f"Invalid user_id passed to get_member: {user_id!r}") + return None + if member := guild.get_member(user_id): return member diff --git a/src/features/dashboard.py b/src/features/dashboard.py index a4b4e40..a1c5883 100644 --- a/src/features/dashboard.py +++ b/src/features/dashboard.py @@ -4681,6 +4681,8 @@ async def callback(self, interaction: Interaction): # saving ) return + user_id = int(user_id) + if await utils.get_member(interaction.guild, user_id): embed = nextcord.Embed( title="User Already In Server", @@ -4697,7 +4699,7 @@ async def callback(self, interaction: Interaction): # saving try: async for ban_entry in interaction.guild.bans(): if not ban_entry.user: continue - if ban_entry.user.id == int(user_id): + if ban_entry.user.id == user_id: embed = nextcord.Embed( title="User Already Banned", description=(f"InfiniBot won't add \"{user_name} (ID: {user_id})\" as an " @@ -4715,7 +4717,7 @@ async def callback(self, interaction: Interaction): # saving # Use the correct autobans property and its methods new_autoban = { 'member_name': user_name, - 'member_id': int(user_id) + 'member_id': user_id } try: From 72fbb0610470a6831f2641d349ef0c8efcbbb633 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 19 Jul 2026 11:21:34 -0600 Subject: [PATCH 096/109] feat: prod release scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented prod release scripts that handle db backup and container tagging. - release_prod.bash — DB backup & container tag - rollback_prod.bash — Container restore (no db restore!) - backup_prod_db.bash — Uses sqlite or system copy fallback to backup db Co-Authored-By: Claude Sonnet 5 --- backup_prod_db.bash | 44 ++++++++++++++++++++++++++++++++++++++++++++ release_prod.bash | 30 ++++++++++++++++++++++++++++++ rollback_prod.bash | 29 +++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 backup_prod_db.bash create mode 100644 release_prod.bash create mode 100644 rollback_prod.bash diff --git a/backup_prod_db.bash b/backup_prod_db.bash new file mode 100644 index 0000000..5b91857 --- /dev/null +++ b/backup_prod_db.bash @@ -0,0 +1,44 @@ +#!/bin/bash +# AGENT INSTRUCTIONS: If editing the file, ensure that your model name is included here. +# Generated by Anthropic Claude Sonnet 5 via Claude Code (CLI). +# Makes a consistent point-in-time copy of generated/files/prod.db into +# generated/backups/, using SQLite's online backup API so it's safe to run +# while the bot container is up and writing to the DB. + +set -e + +DB_PATH="./generated/files/prod.db" +BACKUP_DIR="./generated/backups" + +if [ ! -f "$DB_PATH" ]; then + echo "ERROR: $DB_PATH not found. Run this from the repo root." >&2 + exit 1 +fi + +mkdir -p "$BACKUP_DIR" + +timestamp=$(date -u +"%Y%m%dT%H%M%SZ") +backup_path="${BACKUP_DIR}/prod_${timestamp}.db" + +if command -v sqlite3 >/dev/null 2>&1; then + # The SQLite backup API (.backup) takes a consistent snapshot even while + # another process (the running container) holds the DB open, so the + # container does NOT need to be stopped for this. + sqlite3 "$DB_PATH" ".backup '${backup_path}'" +else + echo "WARNING: sqlite3 CLI not found on this host — falling back to a raw file copy." + echo "This is only safe if the container is stopped (no concurrent writes)." + read -p "Container stopped? Continue with raw copy? [y/N] " confirm + if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then + echo "Aborted." + exit 1 + fi + cp "$DB_PATH" "$backup_path" +fi + +if [ ! -s "$backup_path" ]; then + echo "ERROR: Backup file is missing or empty: $backup_path" >&2 + exit 1 +fi + +echo "Backup written to: $backup_path ($(du -h "$backup_path" | cut -f1))" diff --git a/release_prod.bash b/release_prod.bash new file mode 100644 index 0000000..f9d918a --- /dev/null +++ b/release_prod.bash @@ -0,0 +1,30 @@ +#!/bin/bash +# AGENT INSTRUCTIONS: If editing the file, ensure that your model name is included here. +# Generated by Anthropic Claude Sonnet 5 via Claude Code (CLI). +# Prod release playbook: back up the DB, tag the current image as a rollback +# point, then rebuild and restart the container. Pass --no-cache to force a +# clean Docker build (forwarded to build.bash). +# +# Rollback afterwards: ./rollback_prod.bash + +set -e + +echo "==> Step 1/3: Backing up the database..." +bash ./backup_prod_db.bash + +echo "" +echo "==> Step 2/3: Tagging current image as rollback point..." +if docker image inspect infinibot:latest >/dev/null 2>&1; then + docker tag infinibot:latest infinibot:rollback + echo "Tagged infinibot:latest -> infinibot:rollback" +else + echo "No existing infinibot:latest image found — nothing to tag (first deploy?)." +fi + +echo "" +echo "==> Step 3/3: Rebuilding and restarting the container..." +bash ./rebuild_and_run.bash -d "$@" + +echo "" +echo "Release complete." +echo "If something's wrong, roll back in under a minute with: ./rollback_prod.bash" diff --git a/rollback_prod.bash b/rollback_prod.bash new file mode 100644 index 0000000..30ad8a8 --- /dev/null +++ b/rollback_prod.bash @@ -0,0 +1,29 @@ +#!/bin/bash +# AGENT INSTRUCTIONS: If editing the file, ensure that your model name is included here. +# Generated by Anthropic Claude Sonnet 5 via Claude Code (CLI). +# Fast rollback to the image tagged by release_prod.bash: stop the +# container, retag infinibot:rollback -> infinibot:latest, start it back up. +# No git surgery, no rebuild — under a minute. + +set -e + +if ! docker image inspect infinibot:rollback >/dev/null 2>&1; then + echo "ERROR: No infinibot:rollback image found. Nothing to roll back to." >&2 + echo "(A rollback image is created by release_prod.bash on each release.)" >&2 + exit 1 +fi + +echo "==> Stopping current container..." +bash ./remove_container.bash + +echo "" +echo "==> Retagging infinibot:rollback -> infinibot:latest..." +docker tag infinibot:rollback infinibot:latest + +echo "" +echo "==> Starting rolled-back container..." +bash ./run.bash -d + +echo "" +echo "Rollback complete. Note: this does not restore the database —" +echo "if the DB was also affected, restore it from generated/backups/ manually." From 9c94b5d0b32374d1713f3b1d15f8ac10cb3e4070 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 19 Jul 2026 11:25:00 -0600 Subject: [PATCH 097/109] docs: bug report review #3 Claude Fable 5 did one last pass of the earlier changes to see if there were any bugs in those. It found a few things. I might fix them real quick before pushing this branch, but they're not critical. Co-Authored-By: Claude Fable 5 & Sonnet 5 --- .../claude-reports/fix_review_2026-07.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 documentation/claude-reports/fix_review_2026-07.md diff --git a/documentation/claude-reports/fix_review_2026-07.md b/documentation/claude-reports/fix_review_2026-07.md new file mode 100644 index 0000000..4b1fd76 --- /dev/null +++ b/documentation/claude-reports/fix_review_2026-07.md @@ -0,0 +1,215 @@ + + +# InfiniBot Fix-Commit Review — July 2026 + +**Branch:** `fix/bug-fixes` +**Range reviewed:** `ca60550^..6421c2a` (25 commits, one per bug-hunt finding ID: +D1, M1, H1, R1, R2, C1, J1, J2, E1, C2, M3, R4, B1, X1, DASH2, DASH3, DASH4, DASH5, +L1, A1, J3, R3, X2, C3, X3). +**Method:** Eight parallel review angles (correctness of each fix against its stated +bug, regressions the fix could introduce, code the fix moved/touched without fixing, +adjacent logic sharing the same file/function, cleanup opportunities, and a fresh +top-to-bottom diff read) produced ~19 candidates. The 10 bug-shaped candidates were +each independently re-verified by a `bug-verifier` subagent (static tracing against +the repo). Documentation only — **no code was changed.** + +**Reported symptoms:** none — this was a review of the fix commits themselves, not a +symptom-driven investigation. + +## Verification status + +All 10 bug-shaped candidates were independently re-checked by bug-verifier subagents +(static tracing only). Result: **6 CONFIRMED, 4 refuted** (folded into "Investigated +and refuted" below — no separate false-positive IDs assigned since these were +candidates from this review pass, not carried-over findings). + +## Severity legend + +- **CRITICAL** — data loss / security / breaks the bot broadly +- **HIGH** — breaks a common user-facing path +- **MEDIUM** — edge case with real user impact, or perf that compounds at scale +- **LOW** — cosmetic, latent, or cleanup + +--- + +## The one real regression + +### FR1 — Autoban failure now welcomes the member it meant to ban +**[VERIFIED: CONFIRMED]** +**src/features/autobans.py:36**, **src/core/bot.py:692** (introduced by `bf9d0b7`, fix for X1) + +The X1 fix correctly stopped deleting the autoban entry when `member.ban()` fails +(`Forbidden`), but it also changed `check_and_run_autoban_for_member`'s return value +to `False` in that case (autobans.py:36-38). `on_member_join` uses that single +boolean to mean "not an autoban target": + +```python +if await autobans.check_and_run_autoban_for_member(member): + return +# falls through to trigger_join_message + add_roles_for_new_member +``` + +So when the ban fails — most commonly because the bot lacks Ban Members permission — +the join falls through to `trigger_join_message` and `add_roles_for_new_member`: +**publicly welcoming and auto-roling the exact person an admin flagged for an +autoban.** The pre-X1 code suppressed normal join processing even on a failed ban +(it just also incorrectly deleted the entry). The fix collapsed two different +meanings — "was this member banned" and "should join processing be suppressed" — +into one boolean, and picked the wrong one to preserve. + +**Fix direction:** separate the two meanings. Either return a tri-state (banned / +retry-pending / not-a-target), or simply return `True` whenever the member matched +an autoban entry, regardless of whether the ban call itself succeeded. + +--- + +## Confirmed, but pre-existing (the diff touched or relocated them) + +### FR2 — Filtered words aren't `re.escape`d +**[VERIFIED: CONFIRMED — pre-existing, moved by `bc9c5a0` (M3)]** +**src/features/moderation.py:290** (`_generate_profanity_regex_pattern`) + +An admin adding a filtered word like `(` or `[` through the dashboard — which +validates only length and duplicates, not regex-safety — makes the pattern +`re.compile` call at `_get_compiled_profanity_patterns` (moderation.py:319) raise. +`functools.lru_cache` does not cache exceptions, so the same invalid word +**re-raises on every message** in that guild, not just once. `LogIfFailure` around +the moderation call swallows the exception, so profanity moderation is **silently +disabled for that guild** until an admin removes the bad word — with no error +surfaced anywhere. M3 relocated this code from an uncached per-message call into the +cached helper but did not introduce the missing `re.escape`. +**Fix direction:** `re.escape` the literal portions of `word` before wildcard +substitution (or catch and skip unparseable words with a logged warning at add-time). + +### FR3 — The M3 cache doesn't remove the dominant per-message cost +**[VERIFIED: CONFIRMED]** +**src/features/moderation.py:353** (`str_is_profane`), **`Server.filtered_words`** + +For default-word-list guilds — the common case, since the storage optimization +stores `[]` for "using the default list" — every moderated message still +constructs a fresh `Server` object, whose `filtered_words` getter calls +`read_txt_to_list("default_profane_words.txt")`: an **uncached disk read + parse**, +executed before the M3 pattern cache is even consulted. The regex-compile cost M3 +targeted is real but secondary to this read for the majority of guilds. +**Fix direction:** cache the parsed default word list (module-level, read once) so +the M3 pattern cache actually sees a stable, hashable key on the hot path. + +### FR4 — Orphaned-guild cleanup blocks the event loop +**[VERIFIED: CONFIRMED]** +**src/core/db_manager.py:1172** (`daily_database_maintenance`) + +Its siblings in the same function — `get_database().checkpoint` (line 1136), +`optimize_database(throttle=True)` (line 1145), `stored_messages.cleanup_db` (line +1156) — are all offloaded via `asyncio.to_thread`, with a comment explaining exactly +why ("blocking phases run in a worker thread... would otherwise freeze the bot"). +`cleanup_orphaned_guild_entries(...)` at line 1172 is called directly on the event +loop. It used to *have* to run synchronously (shared pooled connection + a SQLite +TEMP table, per the D1 fix), but D1's `pinned_connection` change removed that +constraint — the old justifying comment for keeping it off-thread was deleted along +with the bug, and nothing was moved back onto `to_thread` to match its siblings. +**Fix direction:** wrap the line-1172 call in `await asyncio.to_thread(...)` like the +other three phases. + +### FR5 — Autoban "already banned?" check paginates the entire ban list +**[VERIFIED: CONFIRMED]** +**src/features/dashboard.py:4729** (autoban-add flow) + +The inline comment at dashboard.py:4725-4726 claims "nextcord doesn't provide a +direct method to check if a user is banned through an ID" and falls back to +`async for ban_entry in interaction.guild.bans(): ...` — paginating the *entire* +ban list on every autoban addition. This is incorrect: `await +guild.fetch_ban(nextcord.Object(id=user_id))` is a single REST call that raises +`NotFound` when the user isn't banned, and it's already used elsewhere in this exact +codebase (`src/features/options_menu/ban_button.py:70`). On a server with thousands +of bans, adding one autoban entry costs seconds of paginated REST calls instead of +one. +**Fix direction:** replace the `async for` loop with a +`try: await guild.fetch_ban(...) / except nextcord.NotFound: pass` pair. + +### FR6 — Reaction-role emoji/role misalignment on role deletion +**[VERIFIED: CONFIRMED, narrow window]** +**src/features/reaction_roles.py:263** (`create_reaction_role`, Custom flow) + +In the Custom flow, `roles` is built by filtering `new_roles_str` against +`interaction.guild.roles` — so a role deleted between validation and creation is +silently dropped from the list. The parallel `emojis` list (line 265) is built +independently from the raw `roles_str` and is **not** filtered, so if any role in +the middle of the list was deleted, the two lists desync and emoji[i] no longer +corresponds to roles[i] for every entry after the gap. This is reachable because an +earlier validation pass runs, then the flow waits on a modal for up to 5 minutes +(plenty of time for a role to be deleted), after which `create_reaction_role` +re-parses the original raw string rather than re-validating against current guild +state. Pre-existing indexing logic in `create_reaction_role`; R3 only changed the +mention-parsing regex used to build `new_roles_str`, not this filtering asymmetry. +**Fix direction:** filter `roles` and `emojis` together (zip, filter, unzip) instead +of filtering `roles` alone. + +--- + +## Cleanup opportunities (verified real, lower stakes) + +- **FR7.** `SelectView` raises `ValueError` on an empty options list + (`src/components/ui_components.py:332`). DASH4 added proper empty-state handling + at 2 call sites (the channel-select pages), but roughly 5 other `SelectView` call + sites across the dashboard still carry their own ad-hoc guards against the same + crash. The empty-state handling belongs inside the shared `SelectView` component + so new pages can't regress it. +- **FR8.** The R2 fix (using the field index as the select option value, plus an + `option_roles` map to resolve it) was pasted identically into both + `RoleMessageButton_Single` and `RoleMessageButton_Multiple` + (`src/features/role_messages.py:25` and `:122`). The two classes are near-clones; + any future encoding fix to this mechanism has to be made twice, and it's easy to + update one and forget the other. +- **FR9.** `extract_role_id` (`src/features/reaction_roles.py:12`) is roughly the + 5th independent implementation of `<@&id>` mention parsing in the codebase. + Worth extracting to one shared utility so fixes like R3 (spacing-independent + parsing) don't need to be re-discovered per copy. +- **FR10.** `src/features/action_logging.py:467` and `:489` still use bare literals + (`> 9`, `[0:8]`, `>= 10`) for the embed-count limit right next to the new + `MAX_EMBEDS_PER_MESSAGE` constant A1 introduced. If the 10-embed limit logic ever + changes, these will silently drift out of sync with the constant. + +Minor items noted but cut for being below the 10-finding review cap: the DM-send +try/except-Forbidden boilerplate repeated at roughly 7 call sites, a duplicated +`go_back` closure (`dashboard.py:656` and `:1860`), `raise Exception` used as a +goto-style control-flow mechanism in `reaction_roles.py:165` and `:175`, and the +profanity `functools.lru_cache(maxsize=256)` sizing (untuned, no data behind the +number). + +--- + +## Investigated and refuted (no action needed) + +- **`start_scheduler()` re-running on `on_ready` re-fires (C3 concern).** Run times + are grid-aligned to wall clock (e.g. `:00/:15/:30/:45`), so a reconnect that + re-triggers scheduler startup converges on the same boundary as the original + schedule rather than drifting or duplicating runs. No starvation or duplicate-run + risk. +- **The R4 reorder (message fetched before member).** Net *reduction* in REST calls + versus the prior code, which fetched both unconditionally regardless of whether + the early-exit conditions were met. Not a regression. +- **`get_channel`/`get_message` lacking the X3 string-ID coercion.** Every call site + was traced; all already pass native `int`s. `utils.get_member`'s new tolerance for + string IDs (X3) is a hardening improvement for future callers, not a fix for a + currently-reachable bug elsewhere. +- **`asci_to_emoji` mishandling counts of "11" or more.** Option counts are + structurally capped at 10 in two independent places (the select-menu builder and + the Custom/Numbers type validation), so the theoretical double-digit-overflow path + is unreachable. + +--- + +*Generated by Anthropic Claude Fable 5 and documented by Sonnet 5 via Claude Code (CLI). +Documentation-only pass — no source files were modified. Line references are against +`fix/bug-fixes` at `6421c2a`; verify against current tree before fixing.* From a733e5cb7dfa6fad4e195e390cc68fa18f443ed5 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 19 Jul 2026 15:59:35 -0600 Subject: [PATCH 098/109] fix(autobans): suppress join processing when an autoban ban fails (FR1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_and_run_autoban_for_member's return value was overloaded to mean both "was this member banned" and "should join processing be suppressed". When the ban call failed (usually missing Ban Members), returning False caused on_member_join to fall through to trigger_join_message and add_roles_for_new_member — publicly welcoming and auto-roling the exact member an admin flagged for an autoban. Now returns True whenever the member matched an autoban entry, regardless of whether the ban call succeeded. The entry is still retained on failure so the autoban retries once the permission is granted. Also updated the error message sent to the server owner, explaining the situation in improved quality. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/autobans.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/features/autobans.py b/src/features/autobans.py index 84ff287..ac553ae 100644 --- a/src/features/autobans.py +++ b/src/features/autobans.py @@ -12,7 +12,9 @@ async def check_and_run_autoban_for_member(member: nextcord.Member) -> bool: Args: member (nextcord.Member): The member to check for autoban. Returns: - bool: True if the member was autobanned, False otherwise. + bool: True if the member matched an autoban entry (whether or not the ban + call itself succeeded), meaning normal join processing should be + suppressed. False if the member is not an autoban target. """ if member_has_autoban(member.guild.id, member.id): @@ -23,20 +25,37 @@ async def check_and_run_autoban_for_member(member: nextcord.Member) -> bool: # Now proceed with the autoban banned = False + missing_permission = False try: await member.ban(reason="Autoban triggered") banned = True logging.info(f"Autobanned member {member.id} ({member.name}) in guild {member.guild.id}") except nextcord.Forbidden: logging.error(f"Failed to autoban member {member.id} ({member.name}) in guild {member.guild.id}: Forbidden") - await send_error_message_to_server_owner(member.guild, "Ban Members", guild_permission=True) + missing_permission = True except Exception as e: logging.error(f"Failed to autoban member {member.id} ({member.name}) in guild {member.guild.id}: {e}") if not banned: # Keep the entry so the autoban retries once the permission is granted. - # The member is still here, so treat the join as a normal one. - return False + # The member is still an autoban target, so don't welcome or auto-role them. + if missing_permission: + reason = "since it's missing the \"Ban Members\" permission" + remedy = " Additionally, ensure InfiniBot has the \"Ban Members\" permission so that it can enforce future autobans." + else: + reason = "due to an unexpected error" + remedy = "" + + message = ( + f"Failed to autoban member {member.name} ({member.id}) in guild {member.guild.name}.\n\n" + f"**What Happened:** InfiniBot detected that {member.name} just joined your server. InfiniBot failed to " + f"execute their autoban {reason}. {member.name} *is* still in your server, but InfiniBot " + f"intentionally did not grant them any roles or welcome them. Their autoban record remains in place, " + f"but InfiniBot will not retry unless they leave and rejoin the server.\n\n" + f"**Next Steps:** Consider manually banning the member, since InfiniBot was unable to.{remedy}" + ) + await send_error_message_to_server_owner(member.guild, None, message=message) + return True await asyncio.sleep(5) # Ensure the ban is processed before removing autoban entry From f5cf3db673e83b44eccc0a327ccf00ef8a818007 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 19 Jul 2026 16:00:29 -0600 Subject: [PATCH 099/109] fix(moderation): escape filtered words before building regex patterns (FR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A filtered word containing a regex metacharacter (e.g. "(" or "[") made re.compile raise inside _get_compiled_profanity_patterns. functools.lru_cache does not cache exceptions, so the bad word re-raised on every message in that guild, and LogIfFailure swallowed it — silently disabling profanity moderation for the whole guild with no error surfaced anywhere. The literal portion of each word is now re.escape'd, with the boundary markers stripped beforehand and the supported wildcards (* and ?) re-introduced after, so admin-supplied metacharacters match literally instead of breaking compilation. The shipped default word list uses only ? and " markers, so its behavior is unchanged. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/features/moderation.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/features/moderation.py b/src/features/moderation.py index 5cca1bd..8b406b7 100644 --- a/src/features/moderation.py +++ b/src/features/moderation.py @@ -300,23 +300,30 @@ def _generate_profanity_regex_pattern(word: str) -> str: :rtype: str """ word = word.lower() + + # Boundary logic. The markers are stripped before escaping so they don't end up + # as literal quote characters in the pattern. + exact_start = word.startswith("\"") + if exact_start: + word = word[1:] + + exact_end = word.endswith("\"") + if exact_end: + word = word[:-1] + + # Escape the word so regex metacharacters an admin typed into their filtered-word + # list (e.g. "(" or "[") are matched literally instead of breaking re.compile. + word = re.escape(word) + # Handle required wildcards (* = exactly 1 character) - word = word.replace("*", ".") + word = word.replace(re.escape("*"), ".") # Add optional wildcards (? = 0 or 1 characters) - word = word.replace("?", ".?") + word = word.replace(re.escape("?"), ".?") - # Boundary logic - if not word.startswith("\""): - word = r"\w*" + word - else: - word = r"\b" + word[1:] - - if not word.endswith("\""): - word = word + r"\w*" - else: - word = word[:-1] + r"\b" + prefix = r"\b" if exact_start else r"\w*" + suffix = r"\b" if exact_end else r"\w*" - return word + return prefix + word + suffix @functools.lru_cache(maxsize=256) def _get_compiled_profanity_patterns(words: tuple[str, ...]) -> tuple[re.Pattern, ...]: From a47fdbc2a3b5114d196ebd1c9852bbdb9a1b4c6f Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 19 Jul 2026 16:01:14 -0600 Subject: [PATCH 100/109] perf(moderation): cache the default profane word list (FR3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For guilds on the default word list — the common case, since the storage optimization stores [] to mean "using the defaults" — every moderated message hit read_txt_to_list("default_profane_words.txt"), an uncached disk read and parse, before the compiled-pattern cache was even consulted. That read dominated the per-message cost the pattern cache was meant to remove. Adds read_txt_to_list_cached in file_manager (lru_cache keyed on file name plus the current base path, cleared by update_base_path so tests and the event harness still see their own files) and uses it for the default word list. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved --- src/config/file_manager.py | 34 +++++++++++++++++++++++++++++++++- src/config/server.py | 6 +++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/config/file_manager.py b/src/config/file_manager.py index 35e2089..142a114 100644 --- a/src/config/file_manager.py +++ b/src/config/file_manager.py @@ -1,4 +1,5 @@ import copy +import functools import json import logging import os @@ -16,6 +17,7 @@ def update_base_path(new_path: str) -> None: """ global base_path base_path = new_path + _read_txt_to_list_cached.cache_clear() def read_txt_to_list(file_name: str) -> list: """ @@ -44,9 +46,39 @@ def read_txt_to_list(file_name: str) -> list: continue result.append(line_strip) - + return result +@functools.lru_cache(maxsize=32) +def _read_txt_to_list_cached(file_name: str, _base_path: str) -> tuple: + """ + Cached variant of :func:`read_txt_to_list` for internal use. + + :param file_name: The name of the text file. + :type file_name: str + :param _base_path: No Op. Used for caching purposes only. The base path for the text file. + :type _base_path: str + :return: A tuple of lines from the text file. + :rtype: tuple + """ + # _base_path is part of the cache key only; read_txt_to_list reads the global. + return tuple(read_txt_to_list(file_name)) + +def read_txt_to_list_cached(file_name: str) -> list: + """ + Cached variant of :func:`read_txt_to_list` for files that don't change while + InfiniBot is running (the shipped defaults). Callers on hot paths should use this + rather than paying a disk read and parse per call. + + Edits to the file are picked up on the next restart. + + :param file_name: The name of the text file. + :type file_name: str + :return: A list of lines from the text file. + :rtype: list + """ + return list(_read_txt_to_list_cached(file_name, base_path)) + class JSONFile: """ Represents a JSON file. diff --git a/src/config/server.py b/src/config/server.py index 72a967d..02dfa3b 100644 --- a/src/config/server.py +++ b/src/config/server.py @@ -2,7 +2,7 @@ from nextcord import Guild as NextcordGuild -from config.file_manager import read_txt_to_list +from config.file_manager import read_txt_to_list_cached from config.messages.cached_messages import remove_cached_messages_from_guild from config.messages.stored_messages import remove_db_messages_from_guild from core.db_manager import get_database, Simple_TableManager, IntegratedList_TableManager, TableManager @@ -170,7 +170,7 @@ def _get_filtered_words_with_defaults(words_list): """Get filtered words with default values when list is empty.""" # If the list is empty, return default words if not words_list: - default_words = read_txt_to_list("default_profane_words.txt") + default_words = read_txt_to_list_cached("default_profane_words.txt") return default_words if default_words else [] return words_list @@ -178,7 +178,7 @@ def _set_filtered_words_with_optimization(words_list): """Set filtered words with storage optimization.""" # Storage optimization: if the input exactly matches default values, store [] if words_list: # Only optimize if list is not empty - default_words = read_txt_to_list("default_profane_words.txt") + default_words = read_txt_to_list_cached("default_profane_words.txt") if default_words and set(words_list) == set(default_words): # Store empty list when contents match defaults exactly return [] From 98bab3802dbbe9ac02e71fe825aaa718a21aa940 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 19 Jul 2026 16:01:39 -0600 Subject: [PATCH 101/109] perf(db): run orphaned-guild cleanup off the event loop (FR4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other blocking phase of daily_database_maintenance (checkpoint, optimize_database, stored_messages.cleanup_db) is offloaded via asyncio.to_thread; the orphaned-guild cleanup was still called directly on the loop, freezing the bot for its duration. It originally had to run inline because it shared a pooled connection with a SQLite TEMP table, but the D1 fix moved it onto pinned_connection, which takes its own connection and is safe in a worker thread. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** I don't 100% understand this, but the AI is very confident and it seems like something it would know. --- src/core/db_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/db_manager.py b/src/core/db_manager.py index 184524e..bb2c47e 100644 --- a/src/core/db_manager.py +++ b/src/core/db_manager.py @@ -1169,7 +1169,7 @@ async def daily_database_maintenance(bot: nextcord.Client): orphaned_guild_entries_cleanup_start = datetime.datetime.now(datetime.timezone.utc) valid_guild_ids = set([int(guild.id) for guild in bot.guilds]) - total_deleted += cleanup_orphaned_guild_entries(valid_guild_ids, get_database()) + total_deleted += await asyncio.to_thread(cleanup_orphaned_guild_entries, valid_guild_ids, get_database()) orphaned_guild_entries_cleanup_duration = datetime.datetime.now(datetime.timezone.utc) - orphaned_guild_entries_cleanup_start logging.info(f"Orphaned guild entries cleanup completed in {orphaned_guild_entries_cleanup_duration.total_seconds():.2f}s") From 337619ebbd28bef2e7948d0869a79708b6ae81bd Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 19 Jul 2026 16:01:59 -0600 Subject: [PATCH 102/109] perf(dashboard): use fetch_ban for the autoban already-banned check (FR5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autoban-add flow paginated the guild's entire ban list to decide whether the target was already banned, on the premise that nextcord can't check a ban by ID. It can: guild.fetch_ban is one REST call that raises NotFound when the user isn't banned, and it's already used in options_menu/ban_button.py. On a server with thousands of bans, adding one autoban cost seconds of paginated REST calls. Unexpected failures are now logged instead of silently swallowed. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** Yes, this change *does* work. I tested it in Discord. Much better. --- src/features/dashboard.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/features/dashboard.py b/src/features/dashboard.py index a1c5883..cbe5f66 100644 --- a/src/features/dashboard.py +++ b/src/features/dashboard.py @@ -4693,22 +4693,19 @@ async def callback(self, interaction: Interaction): # saving ) elif interaction.guild.me.guild_permissions.ban_members: - # Check if user is already banned by iterating through bans - # Unfortunately, nextcord doesn't provide a direct method to check if a user is banned through an ID - # We would need to have a nextcord.User mention, and we don't have one here... + # Single REST lookup for this one user; raises NotFound when they aren't banned. try: - async for ban_entry in interaction.guild.bans(): - if not ban_entry.user: continue - if ban_entry.user.id == user_id: - embed = nextcord.Embed( - title="User Already Banned", - description=(f"InfiniBot won't add \"{user_name} (ID: {user_id})\" as an " - f"autoban because they are already banned in this server."), - color=nextcord.Color.red() - ) - break - except Exception: + await interaction.guild.fetch_ban(nextcord.Object(id=user_id)) + embed = nextcord.Embed( + title="User Already Banned", + description=(f"InfiniBot won't add \"{user_name} (ID: {user_id})\" as an " + f"autoban because they are already banned in this server."), + color=nextcord.Color.red() + ) + except nextcord.NotFound: pass + except Exception: + logging.warning(f"Could not check ban status for user {user_id} in guild {interaction.guild.id}", exc_info=True) if embed is None: # Save data From 89d69b9b55005770a5a32bc478a198eb2f1a17f0 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 19 Jul 2026 16:02:21 -0600 Subject: [PATCH 103/109] fix(reaction-roles): keep emoji and role lists aligned in the Custom flow (FR6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_reaction_role built `roles` by filtering the parsed role IDs against interaction.guild.roles, silently dropping any role deleted since validation, while `emojis` was built independently from the raw option string with no filtering. A role deleted mid-list — reachable because the flow waits on a modal for up to five minutes after validation — desynced the two lists, so every entry after the gap got the wrong emoji. Both lists are now built in one pass, so a dropped role drops its emoji with it. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a legitimate issue that was properly resolved **Human Review Notes:** This bug would have been really rare, but it's good to fix anyway. --- src/features/reaction_roles.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/features/reaction_roles.py b/src/features/reaction_roles.py index d7caf6b..c98fb1b 100644 --- a/src/features/reaction_roles.py +++ b/src/features/reaction_roles.py @@ -261,9 +261,22 @@ async def create_reaction_role(interaction: Interaction, title: str, message: st roles: list[nextcord.Role] = [role for role_name in roles_str for role in interaction.guild.roles if role.name == role_name] emojis = None else: - new_roles_str = [extract_role_id(role.split("=", 1)[1]) for role in roles_str] - roles: list[nextcord.Role] = [role for role_ID in new_roles_str for role in interaction.guild.roles if role.id == role_ID] - emojis: list[str] = [emoji.split("=", 1)[0].strip() for emoji in roles_str] + # Roles and emojis are collected in lockstep: a role deleted between validation + # and now drops its emoji too, so the two lists stay index-aligned. + guild_roles_by_id = {role.id: role for role in interaction.guild.roles} + roles: list[nextcord.Role] = [] + emojis: list[str] = [] + for option in roles_str: + emoji_str, separator, role_str = option.partition("=") + if not separator: + continue + + role = guild_roles_by_id.get(extract_role_id(role_str)) + if role is None: + continue + + roles.append(role) + emojis.append(emoji_str.strip()) # Ensure that these roles are grantable for role in roles: From 2636e0a4b9e846063f99328ff292bcda64bcc8e5 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 19 Jul 2026 16:03:33 -0600 Subject: [PATCH 104/109] fix(ui): give SelectView a built-in empty-options state (FR7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SelectView raised ValueError when handed an empty options list, so every page that could produce one had to guard for it itself — DASH4 added handling at the two channel-select pages, and roughly five other call sites carry their own ad-hoc guards. Any new page that forgets is a generic error embed. SelectView now accepts an empty list and renders empty_state_message with a Back button routed through the existing cancel path (return_command still receives None), instead of raising. The select and its pagination are dropped in that state since Discord rejects a zero-option select. Type errors on options still raise. Existing call-site guards are left in place — their messages are more specific than any generic default — but they're now a nicety rather than the only thing standing between a page and a crash. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a nicety that this properly implements **Human Review Notes:** I'm not sure how much we needed this, but sure. It can't hurt. --- src/components/ui_components.py | 57 +++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/src/components/ui_components.py b/src/components/ui_components.py index 44d4d26..b1b914e 100644 --- a/src/components/ui_components.py +++ b/src/components/ui_components.py @@ -336,32 +336,41 @@ class SelectView(CustomView): :type cancel_button_label: str :param preserve_order: Preserves the order of options. Defaults to False, where the options will be alphabetized. :type preserve_order: bool + :param empty_state_message: Message shown instead of the select when there are no options. + Pages with a more specific explanation should pass their own. + :type empty_state_message: str :raises ValueError: If the arguments are incorrect. + An empty options list is not an error: `setup` renders `empty_state_message` with a + Back button that routes through the cancel path, so `return_command` still receives + `None`. + Setup ------ Call `await ~setup(nextcord.Interaction)` to begin setup. """ - def __init__(self, embed: nextcord.Embed, - options: list[nextcord.SelectOption], - return_command, - placeholder: str = None, - continue_button_label = "Continue", - cancel_button_label = "Cancel", - preserve_order = False): - + def __init__(self, embed: nextcord.Embed, + options: list[nextcord.SelectOption], + return_command, + placeholder: str = None, + continue_button_label = "Continue", + cancel_button_label = "Cancel", + preserve_order = False, + empty_state_message = "There's nothing to choose from here."): + super().__init__() self.page = 0 self.embed = embed self.options = options self.return_command = return_command - + self.empty_state_message = empty_state_message + # Confirm objects - if self.options == None or self.options == []: - raise ValueError(f"'options' must be a 'list' with one or more 'nextcord.SelectOption' items.") + if self.options is None: + self.options = [] if type(self.options) != list: - raise ValueError(f"'options' must be of type 'list'. Received type '{type(self.options)}'") + raise ValueError(f"'options' must be of type 'list'. Received type '{type(self.options)}'") for option in self.options: if type(option) != nextcord.SelectOption: raise ValueError(f"'options' must only contain 'nextcord.SelectOption' items. Contained 1+ '{type(option)}'") @@ -423,8 +432,30 @@ def __init__(self, embed: nextcord.Embed, self.add_item(self.continue_btn) async def setup(self, interaction): + if not self.options: + await self._show_empty_state(interaction) + return + await self.setPage(interaction, 0) - + + async def _show_empty_state(self, interaction: Interaction): + """ + Render the empty-state message in place of the select. Discord rejects a select + menu with zero options, so the select and its pagination are dropped entirely + and only the cancel button (relabeled "Back") remains. + """ + embed = copy.copy(self.embed) + embed.description = (embed.description or "") + f"\n\n{self.empty_state_message}" + embed.color = nextcord.Color.red() + + self.clear_items() + self.cancel_btn.label = "Back" + self.cancel_btn.row = 0 + self.add_item(self.cancel_btn) + + await interaction.response.edit_message(embed = embed, view = self) + + async def setPage(self, interaction: Interaction, page: int): if page >= len(self.select_options): raise IndexError("Page (int) was out of bounds of self.selectOptions (list[nextcord.SelectOption]).") From 5a638f5ac0cfafed3ca49aa64dd6eeafd8e5e1ea Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 19 Jul 2026 16:04:39 -0600 Subject: [PATCH 105/109] refactor(role-messages): share one select view between the two button types (FR8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RoleMessageButton_Single.View and RoleMessageButton_Multiple.View were near-clones, so the R2 fix (field index as the option value plus an option_roles map) had to be pasted into both — and any future change to that encoding would too, with nothing catching a one-sided edit. Both now subclass RoleMessageSelectView, which carries the shared option building and callback; the subclasses only declare their select custom_id, placeholder, and whether multiple options may be chosen. Two incidental alignments from the merge: the single-choice variant picks up the multi-choice variant's NotFound guard around edit_message, and the multi-choice max_values no longer goes to 0 when a role message has no fields. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a good refactor that was properly implemented. **Human Review Notes:** Looks right. --- src/features/role_messages.py | 292 +++++++++++++--------------------- 1 file changed, 115 insertions(+), 177 deletions(-) diff --git a/src/features/role_messages.py b/src/features/role_messages.py index 9111fde..72f1068 100644 --- a/src/features/role_messages.py +++ b/src/features/role_messages.py @@ -6,98 +6,121 @@ from components.ui_components import CustomModal, CustomView from config.server import Server +ROLE_MENTION_PATTERN = re.compile(r"<@&(\d+)>") + +class RoleMessageSelectView(CustomView): + """ + The role picker behind a role message's button, shared by the single-choice and + multiple-choice variants. + + Each embed field is one option: its last line holds the role mentions the option + grants, and the lines above it are the option's description. + + Subclasses set :attr:`select_custom_id`, :attr:`select_placeholder` and + :attr:`allow_multiple`. + """ + select_custom_id: str + select_placeholder: str + allow_multiple: bool + + def __init__(self, user: nextcord.Member, message: nextcord.Message): + super().__init__() + self.message = message + self.available_roles = [] + + user_role_ids = [role.id for role in user.roles if role.name != "@everyone"] + + options = [] + # Option values are field indices; role IDs would overflow Discord's + # 100-char value cap once an option grants 6+ roles. + self.option_roles: dict[str, list[int]] = {} + has_default_option = False + for field_index, field in enumerate(self.message.embeds[0].fields): + name = field.name + description = "\n".join(field.value.split("\n")[:-1]) + roles = self.extract_ids(field.value.split("\n")[-1]) + self.add_available_roles(roles) + + # Preselect the option only if the user already has every role it grants + selected = bool(roles) and all(int(role) in user_role_ids for role in roles) + if selected and not self.allow_multiple: + # A single-choice select accepts only one default option. + selected = not has_default_option + has_default_option = True + + # Truncate name and description if too long + if len(name) > 100: + name = name[:97] + "..." + + if len(description) > 100: + description = description[:97] + "..." + + value = str(field_index) + self.option_roles[value] = [int(role) for role in roles] + options.append(nextcord.SelectOption(label=name, description=description, value=value, default=selected)) + + self.select = nextcord.ui.Select( + custom_id=self.select_custom_id, + placeholder=self.select_placeholder, + min_values=0, + max_values=(max(len(options), 1) if self.allow_multiple else 1), + options=options + ) + self.select.callback = self.select_callback + self.add_item(self.select) + + def extract_ids(self, input_string): + return ROLE_MENTION_PATTERN.findall(input_string) + + def add_available_roles(self, roles_list): + for role in roles_list: + if int(role) not in self.available_roles: + self.available_roles.append(int(role)) + + async def setup(self, interaction: Interaction): + await interaction.response.send_message(view = self, ephemeral = True) + + async def select_callback(self, interaction: Interaction): + selected_roles = [] + for option in self.select.values: + selected_roles.extend(self.option_roles.get(option, [])) + + # Get the roles + roles_add = [] + roles_remove = [] + for role in self.available_roles: + role_obj = interaction.guild.get_role(int(role)) + if role_obj is None: + continue # Role was deleted from the guild; skip so add/remove_roles doesn't get None + if role in selected_roles: + roles_add.append(role_obj) + else: + roles_remove.append(role_obj) + + error = False + try: + await interaction.user.add_roles(*roles_add) + await interaction.user.remove_roles(*roles_remove) + except nextcord.errors.Forbidden: + error = True + + embed = nextcord.Embed(title="Modified Roles", color=nextcord.Color.green()) + if error: embed.description = "Warning: An error occurred with one or more roles. Please notify server admins." + + try: + await interaction.response.edit_message(embed=embed, view=None, delete_after=2.0) + except nextcord.errors.NotFound: + # Message was deleted, so we can't edit it + pass + class RoleMessageButton_Single(CustomView): def __init__(self): super().__init__(timeout = None) - - class View(CustomView): - def __init__(self, user: nextcord.Member, message: nextcord.Message): - super().__init__() - self.message = message - self.available_roles = [] - - user_role_ids = [role.id for role in user.roles if role.name != "@everyone"] - - options_selected = False - options = [] - # Option values are field indices; role IDs would overflow Discord's - # 100-char value cap once an option grants 6+ roles. - self.option_roles: dict[str, list[int]] = {} - for field_index, field in enumerate(self.message.embeds[0].fields): - name = field.name - description = "\n".join(field.value.split("\n")[:-1]) - roles = self.extract_ids(field.value.split("\n")[-1]) - self.add_available_roles(roles) - - # Check if the user has the roles - selected = False - for index, role in enumerate(roles): - if not int(role) in user_role_ids: - break - else: - if index == (len(roles) - 1): # If this is the last role to check - if not options_selected: - selected = True - options_selected = True - break - - # Truncate name and description if too long - if len(name) > 100: - name = name[:97] + "..." - - if len(description) > 100: - description = description[:97] + "..." - - value = str(field_index) - self.option_roles[value] = [int(role) for role in roles] - options.append(nextcord.SelectOption(label=name, description=description, value=value, default=selected)) - - self.select = nextcord.ui.Select(custom_id="role_message_single_select", placeholder="Choose a Role", min_values=0, options=options) - self.select.callback = self.select_callback - self.add_item(self.select) - - def extract_ids(self, input_string): - pattern = r"<@&(\d+)>" - matches = re.findall(pattern, input_string) - return matches - - def add_available_roles(self, roles_list): - for role in roles_list: - if int(role) not in self.available_roles: - self.available_roles.append(int(role)) - - async def setup(self, interaction: Interaction): - await interaction.response.send_message(view = self, ephemeral = True) - - async def select_callback(self, interaction: Interaction): - selection = self.select.values - selected_roles = [] - for option in selection: - selected_roles.extend(self.option_roles.get(option, [])) - - # Get the roles - roles_add = [] - roles_remove = [] - for role in self.available_roles: - role_obj = interaction.guild.get_role(int(role)) - if role_obj is None: - continue # Role was deleted from the guild; skip so add/remove_roles doesn't get None - if role in selected_roles: - roles_add.append(role_obj) - else: - roles_remove.append(role_obj) - - error = False - try: - await interaction.user.add_roles(*roles_add) - await interaction.user.remove_roles(*roles_remove) - except nextcord.errors.Forbidden: - error = True - - embed = nextcord.Embed(title = "Modified Roles", color = nextcord.Color.green()) - if error: embed.description = "Warning: An error occurred with one or more roles. Please notify server admins." - await interaction.response.edit_message(embed=embed, view=None, delete_after=2.0) + class View(RoleMessageSelectView): + select_custom_id = "role_message_single_select" + select_placeholder = "Choose a Role" + allow_multiple = False @nextcord.ui.button(label = "Get Role", style = nextcord.ButtonStyle.blurple, custom_id = "get_role") async def event(self, button: nextcord.ui.Button, interaction: nextcord.Interaction): @@ -107,96 +130,11 @@ async def event(self, button: nextcord.ui.Button, interaction: nextcord.Interact class RoleMessageButton_Multiple(CustomView): def __init__(self): super().__init__(timeout = None) - - class View(CustomView): - def __init__(self, user: nextcord.Member, message: nextcord.Message): - super().__init__() - self.message = message - self.available_roles = [] - - user_role_ids = [role.id for role in user.roles if role.name != "@everyone"] - - options = [] - # Option values are field indices; role IDs would overflow Discord's - # 100-char value cap once an option grants 6+ roles. - self.option_roles: dict[str, list[int]] = {} - for field_index, field in enumerate(self.message.embeds[0].fields): - name = field.name - description = "\n".join(field.value.split("\n")[:-1]) - roles = self.extract_ids(field.value.split("\n")[-1]) - self.add_available_roles(roles) - - # Check if the user has the roles - selected = False - for index, role in enumerate(roles): - if not int(role) in user_role_ids: - break - else: - if index == (len(roles) - 1): # If this is the last role to check - selected = True - break - - # Truncate name and description if too long - if len(name) > 100: - name = name[:97] + "..." - - if len(description) > 100: - description = description[:97] + "..." - value = str(field_index) - self.option_roles[value] = [int(role) for role in roles] - options.append(nextcord.SelectOption(label=name, description=description, value=value, default=selected)) - - self.select = nextcord.ui.Select(custom_id="role_message_multi_select", placeholder="Choose Roles", min_values=0, max_values=len(options), options=options) - self.select.callback = self.select_callback - self.add_item(self.select) - - def extract_ids(self, input_string): - pattern = r"<@&(\d+)>" - matches = re.findall(pattern, input_string) - return matches - - def add_available_roles(self, rolesList): - for role in rolesList: - if int(role) not in self.available_roles: - self.available_roles.append(int(role)) - - async def setup(self, interaction: Interaction): - await interaction.response.send_message(view = self, ephemeral = True) - - async def select_callback(self, interaction: Interaction): - selection = self.select.values - selected_roles = [] - for option in selection: - selected_roles.extend(self.option_roles.get(option, [])) - - # Get the roles - roles_add = [] - roles_remove = [] - for role in self.available_roles: - role_obj = interaction.guild.get_role(int(role)) - if role_obj is None: - continue # Role was deleted from the guild; skip so add/remove_roles doesn't get None - if role in selected_roles: - roles_add.append(role_obj) - else: - roles_remove.append(role_obj) - - error = False - try: - await interaction.user.add_roles(*roles_add) - await interaction.user.remove_roles(*roles_remove) - except nextcord.errors.Forbidden: - error = True - - embed = nextcord.Embed(title="Modified Roles", color=nextcord.Color.green()) - if error: embed.description = "Warning: An error occurred with one or more roles. Please notify server admins." - - try: - await interaction.response.edit_message(embed=embed, view=None, delete_after=2.0) - except nextcord.errors.NotFound: - # Message was deleted, so we can't edit it - pass + class View(RoleMessageSelectView): + select_custom_id = "role_message_multi_select" + select_placeholder = "Choose Roles" + allow_multiple = True @nextcord.ui.button(label = "Get Roles", style = nextcord.ButtonStyle.blurple, custom_id = "get_roles") async def event(self, button: nextcord.ui.Button, interaction: nextcord.Interaction): From 3c74e3ac5f0bb9c09e4eb6e422a611cfdff9e79b Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 19 Jul 2026 16:06:14 -0600 Subject: [PATCH 106/109] refactor: one shared role-mention parser (FR9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit <@&id> parsing was reimplemented five times — reaction_roles.extract_role_id (added by R3), the raw-reaction get_role helper, both role-message option parsers, and the reaction-role editor — so a fix in one, like R3's spacing-independent parsing, had to be rediscovered in the others. Adds utils.extract_role_id / utils.extract_role_ids and routes all five sites through them. The editor's variant accepted a bare ID with no mention wrapper, so extract_role_id keeps that behind an allow_bare_id flag. Two sites get incidental hardening from the shared parser: the raw-reaction get_role no longer raises ValueError on a malformed mention like "<@&abc>", and it now tolerates surrounding whitespace as R3 intended. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a good refactor that was properly implemented **Human Review Notes:** This all looks correct and it's a good fix for consistency. --- src/components/utils.py | 35 +++++++++++++++++++ .../options_menu/editing/reaction_roles.py | 8 ++--- .../options_menu/editing/role_messages.py | 8 +---- src/features/reaction_roles.py | 31 ++++------------ src/features/role_messages.py | 8 +---- 5 files changed, 46 insertions(+), 44 deletions(-) diff --git a/src/components/utils.py b/src/components/utils.py index 3e440c3..4c97b11 100644 --- a/src/components/utils.py +++ b/src/components/utils.py @@ -2,6 +2,7 @@ import datetime import logging import datetime +import re from typing import Any import nextcord @@ -12,6 +13,40 @@ COLOR_OPTIONS = ["Red", "Green", "Blue", "Yellow", "White", "Blurple", "Greyple", "Teal", "Purple", "Gold", "Magenta", "Fuchsia"] +ROLE_MENTION_PATTERN = re.compile(r"<@&(\d+)>") + +def extract_role_id(text: str, allow_bare_id: bool = False) -> int | None: + """ + Pull the first role ID out of a role mention, tolerating any surrounding text + or whitespace. + + :param text: Text containing a role mention, e.g. " <@&123>". + :type text: str + :param allow_bare_id: Also accept text that is just the ID digits, e.g. "123". + :type allow_bare_id: bool + :return: The role ID, or None if no role mention is present. + :rtype: int | None + """ + match = ROLE_MENTION_PATTERN.search(text) + if match: + return int(match.group(1)) + + if allow_bare_id and text.strip().isdigit(): + return int(text.strip()) + + return None + +def extract_role_ids(text: str) -> list[int]: + """ + Pull every role ID out of the role mentions in a string, in order. + + :param text: Text containing zero or more role mentions. + :type text: str + :return: The role IDs. + :rtype: list[int] + """ + return [int(role_id) for role_id in ROLE_MENTION_PATTERN.findall(text)] + def asci_to_emoji(letter, fallback_letter = "1"): letter = str(letter) letter = letter.lower() diff --git a/src/features/options_menu/editing/reaction_roles.py b/src/features/options_menu/editing/reaction_roles.py index 575711b..fb90d4b 100644 --- a/src/features/options_menu/editing/reaction_roles.py +++ b/src/features/options_menu/editing/reaction_roles.py @@ -1,7 +1,6 @@ import logging from nextcord import Interaction import nextcord -import re import json from config.server import Server @@ -195,10 +194,9 @@ def get_role(self, guild: nextcord.Guild, string: str): if "⚠️" in string: return None - # Original regex pattern with improved handling - match = re.search(r"^(<@&)?(\d+)>?$", string) - if match: - return nextcord.utils.get(guild.roles, id=int(match.group(2))) + role_id = utils.extract_role_id(string, allow_bare_id=True) + if role_id is not None: + return nextcord.utils.get(guild.roles, id=role_id) return nextcord.utils.get(guild.roles, name=string) if string else None def format_options(self, guild: nextcord.Guild, lines: list[str], packet_to_modify=None, display_errors=True): diff --git a/src/features/options_menu/editing/role_messages.py b/src/features/options_menu/editing/role_messages.py index c54766b..84ca568 100644 --- a/src/features/options_menu/editing/role_messages.py +++ b/src/features/options_menu/editing/role_messages.py @@ -1,6 +1,5 @@ from nextcord import Interaction import nextcord -import re from components import utils, ui_components from components.ui_components import CustomModal, CustomView @@ -575,7 +574,7 @@ async def load(self, interaction: Interaction) -> bool: for field in self.message.embeds[0].fields: name = field.name description = "\n".join(field.value.split("\n")[:-1]) - roles = self.extract_ids(field.value.split("\n")[-1]) + roles = utils.extract_role_ids(field.value.split("\n")[-1]) self.options.append([roles, name, description]) # Get Mode @@ -714,11 +713,6 @@ def add_field(self, embed: nextcord.Embed, option_info): embed.add_field(name=title, value=value, inline=False) return True - def extract_ids(self, input_string): - pattern = r"<@&(\d+)>" - matches = re.findall(pattern, input_string) - return matches - async def disable_view(self, interaction: Interaction): for child in self.children: if isinstance(child, nextcord.ui.Button): diff --git a/src/features/reaction_roles.py b/src/features/reaction_roles.py index c98fb1b..d8c37e2 100644 --- a/src/features/reaction_roles.py +++ b/src/features/reaction_roles.py @@ -1,4 +1,3 @@ -import re import nextcord from nextcord import Interaction import logging @@ -7,20 +6,6 @@ from config.server import Server from components import ui_components, utils -ROLE_MENTION_PATTERN = re.compile(r"<@&(\d+)>") - -def extract_role_id(text: str) -> int | None: - """ - Pull a role ID out of a role mention, tolerating any surrounding whitespace. - - :param text: Text containing a role mention, e.g. " <@&123>". - :type text: str - :return: The role ID, or None if no role mention is present. - :rtype: int | None - """ - match = ROLE_MENTION_PATTERN.search(text) - return int(match.group(1)) if match else None - class ReactionRoleModal(ui_components.CustomModal): def __init__(self): super().__init__(title = "Create a Reaction Role") @@ -170,7 +155,7 @@ async def run_custom_reaction_role_command(interaction: Interaction, options: st description="Every emoji has to be unique.", color=nextcord.Color.red()), ephemeral=True) return - role_ID = extract_role_id(option.split("=", 1)[1]) + role_ID = utils.extract_role_id(option.split("=", 1)[1]) if role_ID is None: raise Exception # Trigger the error message at end of try block role = [role for role in interaction.guild.roles if role.id == role_ID][0] @@ -271,7 +256,7 @@ async def create_reaction_role(interaction: Interaction, title: str, message: st if not separator: continue - role = guild_roles_by_id.get(extract_role_id(role_str)) + role = guild_roles_by_id.get(utils.extract_role_id(role_str)) if role is None: continue @@ -406,14 +391,10 @@ async def run_raw_reaction_add(payload: nextcord.RawReactionActionEvent, bot: ne # Declare some functions def get_role(string: str): - pattern = r"^(<@&)(.*)>$" # Regular expression pattern with a capturing group - match = re.search(pattern, string) - if match: - role_id = int(match.group(2)) # Role ID - role = nextcord.utils.get(guild.roles, id=role_id) - else: - role = nextcord.utils.get(guild.roles, name=string) - return role + role_id = utils.extract_role_id(string) + if role_id is not None: + return nextcord.utils.get(guild.roles, id=role_id) + return nextcord.utils.get(guild.roles, name=string) async def send_no_role_error(): await message.channel.send(embed = nextcord.Embed(title="Role Not Found", description=f"Infinibot cannot find one or more of those roles. Check to make sure all roles still exist.", color=nextcord.Color.red()), reference=message) diff --git a/src/features/role_messages.py b/src/features/role_messages.py index 72f1068..791f93d 100644 --- a/src/features/role_messages.py +++ b/src/features/role_messages.py @@ -1,13 +1,10 @@ from nextcord import Interaction import nextcord -import re from components import utils, ui_components from components.ui_components import CustomModal, CustomView from config.server import Server -ROLE_MENTION_PATTERN = re.compile(r"<@&(\d+)>") - class RoleMessageSelectView(CustomView): """ The role picker behind a role message's button, shared by the single-choice and @@ -38,7 +35,7 @@ def __init__(self, user: nextcord.Member, message: nextcord.Message): for field_index, field in enumerate(self.message.embeds[0].fields): name = field.name description = "\n".join(field.value.split("\n")[:-1]) - roles = self.extract_ids(field.value.split("\n")[-1]) + roles = utils.extract_role_ids(field.value.split("\n")[-1]) self.add_available_roles(roles) # Preselect the option only if the user already has every role it grants @@ -69,9 +66,6 @@ def __init__(self, user: nextcord.Member, message: nextcord.Message): self.select.callback = self.select_callback self.add_item(self.select) - def extract_ids(self, input_string): - return ROLE_MENTION_PATTERN.findall(input_string) - def add_available_roles(self, roles_list): for role in roles_list: if int(role) not in self.available_roles: From 50a11a7450bd0db735480b466889414a9ca966fe Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 19 Jul 2026 16:06:43 -0600 Subject: [PATCH 107/109] refactor(logging): derive the embed-count limits from the constant (FR10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deleted-message log still used bare literals (> 9, "8/", [0:8], >= 10) for the embed-count limit right beside the MAX_EMBEDS_PER_MESSAGE constant A1 introduced, so a change to the limit would leave them silently out of sync. MAX_ATTACHED_EMBEDS and TRUNCATED_ATTACHED_EMBEDS now express the two derived counts — how many of the message's embeds fit alongside the log embed, and how many are attached once truncation leaves room for "Show More" — and both call sites use them. Values are unchanged. Co-Authored-By: Claude Opus 4.8 ✓ Verified by cypress-exe as a good refactor that was properly implemented **Human Review Notes:** I'm not crazy about the MAX_ATTACHED_EMBEDS and TRUNCATED_ATTACHED_EMBEDS constants at the toplevel, but I think they're better than inlining it. --- src/features/action_logging.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/features/action_logging.py b/src/features/action_logging.py index 6d4cd5c..ab199fa 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -18,6 +18,13 @@ MAX_EMBEDS_PER_MESSAGE = 10 """Discord's hard limit on embeds in a single message.""" +# By extension: +MAX_ATTACHED_EMBEDS = MAX_EMBEDS_PER_MESSAGE - 1 +"""How many of a deleted message's embeds fit alongside the log embed itself.""" + +TRUNCATED_ATTACHED_EMBEDS = MAX_EMBEDS_PER_MESSAGE - 2 +"""How many are attached once truncation kicks in, leaving a slot for "Show More".""" + class ShowMoreButton(ui_components.CustomView): """ A View that will be used for the Action Logging feature. @@ -464,9 +471,8 @@ async def trigger_delete_log(bot: nextcord.Client, channel: nextcord.TextChannel # Attached Embeds if message and message.embeds != []: attachedMessage = "Attached below" - if len(message.embeds) > 9: - # The send below attaches embeds[0:8] alongside the log embed (9 total) - attachedMessage = f"8/{len(message.embeds)} are attached below" + if len(message.embeds) > MAX_ATTACHED_EMBEDS: + attachedMessage = f"{TRUNCATED_ATTACHED_EMBEDS}/{len(message.embeds)} are attached below" embed.add_field(name = "Embeds", value = f"This message contained one or more embeds. ({attachedMessage})", inline = False) embeds = message.embeds @@ -486,7 +492,7 @@ async def trigger_delete_log(bot: nextcord.Client, channel: nextcord.TextChannel embed.set_footer(text = f"Message ID: {message_id}\nCode: {code}") # Actually send the embed - all_embeds = [embed] + (embeds[0:8] if len(embeds) >= 10 else embeds) + all_embeds = [embed] + (embeds[0:TRUNCATED_ATTACHED_EMBEDS] if len(embeds) > MAX_ATTACHED_EMBEDS else embeds) # "Show More" appends an 11th embed, which Discord rejects. Only offer it when # the log message leaves room. From 6b7ab3805645a1497607acf80aaa52e4649a1635 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 19 Jul 2026 21:52:05 -0600 Subject: [PATCH 108/109] fix(channels): resolve channels via utils.get_channel instead of guild.get_channel guild.get_channel only checks the local cache, so channels that fell out of cache (or were never cached) were silently treated as missing across logging, moderation, leveling, birthdays, join/leave messages, join-to-create VCs, and admin broadcast. This led to real bugs and possible data loss on prod. utils.get_channel falls back to an API fetch (with failed-fetch caching) so these lookups behave correctly again. Also fixes a copy/paste typo in the Leave Messages embed title in the dashboard. --- src/features/action_logging.py | 2 +- src/features/admin_commands.py | 2 +- src/features/birthdays.py | 2 +- src/features/dashboard.py | 63 +++++++++++++++--------------- src/features/join_to_create_vcs.py | 2 +- src/features/leveling.py | 2 +- src/features/moderation.py | 10 ++--- 7 files changed, 41 insertions(+), 42 deletions(-) diff --git a/src/features/action_logging.py b/src/features/action_logging.py index ab199fa..1549104 100644 --- a/src/features/action_logging.py +++ b/src/features/action_logging.py @@ -113,7 +113,7 @@ async def get_logging_channel(guild: nextcord.Guild) -> nextcord.TextChannel: if log_channel_id == UNSET_VALUE: return None - log_channel = guild.get_channel(log_channel_id) + log_channel = await utils.get_channel(log_channel_id) if not log_channel: return None diff --git a/src/features/admin_commands.py b/src/features/admin_commands.py index 9814356..f819223 100644 --- a/src/features/admin_commands.py +++ b/src/features/admin_commands.py @@ -890,7 +890,7 @@ async def _handle_modal_submission(self, interaction, **kwargs): if guild.id == support_server_id: # Special handling for InfiniBot support server logging.info("Running special behavior for the InfiniBot Support Server.") - channel = guild.get_channel(support_server_config['updates-channel-id']) + channel = await utils.get_channel(support_server_config['updates-channel-id']) role = guild.get_role(support_server_config['infinibot-updates-role-id']) role_mention = role.mention if role else "" else: diff --git a/src/features/birthdays.py b/src/features/birthdays.py index 1c3708a..6c61df7 100644 --- a/src/features/birthdays.py +++ b/src/features/birthdays.py @@ -179,7 +179,7 @@ async def check_and_run_birthday_actions( if channel_id == UNSET_VALUE: channel = guild.system_channel elif channel_id is not None: - channel = guild.get_channel(channel_id) + channel = await utils.get_channel(channel_id) if channel is None: # Configured channel was deleted or is invisible — warn instead of silently dropping await utils.send_error_message_to_server_owner( diff --git a/src/features/dashboard.py b/src/features/dashboard.py index cbe5f66..c4beb5c 100644 --- a/src/features/dashboard.py +++ b/src/features/dashboard.py @@ -211,7 +211,7 @@ async def setup(self, interaction: Interaction): if server.profanity_moderation_profile.channel == None: admin_channel_ui_text = "None" elif server.profanity_moderation_profile.channel == UNSET_VALUE: admin_channel_ui_text = "UNSET" else: - admin_channel = interaction.guild.get_channel(server.profanity_moderation_profile.channel) + admin_channel = await utils.get_channel(server.profanity_moderation_profile.channel) if admin_channel: admin_channel_ui_text = admin_channel.mention else: admin_channel_ui_text = "#unknown" @@ -669,7 +669,7 @@ async def select_view_callback(self, interaction: Interaction, selection): color = nextcord.Color.green() ) embed.set_footer(text = f"Action done by {interaction.user}") - discord_channel = interaction.guild.get_channel(server.profanity_moderation_profile.channel) + discord_channel = await utils.get_channel(server.profanity_moderation_profile.channel) await discord_channel.send(embed = embed, view = ui_components.SupportAndInviteView()) class ManageStrikeSystemButton(nextcord.ui.Button): @@ -1855,7 +1855,8 @@ async def select_view_callback(self, interaction: Interaction, selection): embed = nextcord.Embed(title = "Log Channel Set", description = f"This channel will now be used for logging.\n\n**Notification Settings**\nSet notification settings for this channel to \"Nothing\". InfiniBot will constantly be sending log messages in this channel.", color = nextcord.Color.green()) embed.set_footer(text = f"Action done by {interaction.user}") - await interaction.guild.get_channel(server.logging_profile.channel).send(embed = embed, view = ui_components.SupportAndInviteView()) + channel = await utils.get_channel(server.logging_profile.channel) + await channel.send(embed = embed, view = ui_components.SupportAndInviteView()) class EnableDisableButton(nextcord.ui.Button): def __init__(self, outer): @@ -1977,7 +1978,7 @@ async def setup(self, interaction: Interaction): if server.leveling_profile.channel == None: leveling_channel_ui_text = "No Notifications (disabled)" elif server.leveling_profile.channel == UNSET_VALUE: leveling_channel_ui_text = "System Messages Channel" else: - leveling_channel = interaction.guild.get_channel(server.leveling_profile.channel) + leveling_channel = await utils.get_channel(server.leveling_profile.channel) if leveling_channel: leveling_channel_ui_text = leveling_channel.mention else: leveling_channel_ui_text = "#unknown" @@ -2673,7 +2674,7 @@ async def setup(self, interaction: Interaction): leveling_exempt_channels:list[nextcord.abc.GuildChannel] = [] for channel_id in server.leveling_profile.exempt_channels: - channel = interaction.guild.get_channel(channel_id) + channel = await utils.get_channel(channel_id) if channel == None: logging.warning(f"Leveling exempt channel ({channel_id}) not found in server {interaction.guild.id}. Deleting...") leveling_exempt_channel_ids = server.leveling_profile.exempt_channels @@ -2753,7 +2754,7 @@ async def callback(self, interaction: Interaction): options = [] server = Server(interaction.guild.id) for channel_id in server.leveling_profile.exempt_channels: - channel = interaction.guild.get_channel(channel_id) + channel = await utils.get_channel(channel_id) if channel == None: # Should never happen due to previous checks. logging.warning(f"Leveling exempt channel ({channel_id}) not found in server {interaction.guild.id}. Ignoring...") continue @@ -2944,7 +2945,7 @@ async def setup(self, interaction: Interaction): if server.join_message_profile.channel == UNSET_VALUE: channel_ui_text = "System Messages Channel" else: - channel = interaction.guild.get_channel(server.join_message_profile.channel) + channel = await utils.get_channel(server.join_message_profile.channel) if channel: channel_ui_text = channel.mention else: channel_ui_text = "#unknown" @@ -3204,7 +3205,7 @@ async def setup(self, interaction: Interaction): if server.leave_message_profile.channel == UNSET_VALUE: channel_ui_text = "System Messages Channel" else: - channel = interaction.guild.get_channel(server.leave_message_profile.channel) + channel = await utils.get_channel(server.leave_message_profile.channel) if channel: channel_ui_text = channel.mention else: channel_ui_text = "#unknown" @@ -3223,7 +3224,7 @@ async def setup(self, interaction: Interaction): """ description = utils.standardize_str_indention(description) - embed = nextcord.Embed(title = "Dashboard - Join / Leave Messages - Join Messages", description = description, color = nextcord.Color.blue()) + embed = nextcord.Embed(title = "Dashboard - Join / Leave Messages - Leave Messages", description = description, color = nextcord.Color.blue()) await interaction.response.edit_message(embed = embed, view = self) class LeaveMessagesButton(nextcord.ui.Button): @@ -3449,7 +3450,7 @@ async def setup(self, interaction: Interaction): if server.birthdays_profile.channel == UNSET_VALUE: birthday_channel_ui_text = "System Messages Channel" else: - birthday_channel = interaction.guild.get_channel(server.birthdays_profile.channel) + birthday_channel = await utils.get_channel(server.birthdays_profile.channel) birthday_channel_ui_text = birthday_channel.mention if birthday_channel else "#unknown" # Convert stored UTC time to server's timezone for display @@ -4284,15 +4285,24 @@ def __init__(self, outer, guild: nextcord.Guild, onboarding_modifier = None, onb self.onboarding_modifier = onboarding_modifier self.onboarding_embed = onboarding_embed - server = Server(self.guild.id) - + self.back_btn = nextcord.ui.Button(label = "Back", style = nextcord.ButtonStyle.danger, row = 1) + self.back_btn.callback = self.back_btn_callback + self.add_item(self.back_btn) + + async def setup(self, interaction: Interaction): + if self.onboarding_modifier: self.onboarding_modifier(self) + + if not utils.feature_is_active(server_id = interaction.guild.id, feature = "join_to_create_vcs"): # server_id won't be used here, but it's required as an input + await ui_components.disabled_feature_override(self, interaction) + return + # Find all join-to-create VCs (log which ones have errors) # This needs to be done here because the buttons need this info to determine # whether or not to be disabled self.join_to_create_vcs_with_error_info = [] - for vc_id in server.join_to_create_vcs.channels: - voice_channel = guild.get_channel(vc_id) - + for vc_id in Server(self.guild.id).join_to_create_vcs.channels: + voice_channel = await utils.get_channel(vc_id) + error = False if not voice_channel: error = True; logging.warning(f"Join-to-create VC {vc_id} in guild {self.guild.id} was not found. Ignoring, but marking it as an error...") elif not voice_channel.permissions_for(self.guild.me).view_channel: error = True; logging.warning(f"Join-to-create VC {vc_id} in guild {self.guild.id} does not have view_channel permission for InfiniBot. Ignoring, but marking it as an error...") @@ -4300,23 +4310,12 @@ def __init__(self, outer, guild: nextcord.Guild, onboarding_modifier = None, onb self.join_to_create_vcs_with_error_info.append([voice_channel, error]) - self.add_btn = self.AddButton(self, guild, self.join_to_create_vcs_with_error_info) + self.add_btn = self.AddButton(self, self.guild, self.join_to_create_vcs_with_error_info) self.add_item(self.add_btn) - - self.delete_btn = self.DeleteButton(self, guild) + + self.delete_btn = self.DeleteButton(self, self.guild) self.add_item(self.delete_btn) - - self.back_btn = nextcord.ui.Button(label = "Back", style = nextcord.ButtonStyle.danger, row = 1) - self.back_btn.callback = self.back_btn_callback - self.add_item(self.back_btn) - - async def setup(self, interaction: Interaction): - if self.onboarding_modifier: self.onboarding_modifier(self) - - if not utils.feature_is_active(server_id = interaction.guild.id, feature = "join_to_create_vcs"): # server_id won't be used here, but it's required as an input - await ui_components.disabled_feature_override(self, interaction) - return - + # Get server data server = Server(interaction.guild.id) join_to_create_voice_channels:list = server.join_to_create_vcs.channels @@ -4328,7 +4327,7 @@ async def setup(self, interaction: Interaction): if len(join_to_create_voice_channels) > 0: vcs_ui_text_list = [] for vc_id in join_to_create_voice_channels: - voice_channel = interaction.guild.get_channel(vc_id) + voice_channel = await utils.get_channel(vc_id) error = False if not voice_channel: @@ -4468,7 +4467,7 @@ async def callback(self, interaction: Interaction): server = Server(interaction.guild.id) select_options = [] for vc_id in server.join_to_create_vcs.channels: - voice_channel = interaction.guild.get_channel(vc_id) + voice_channel = await utils.get_channel(vc_id) if voice_channel: label = voice_channel.name description = (voice_channel.category.name if voice_channel.category else None) diff --git a/src/features/join_to_create_vcs.py b/src/features/join_to_create_vcs.py index 42212f5..4876adb 100644 --- a/src/features/join_to_create_vcs.py +++ b/src/features/join_to_create_vcs.py @@ -41,7 +41,7 @@ async def run_join_to_create_vc_member_update(member: nextcord.Member, before: n # Check if the channel is a join-to-create channel if after.channel.id in voice_channels: # Ensure bot has necessary permissions - voice_channel = member.guild.get_channel(after.channel.id) + voice_channel = await utils.get_channel(after.channel.id) if not voice_channel: return if not voice_channel.permissions_for(member.guild.me).view_channel: diff --git a/src/features/leveling.py b/src/features/leveling.py index 3e4d5eb..a29cf19 100644 --- a/src/features/leveling.py +++ b/src/features/leveling.py @@ -416,7 +416,7 @@ async def process_level_change(guild: nextcord.Guild, member: nextcord.Member, l leveling_channel_id = server.leveling_profile.channel if leveling_channel_id == None: _continue = False # Level messages are disabled elif leveling_channel_id == UNSET_VALUE: leveling_channel = guild.system_channel # Use the system messages channel - else: leveling_channel = guild.get_channel(leveling_channel_id) # Use the leveling channel + else: leveling_channel = await utils.get_channel(leveling_channel_id) # Use the leveling channel if leveling_channel == None: _continue = False # Either the system messages channel is not set, or the leveling channel does not exist if not await utils.check_text_channel_permissions(leveling_channel, auto_warn=True, custom_channel_name="the leveling channel"): _continue = False diff --git a/src/features/moderation.py b/src/features/moderation.py index 8b406b7..1f54cf1 100644 --- a/src/features/moderation.py +++ b/src/features/moderation.py @@ -231,7 +231,7 @@ async def check_and_punish_nickname_for_profanity(bot: nextcord.Client, guild: n # Send a message to the admin channel admin_channel_id = server.profanity_moderation_profile.channel if admin_channel_id == UNSET_VALUE: return - admin_channel = guild.get_channel(admin_channel_id) + admin_channel = await utils.get_channel(admin_channel_id) if admin_channel == None: return if not await utils.check_text_channel_permissions(admin_channel, True, custom_channel_name = f"Admin Channel (#{admin_channel.name})"): return @@ -253,7 +253,7 @@ async def check_and_punish_nickname_for_profanity(bot: nextcord.Client, guild: n # Send a message to the admin channel admin_channel_id = server.profanity_moderation_profile.channel if admin_channel_id == UNSET_VALUE: return - admin_channel = guild.get_channel(admin_channel_id) + admin_channel = await utils.get_channel(admin_channel_id) if admin_channel == None: return if not await utils.check_text_channel_permissions(admin_channel, True, custom_channel_name = f"Admin Channel (#{admin_channel.name})"): return @@ -467,7 +467,7 @@ async def grant_and_punish_strike(bot: nextcord.Client, guild_id: int, member: n message = f"Failed to timeout {member.mention} for profanity moderation. Missing permissions." embed = nextcord.Embed(title = "Failed to Timeout", description = message, color = nextcord.Color.red()) - admin_channel = guild.get_channel(server.profanity_moderation_profile.channel) + admin_channel = await utils.get_channel(server.profanity_moderation_profile.channel) if admin_channel is None: return False await admin_channel.send(embed = embed) return False @@ -478,7 +478,7 @@ async def grant_and_punish_strike(bot: nextcord.Client, guild_id: int, member: n embed = nextcord.Embed(title = "Failed to Timeout", description = message, color = nextcord.Color.red()) embed.set_footer(text = f"Error ID: {uuid}") - admin_channel = guild.get_channel(server.profanity_moderation_profile.channel) + admin_channel = await utils.get_channel(server.profanity_moderation_profile.channel) if admin_channel is None: return False await admin_channel.send(embed = embed) @@ -626,7 +626,7 @@ async def check_and_trigger_profanity_moderation_for_message( # Send message to admin channel (if enabled) if server.profanity_moderation_profile.channel != UNSET_VALUE: - admin_channel = message.guild.get_channel(server.profanity_moderation_profile.channel) + admin_channel = await utils.get_channel(server.profanity_moderation_profile.channel) if admin_channel is None: description = f"InfiniBot couldn't find your server's admin channel (Moderation -> Profanity -> Admin Channel). It was either deleted, or the bot does not have permission to view it. Please go to the Moderation -> Profanity page of the `/dashboard` and configure the admin channel." await utils.send_error_message_to_server_owner(message.guild, None, message=description, administrator=False) From e98f515ee9d5624c0cb74289dae6dfb81f439418 Mon Sep 17 00:00:00 2001 From: cypress-exe Date: Sun, 19 Jul 2026 22:07:38 -0600 Subject: [PATCH 109/109] fix(dashboard): correct admin-roles help docs link Link had a stray .md extension, unlike every other help-docs link in the file, which would 404 on the Jekyll-hosted GitHub Pages site. --- src/features/dashboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/dashboard.py b/src/features/dashboard.py index c4beb5c..dfb1002 100644 --- a/src/features/dashboard.py +++ b/src/features/dashboard.py @@ -1554,7 +1554,7 @@ async def setup(self, interaction: Interaction): **How It Works** When a member with an admin role attempts to perform an action that would normally be moderated by InfiniBot, InfiniBot will ignore the action and allow it to proceed as normal. - View the [help docs](https://cypress-exe.github.io/InfiniBot/docs/core-features/moderation/admin-roles.md) for more information. + View the [help docs](https://cypress-exe.github.io/InfiniBot/docs/core-features/moderation/admin-roles/) for more information. """ description = utils.standardize_str_indention(description) self.embed = nextcord.Embed(title = "Dashboard - Moderation - Admin Roles",