Skip to content

Fix/bug fixes#76

Merged
cypress-exe merged 109 commits into
mainfrom
fix/bug-fixes
Jul 20, 2026
Merged

Fix/bug fixes#76
cypress-exe merged 109 commits into
mainfrom
fix/bug-fixes

Conversation

@cypress-exe

Copy link
Copy Markdown
Owner

Description

This PR includes over 100 bug fixes. Issues were found and classified by Claude Fable. Fable and Opus then resolved each issue, and committed. All changes were individually reviewed and edited by me. Literally—all of them. It took a while, but I'm confident that this isn't a regression anywhere.

At the end, there are a few general refactors too, but most of this PR is bug fixes.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Performance / refactor (no functional change)

How Has This Been Tested?

All tests pass, and a thorough testing Discord bot pass was conducted, testing every feature, and trying to stress edge-cases.

Checklist

  • I have read the CONTRIBUTING guidelines.
  • My changes follow the existing code style in the files I touched.
  • I have added/updated tests that prove my fix is effective or my feature works.
  • I have updated relevant documentation in ./documentation (if applicable).
  • ./run_tests.bash passes locally.
  • Any derivative/modified code remains open source, per the project LICENSE. (N/A)

cypress-exe and others added 30 commits July 12, 2026 12:35
I had Claude Fable generate a bug report. I will now work to resolve
each bug.
…stored

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
…n place

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
… not an exact minute

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
…e guild

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
…only guild.owner

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 <noreply@anthropic.com>
✓ 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.
…ooter

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 <noreply@anthropic.com>
✓ 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.
… fields

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved

**Human Review Notes:**
Well that was embarrassing... Mb. Good change.
…idates the target

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
…rgeting the leaver

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
…bidden handler

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved

**Human Review Notes:**
Woops.
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 <noreply@anthropic.com>
✓ 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.
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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
…ess window

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
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 <noreply@anthropic.com>
✗ 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.
…top_role crash

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a potential issue that was properly
resolved
…lues

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 <noreply@anthropic.com>
✓ 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.
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 <noreply@anthropic.com>
✓ 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.
…eanup

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
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 <noreply@anthropic.com>
✓ 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.
…resolved

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved

**Human Review Notes:**
Good change.
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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved

**Human Review Notes:**
Again: Woops, mb.
…f crashing

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a plausible issue that was properly
resolved
… failed author fetch

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved

**Human Review Notes:**
This was a sly one. Good catch.
…mption

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 <noreply@anthropic.com>
✓ 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().
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 <noreply@anthropic.com>
✓ 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.
… managed-message lookup

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a possible issue that was properly resolved
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 <noreply@anthropic.com>
✓ 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.
cypress-exe and others added 28 commits July 19, 2026 01:12
`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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
`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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
`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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
`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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved

**Human Review Notes:**
Good catch.
…tor (DASH3)

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 <noreply@anthropic.com>
✓ 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.
…el is selectable (DASH4)

`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 <noreply@anthropic.com>
✓ 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.
`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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
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 <noreply@anthropic.com>
✓ 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.
…mbeds (A1)

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 <noreply@anthropic.com>
✓ 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.
`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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
…g (R3)

`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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
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 <noreply@anthropic.com>
✓ 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.
… autoban modal (X3)

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
… (FR2)

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a legitimate issue that was properly
resolved
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 <noreply@anthropic.com>
✓ 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.
…FR5)

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 <noreply@anthropic.com>
✓ 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.
…flow (FR6)

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 <noreply@anthropic.com>
✓ 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.
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 <noreply@anthropic.com>
✓ 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.
… types (FR8)

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 <noreply@anthropic.com>
✓ Verified by cypress-exe as a good refactor that was properly
implemented.

**Human Review Notes:**
Looks right.
<@&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 <noreply@anthropic.com>
✓ 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.
…R10)

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 <noreply@anthropic.com>
✓ 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.
…d.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.
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.
@cypress-exe
cypress-exe merged commit 2fcaccd into main Jul 20, 2026
7 checks passed
@cypress-exe
cypress-exe deleted the fix/bug-fixes branch July 20, 2026 04:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant