v3.0.0 #1

Merged
zevaryx merged 168 commits from dev into main 2026-11-03 23:28:20 +00:00
Owner

Massive overhaul to fix bugs, polish features, new CI/CD, and prepare for web management platform

Massive overhaul to fix bugs, polish features, new CI/CD, and prepare for web management platform
Move metadata to the PEP 621 [project] table and dev tools to a PEP 735 dependency group, replace poetry.lock with uv.lock, and build the Docker image with uv. The bot is run in place (run.py), so it is not packaged.

Git dependencies now point at their current homes: jarvis-core, erapi and calculator on git.zevaryx.com (the git.zevs.me URLs no longer resolve), statipy and the thefuzz fork on GitHub, each pinned to the commit the Poetry lock used (calculator's git.zevaryx.com history contains the same commit). Other constraints are unchanged.
cogs/unique (ctc2 and dbrand) were documented as abandoned, hard-coded the IDs of specific guilds, and contained hostile messages; /ctc2 guesses also always crashed on guess["correct"]. Remove them along with the dbrand shipping data, the dbrand-only custom emoji constants, the dbrand auto-correct in the message handler (which could never match: "\b" was a backspace), and the beautifulsoup4 dependency that only the scraper used.

BREAKING CHANGE: the /ctc2 and /db commands are gone.
install.sh never installed its packages (it compared strings with -eq), used chown -r, and installed from a requirements.txt that had drifted from the lock; jarvis.service targeted a nonexistent multiplexer.target, ran run.py without a shebang, and its ExecStop killed every tmux session of the user. .env.example listed long-removed settings. Docker (Dockerfile and compose.yaml) is the supported deployment.

BREAKING CHANGE: install.sh, jarvis.service, requirements.txt and .env.example are removed.
The command rendered tokei output to an image with ansitoimg, which pulls in Playwright and a browser download and caps rich below 14; it also blocked the event loop on a subprocess and leaked a PNG in /tmp per run. Remove it, the ansitoimg dependency and the tokei download in the Docker image.

BREAKING CHANGE: /dev cloc is gone.
statipy pins beanie<2 and imports Motor, which blocks moving the bot to Beanie 2 and PyMongo's async client. Copy it (github.com/zevaryx/statipy at 25cef25) into jarvis/stats with only its import paths changed, point the bot's imports at it, and drop the dependency. Porting it to Beanie 2 and fixing its bugs follow separately.
jarvis-core 2.0 (Beanie 2 on PyMongo's async client, IDs stored as int64), interactions-py 5.16, redis 8, croniter 6, Pillow 13, opencv-python 5, psutil 7, rich 15, dateparser 1.4, aiohttp 3.14, pydantic 2.14, pydantic-settings 2.16, jurigged 0.6 and aiofile 3.12; calculator moves to the head of git.zevaryx.com. Python 3.12 is now required (NumPy 2.5's floor).

Replace the thefuzz fork with upstream thefuzz 0.22 (the fork only removed a log warning, now silenced through logging instead), port the vendored stats' init_db to PyMongo, and declare numpy, pytz and tzlocal, which were imported but only installed transitively. Drop the unused orjson and pandas dependencies.

Now that IDs are stored as integers, revert the str(guild) workarounds in the settings and invite checks; string queries would no longer match.

BREAKING CHANGE: requires Python 3.12 and a database migrated with jarvis-core 2.0's jarvis-migrate.
The pytest config measured coverage of a nonexistent spamback package and there were no tests. Add pytest and pytest-asyncio, a database fixture matching jarvis-core and jarvis-tasks (JARVIS_TEST_MONGO_URI), and a test that every extension loads and registers its commands.
Mongo and Redis were unprefixed BaseSettings instantiated at import time, so they read HOST/USERNAME/PASSWORD/PORT from the environment (on Windows, USERNAME is the OS login, which was sent to both servers), while the MONGO_*/REDIS_* names in sample.env were silently ignored. Only the dotted MONGO.HOST form in compose.env worked, and those names cannot be exported from most shells. The default Redis host, localhost, was also rejected by redis.from_url for lacking a scheme.

Use MONGO_ and REDIS_ prefixes with per-instance defaults, ignore unrelated keys and empty values, turn a bare Redis host into a redis:// URL, and pass MONGO_DATABASE/MONGO_AUTH_SOURCE through to connect(). compose.env uses the new names, the unused Mastodon settings are removed, and loading both env files is tested.
The bot service ran the jarvis-tasks image, so a compose deployment ran two copies of the tasks and no bot. MongoDB had no volume, so its data was lost whenever the container was recreated, and neither service waited for it. Also drop the obsolete version key.
The phishing handler built WarningMetadata with name= instead of the required type=, so a pydantic ValidationError was raised right after the warning was saved and before the message was deleted, the user notified, or the sender timed out: detected phishing links stayed up.
on_message awaited autoreact, massmention, roleping, the invite check, filters and the phishing/malicious link checks one after another with no error handling, so any exception (an invalid filter regex, missing permissions, an unknown autoreact emoji, a network error) silently skipped every later check, including the security ones. Each check now runs through _run_check(), which logs its failure and lets the rest continue.
autopurge ran before the DM/bot check in on_message, and on_message_edit and on_message_delete read message.guild.id first, so every message, edit and deletion in a DM raised AttributeError. Return early when there is no guild.
When an activity log was configured, the edit handler returned early if the previous version was not cached (e.g. after a restart) or its content was unchanged, which also skipped the invite, filter and phishing checks on the edited message. Logging moves to _log_edit(), whose early returns no longer end the handler; it also uses the new message's guild and channel since the old one may be missing, and skips a deleted log channel.
on_message_edit called roleping() and checks() twice each, so an edit that broke a rule produced duplicate warnings.
Only the first invite was examined, so posting an allowed invite first let any other through; codes matched by the discord:// form (group 2) were never read; a fixed list of other servers' invites was allowed in every guild; the noinvite setting was written to the database on every message in every guild; and fetch_invites() ran unguarded, raising without Manage Server.

Now every invite (in the raw and whitespace-collapsed text) is compared against the guild's own invites and vanity URL, a missing noinvite setting means disabled, and fetch_invites() runs only when an invite is present and tolerates missing permissions.
The filter modal split its input on newlines and saved every line, so a stray blank line stored an empty pattern, which matches every message and made the bot warn and delete everything in the guild. Invalid regular expressions were also stored and later raised in the message handler. Blank lines are now dropped, each pattern is compiled before saving (with a clear error back to the admin), and filters need at least one pattern.

Also give FilterCog the logger its error path already used, and replace the placeholder example, whose anchors were reversed, with a working one. Matching helpers use the regex package so a later change can bound how long a pattern may run.
Filters ran stdlib re.search on the event loop with admin-supplied patterns: an invalid stored pattern raised re.error out of the handler, a catastrophic one could freeze the bot, a stored blank pattern matched every message, and, unlike the invite, mass-mention and roleping checks, moderators were not exempt.

Match through the timeout-bounded helper, skip blank and invalid patterns, and exempt members with Manage Server or Administrator.
The author was treated as lacking the role whenever any of their roles was not on the blocklist, which is true of @everyone, so members who held a blocked role were flagged for pinging it. A bypass on any roleping was also applied to every other roleping. Decide per pinged role instead: allowed if the author holds it or is on that roleping's own bypass list (by user or role). Mentioned users who are not members no longer raise on .roles.
The threshold was converted with int() for one check but the raw setting value was compared against the count, so a setting stored as a string raised TypeError; mentions were counted with duplicates, and an invalid or missing setting was not handled. Parse the threshold once (invalid or non-positive disables the check), check moderators first, and count distinct users and roles excluding the author.
The phishing and malicious-URL handlers were near-duplicates, and both posted confirmation requests to a hard-coded channel ID from one specific server, so on any other deployment fetch_channel returned None and the handler crashed after already deleting the message. Move the common warn/delete/review flow into _punish_link(), and read the review channel from the optional PHISHLIST_CHANNEL setting; without it detections are still acted on, just not sent for review.
The list of tens of thousands of known phishing domains was a Python list, scanned linearly for every URL in every message, and only exact domains matched, so login.secure.<listed domain> got through. Keep it as a set of lowercase domains (the periodic update becomes set arithmetic, which also fixes the removal counter going negative) and check the domain and each of its parents. A domain repeated in one message is now punished once.
The malicious-URL check returned as soon as one domain was confirmed safe, skipping the rest of the message, opened a new HTTP session and request per link with no timeout, let any network error escape and abort the remaining checks, and posted the whole message text to a third-party service for each link.

Links are now checked in one request through a shared session with a 5 second timeout, only the link text is sent, a confirmed-safe domain is skipped without hiding the others, and an unavailable service is logged and treated as no match.
The verification embed looked the entry up with fetch_audit_log(user_id=...), but that filters by who performed the action (the bot, via /verify), not the member who was verified, so it never matched; it then read auditlog.events (the attribute is entries) and called a misspelled fet_member. Fetch recent role updates unfiltered and match on the entry's target and reason.
The role-change embed was nested inside the verified-role branch, so guilds without a verified role never had role changes logged; on_member_update read before.display_name before checking that the previous state was cached, and a deleted activity channel raised out of the handler. Compare role IDs, un-nest the logging, return early for uncached members, and treat a missing activity channel as unset.
Rejoining members were given every stored temp role, including expired ones and ones saved with reapply_on_rejoin=False, and one role that could not be assigned (Forbidden) aborted the rest and the unverified-role step. Filter on expiry and the flag, and log and continue past roles that cannot be given. The leave handler also tolerates a missing activity channel.
The staged commits that introduced these tests each carried a copy of the join helpers and tests.
on_startup waited on chunk_task, which was only assigned inside a branch (so a failed phishing sync or a second ready event raised NameError and skipped the remaining startup work) and whose coroutine did nothing but log a deprecation warning. Remove it, and log a failed currency-rate fetch instead of letting it end startup.
The condition was modlog and (ignore and channel not in ignore), so nothing was logged unless a log_ignore setting existed; message edits and deletions used the correct form. Prefixed-command arguments were truncated with v[97] (one character) instead of v[:97], the footer appended a legacy discriminator through ctx.author.user, a deleted log channel raised out of the handler (Guild.fetch_channel raises, so the cleanup branch never ran), and a backtick in an argument could close the ansi code block and inject markdown into the log.

Fix the condition, truncate properly, replace backticks with a lookalike, and remove the setting when the channel is gone.
The buttons carried the entry id as a string and queried Phishlist.id == <str>, which never matches an ObjectId, so confirming or rejecting a detection never did anything. They also had no permission check, so anyone who could see the review channel could decide what counts as phishing. Parse the id (replying if it is malformed or gone) and allow only the bot owner and administrators.
A click by someone without Moderate Members returned without any response (Discord showed "interaction failed"), and every modcase click ended with "Moderation case opened" even when the member had left, the action data had expired, or a case already existed. Reply to unauthorized clicks, only confirm success when a case was created, and stop deleting an empty Redis key when there was no action key.
The copy button duplicated any reminder it was given, including private ones (exposing their text to whoever clicked), finished or disabled ones, and crashed in DMs because context.guild is None; the copy also dropped the repeat schedule and timezone, and inherited a disabled state. Refuse private, finished and disabled reminders, carry over repeat and timezone, always activate the copy, and fall back to the user's ID as the guild like /reminders set does.
The handler fetched a specific server and channel by hard-coded ID (so on any other deployment the user got no reply at all), read ctx.guild.name (an AttributeError for commands run in DMs), truncated arguments with v[97] (one character), ran together the prefixed-command arguments, and called ctx.defer() after replying, which always raised and was logged as an uncaught exception.

The error channel is now the optional ERROR_CHANNEL setting. Tracebacks too long for Discord go to PASTE_SITE if one is configured (they include command arguments, so that is opt-in) and are otherwise truncated from the front, keeping the actual error.
_update_currencies was defined to refresh the rates every 30 minutes but never started, so they were fetched once at startup and then went stale for the life of the process.
The task was never started, and starting it would have been destructive: it deleted every document referencing a user whose mutual_guilds was empty, but mutual_guilds only reflects the member cache, and banned or kicked users are by definition not in any shared guild, so it would have erased their moderation records (and guilds or channels that merely failed to fetch). Remove it and its CronTrigger rather than leave it to be switched on.

BREAKING CHANGE: the unused _cleanup task is gone.
cleanup_user deleted every document with a user field, which includes the bans, kicks, mutes, warnings, cases and temporary roles recorded about that user, so anyone could erase their own moderation history with /data delete (and active temp bans and roles would then never be lifted or removed). It also missed the user's ID inside roleping bypass lists and case notes.

Now it deletes only personal data (reminders, personal settings, captchas), keeps records about the user, reassigns everything they authored to the bot (including case notes), and removes them from roleping bypass lists. The confirmation text says what is kept.

BREAKING CHANGE: /data delete no longer removes moderation records.
The case hook built its warning statistic with self.user and self.client_name, but self is the Extension, which has neither, so every /warn raised AttributeError after warning the user (reported to the error channel with "Whoops!" shown to the moderator) and the case was never linked. Use self.bot.
The case hook looked up the latest action with coll.active == True for every action type, but Kick has no such field, so building the query raised AttributeError after every successful /kick. Only filter on active for models that have it.
Guild.icon is None for guilds without an icon, but thirteen places read guild.icon.url directly. The ban and kick DM embeds are built before the action is carried out, so on such a guild /ban crashed before banning anyone; the warnings, bans list, server info, rolegiver and notification embeds crashed the same way. Add guild_icon_url() and set_guild_thumbnail() and use them everywhere.
kick_embed hard-coded the reason as "A valid reason" instead of using the one given, and its DM variant called build_embed() without the required description, which raised TypeError (caught and logged as a failed DM) so kicked users never received the message.
C:/Program Files/Git/kick was guarded by Ban Members, so a moderator with Kick Members could not kick, while anyone who could ban could.

BREAKING CHANGE: members who relied on Ban Members to use /kick now need Kick Members.
The mute commands accepted Mute Members, which only governs muting in voice channels and does not grant Discord timeouts, while the moderation-case buttons already required Moderate Members. Replace it with Moderate Members; Kick Members and Ban Members still work.

BREAKING CHANGE: members who relied on Mute Members alone to use /mute or /unmute now need Moderate Members.
The stored duration used timedelta.seconds, which ignores whole days, so a 2 day 3 hour mute was recorded as 180 minutes. The Mute User context menu also skipped the checks the slash command has: Discord rejects timeouts over 28 days (surfacing as an unhandled HTTP error), and it allowed muting yourself or the bot. Use total_seconds() and apply the same checks.
Mute documents were created as active and nothing ever cleared the flag, so /unmute left the record active and the moderation-case hook kept linking later actions to the old mute.
C:/Program Files/Git/ban stored permanent bans as "perma" while Ban.type defaults to "perm" and /bans list filters on "perm", so permanent bans never appeared when filtering by type. Also drop the unused user_message string that was built and never sent.
delete_history was validated with time_pattern.match(), which only checks the start of the string, so "1w garbage" passed and was silently parsed as 1 week. Match the entire value. A temp ban of 0 hours was also accepted (only negative values were rejected), which expires immediately.
Lifting a ban through /unban only created an Unban record and left the Ban active, so it still appeared in the active list and the case hook kept treating it as current. Deactivate the guild's active bans for that user. The discriminator is now stored as given since the model normalizes it (the old == 0 comparison never matched Discord's string "0").
Looking up a ban only searched the first 1000 entries, the fallback to our own records could never find anything (it tested isinstance(user, User) on a string, searched an integer against the username, and compared an ObjectId to a user ID), its saves were never awaited so nothing was written, and name#1234 parsing used re.match and could not match. An ambiguous username replied with every ban in the guild, publicly.

Look IDs up directly with fetch_ban, scan usernames across every page of bans, list only the ambiguous matches (ephemerally and bounded), and fall back to the stored ban by user ID or username with awaited writes. The command now defers, since it makes several API calls.
The manual-bans branch tested the builtin type instead of btype, so it never ran, and called ctx.guild.bans(), which does not exist (the method is fetch_bans), so it would have crashed if it had. Pages were counted from the number of database bans rather than the number of entries shown, so entries were dropped or pages came out empty, and the command did database and REST work without deferring. Fetch every page of Discord bans, count pages from the entries, and defer.
/lock changed the channel's permissions first and only then saved its Lock record, and saving raised a ValidationError whenever @everyone had no overwrite on the channel, leaving it locked with nothing to unlock it. It also never checked for an existing lock, so a second lock stored the already-locked permissions as the ones to restore and unlock left the channel locked. And it overwrote any other permissions @everyone had set on the channel, and its confirmation was an ephemeral reply, so the channel was never told.

Add shared lock_channel()/unlock_channel() helpers that save the record first (removing it if applying fails), keep other @everyone permissions, refuse to lock an already-locked channel, and unlock a channel that was deleted; /unlock finds the lock by channel, which also works for locks saved before Lock had a guild.
/lockdown start only locked categories and the channels inside them, so channels without a category were never locked; a failure on any channel aborted the whole thing half-way with nothing recorded; and /lockdown end unlocked channels but never gave @everyone back its permissions (only the tasks service did, and only if it still had the job queued), so ending a lockdown by hand could leave the server muted indefinitely.

Start now records the lockdown, with the permissions to restore, before changing anything, locks each lockable channel individually (skipping and reporting ones it can't), and says if the role couldn't be edited; end unlocks every locked channel in the guild and restores @everyone from the record.
BREAKING CHANGE: /case repoen is now /case reopen.
/purge read amount + 1 messages (slash commands post no message of their own, so that was one too many), accepted any amount, and deferred without ever sending a follow-up, so the interaction ended as "did not respond". Delete exactly the requested number, cap it at 1000, record how many were actually deleted, and confirm. /autopurge update also skipped the bounds that /autopurge add enforces.
/roleping bypass user passed role=role.id as a keyword to find_one(), which is not a query; pymongo rejected it with a TypeError, so the command could never work.
/roleping restore role read bypass.display_name on a Role (which has no such attribute) after already saving, so it always raised; /roleping list dereferenced .mention on the None that fetch_member returns for members who left, so a single departed bypass user broke the whole list.
/autoreact add defaulted thread to True and always wrote it back, so adding an emote to an autoreact that had threads switched off silently turned them on. The custom emote pattern also only matched static emotes, rejecting animated ones. The thread option now only changes the setting when given (new autoreacts still default to threads), and animated emotes are accepted.
C:/Program Files/Git/settings set log_ignore compared the channel object to a list of IDs, so the already-ignored check never matched and the same channel was appended repeatedly. /settings unset noinvite took an active parameter that no slash option provided, so invoking it failed.
The command only checked that the bot could assign the role, and the bot is normally far above its users, so anyone with Manage Roles could use /temprole to give themselves (or anyone) a role higher than their own, including administrator roles. Apply Discord's own hierarchy rule: the server owner, or a member whose highest role is above the role. Adds outranks_role() and is_dangerous() helpers for the other commands that hand out roles.

BREAKING CHANGE: /temprole refuses roles at or above the invoker's top role.
Rolegiver offers its roles to every member to pick for themselves, but adding one only checked that the bot could assign it. Anyone with Manage Server could therefore offer a role above their own, or one carrying Administrator or other moderation permissions, to the whole server. Apply the role hierarchy rule and refuse roles that grant dangerous permissions.

BREAKING CHANGE: /rolegiver add refuses roles above the invoker's top role and roles with administration or moderation permissions.
These roles are handed out automatically (the verified role to everyone who passes /verify, the unverified role to everyone who joins), so setting one to a role above the admin's own, or one with administration or moderation permissions, gave that role to everyone. Apply the role hierarchy rule and refuse dangerous roles.

BREAKING CHANGE: /settings set verified and /settings set unverified refuse roles above the invoker's top role and roles with administration or moderation permissions.
The loop condition used a verified flag that was never set, so after a correct answer it kept waiting for another click for 30 seconds, then logged a false "failed to verify before timeout" and tried to delete the already-deleted message. Return as soon as the role has been granted.
/warn required Manage Server, a far higher bar than muting or kicking, so a moderator who could ban could not warn; the case commands required Ban Members while the case buttons already required Moderate Members. Accept Moderate Members for both (Manage Server for /warn and Ban Members for cases still work).
The permission check on /warnings was commented out, so any member could read any other member's warnings and which administrator issued them. Moderators (Administrator, Manage Server, Moderate Members, Kick Members or Ban Members) can still look anyone up; everyone else can look up themselves.

BREAKING CHANGE: non-moderators can no longer view other members' warnings.
/filter view had no check, so any member could read the guild's word filters and regular expressions (making them trivial to evade), and /roleping list, also unchecked, listed the bypass users and roles. Require Manage Messages (as for editing filters) and Manage Server (as for the rest of roleping). Also fix the "Lick" typo in the list description.

BREAKING CHANGE: these two commands now need Manage Messages and Manage Server respectively.
Either Manage Server or Manage Messages was enough for every pinboard command, so a Manage Messages user could delete a pinboard, which also deletes all the messages pinned to it. Creating and deleting boards now need Manage Server; listing and pinning keep accepting Manage Messages.

BREAKING CHANGE: Manage Messages alone no longer allows /pinboard create or /pinboard delete.
Deleting a pinboard looked its pins up with Pin.pinboard == channel.id (a Link compared to a channel ID, so nothing matched, and no pin was ever removed), and fetched pin.message from the board channel: that is the original message, which lives elsewhere, rather than pin.pin, the copy posted to the board. A deleted board channel crashed it outright (Guild.fetch_channel raises). Query the board's pins by the board's id, delete the copies, and clear the pins even if the channel or a copy is already gone.
After the user picked a board, the command used the loop variable left over from building the list (the last pinboard, not the choice), then called int() on an ObjectId, read .mention and .send on the database document instead of the channel, and indexed a dict keyed by channel ID with a document ID, so it raised on every use. It also waited for the selection forever.

Keep each board paired with its channel, post to the chosen channel, store the board document on the Pin, count and de-duplicate pins per board, time out after five minutes, and treat boards whose channels are gone as removed.
After the selection timed out the command carried on and read the unbound response, raising UnboundLocalError. It also posted embed fields with empty values when everything was removed, crashed on roles deleted from the guild meanwhile, and raised KeyError when the guild had no cache entry.
on_ready deleted a guild's rolegiver configuration whenever the guild or a role could not be fetched at that moment, and the cache code raised KeyError for guilds or groups it had not seen (after a restart, or when deleting or listing). Cleanup and cache refresh removed items from the list they were iterating, skipping entries, and the autocomplete refresh read role.role.id, which does not exist.
Creating or editing a tag raised AttributeError in guilds with no noinvite setting. Edit and delete raised KeyError or ValueError when the autocomplete cache was cold or out of step after a restart, and a name clash during edit replied on a context that had already answered with the modal, so the reply failed.
Embeds, warning and temp-role displays, /serverinfo and the stats client formatted every user as name#discriminator, which prints name#0 for every account that has migrated to unique usernames. Add user_tag(), which only appends a discriminator for the few legacy accounts that still have one. Also run the smoke test last, since loading the extensions binds shared subcommand callbacks and broke later tests that call them directly.
Tag content was sent with the default allowed mentions, so any mention that slipped past the escaping pinged people. /tag list failed on guilds with no tags (empty description) and on guilds whose names exceed the 4096-character embed limit.
/reminders delete and /reminders fetch looked a reminder up by ID without checking the owner, so anyone who obtained an ID could delete or read another member's reminder, including private ones. Fetch also compared the ID string to an ObjectId and so never found anything, and a malformed ID raised instead of answering. Look reminders up through one helper that parses the ID and requires the caller to own it.
An unknown timezone raised from pytz, a missing cron field would have crashed on None, and a repeating reminder could be set to fire every minute. Reject unknown timezones, require repeats to be at least five minutes apart and limit members to 25 active reminders, which also keeps /reminders list inside an embed's 25 fields. Also drop a dead duplicate past-date check.
The logo and the unicode emoji JSON were opened relative to the working directory, so the bot failed to import unless it was started from the repository root. /bot donate was also defined twice.
/serverinfo built continuation embeds for servers with many roles but never sent them, so long role lists were silently missing. /userinfo put every role mention in one field, which Discord rejects over 1024 characters, and read roles and join date from the option's User instead of the guild Member.
/pw accepted zero or negative lengths and produced an empty password. /timestamp interpreted bare times in the bot host's local zone and crashed on out-of-range dates. /emotes swallowed every error and left the interaction unanswered, and failed on servers with no custom emojis.
The volume table had no milliliter unit (it was misspelled millimeter and the base millileter), and any unit the converter did not know raised or returned None, which crashed the unit conversion commands. Unknown units now raise KeyError in the table and the command asks for a known one.
Kelvin to Celsius added 273.15 instead of subtracting it and Kelvin to Fahrenheit converted the wrong value. A math result of 0 was treated as no result. Converting a currency to itself raised UnboundLocalError, and unknown codes or rates that had not loaded raised instead of answering.
/dev hash checked the size with a separate request and then fetched the data again, so a server could report a small size and send gigabytes, and the pre-check did nothing when no Content-Length was sent. Pass the limit into hash(), which only connects to public addresses and stops at the limit, report refusals without echoing exception text, and match invite links anywhere in the text instead of only at the start. The limit drops from 5GB to 512MB.
An unknown timezone raised inside a catch-all that only logged it, leaving the interaction without a response.
The command fetched user URLs with a plain client (SSRF), read whole bodies into memory, decoded images of any size, and ran OpenCV on the event loop. Its resize loop compared a percentage to a 0.80 fraction and could loop forever or resize to zero pixels, and the ClientSession it created in __init__ was never closed. Download through the public-address client with a 25MB limit, refuse images over 50 megapixels, binary-search the scale that fits the target in a worker thread, and report failures to the user.
Logger.warn is a deprecated alias that emits a DeprecationWarning and is slated for removal. Replace its uses and add a test that keeps them out.
jarvis.log was opened in write mode, so every restart (including a crash loop) erased the log that explains the crash. jurigged, a hot-reload development tool, was imported unconditionally, and run.py patched the event loop twice with nest_asyncio although nothing nests loops.
The prefixed-commands extension was never set up, so echo, tail, log, sysinfo and crash could not run, and the owner check raised AttributeError when the owner was not yet known. Set the extension up when OWNER_PREFIX is configured (off by default), make the check fail closed, and bound /tail's line count.
Every command defaulted to being available in DMs, where those that read ctx.guild (moderation, tags, rolegivers, pinboards, server and user info) failed with AttributeError. Mark them guild-only when the extensions are loaded.
Drop nest-asyncio and tzlocal, which nothing imports any more, move the jurigged hot-reloader to the dev group, and point jarvis-core at the current rewrite branch head. Relock. Also run both tests that load every extension last.
pre-commit-hooks 6.0.0, ruff 0.17.3, black 26.10.1 and pre-commit 4.7.0. Remove the deprecated ignore-init-module-imports option, and fix the findings the newer ruff reports: unused imports, implicit Optional in the stats module, superfluous else-after-return and docstring spacing.
The container ran as root. Create a system user that owns /app so the bot can still write jarvis.log.
Replace the stale requirements (tokei, requirements.txt, Python 3.10 badge, GitLab links) with how to run, migrate and test the bot.
Breaking: Discord IDs are stored as integers (run jarvis-migrate before deploying), several commands changed permissions, and the pinned jarvis-core is the 2.0 rewrite.
Replace the GitLab pipeline with a Forgejo Actions workflow: pre-commit, the test suite against a Mongo service, and a Docker build and push to both registries on main, dev and tags. Secrets used: ZEVARYX_REGISTRY_URL/USERNAME/PASSWORD and DOCKERHUB_REGISTRY_USERNAME/PASSWORD.
Set default_member_permissions on ban, unban, bans, kick, purge, autopurge, lock, unlock, lockdown and settings, whose checks each need exactly one permission. Commands whose checks accept any of several permissions (mute, unmute) or that mix levels under one base command are left visible, since Discord requires all of a command's default permissions. The runtime checks are unchanged.
C:/Program Files/Git/bot status linked the version to the old stark-industries/jarvis GitLab path, and PRIVACY.md linked its changelog to a GitLab commits URL. Use the current repository and the Forgejo history URL, and add a test that keeps the old group out of the code and docs.
fix(reminders): warm the date parser so the first modal submit is answered in time
Some checks failed
CI / precommit (push) Failing after 36s
CI / test (push) Failing after 43s
CI / image (push) Has been skipped
d314fc8024
The first reminder after a restart spent about a second loading dateparser's language data before replying, which together with network and database time could exceed Discord's three-second window for answering a modal. The reminder was saved but the modal showed as failed; later uses were fast. Parse a few phrases in a worker thread at startup.
ci: run workflows on ubuntu-latest
Some checks failed
CI / precommit (push) Failing after 2s
CI / test (push) Failing after 3s
CI / image (push) Has been skipped
f9edb2758b
Use the ubuntu-latest runner label instead of docker.
ci: run jobs in the runner image instead of python containers
Some checks failed
CI / test (push) Failing after 21s
CI / precommit (push) Failing after 35s
CI / image (push) Has been skipped
79695c163a
The python job containers have no Node, which actions/checkout needs. Drop them, install uv in the ubuntu-latest image and let it fetch Python, and build Docker images on ubuntu-full-latest.
CI runs pre-commit over everything, including tests. Apply black 26's formatting and ruff's safe fixes, exempt tests from the annotation and docstring rules, and replace the lookalike-backtick literal and its noqa with a named constant so the formatter can't separate them.
ci: install OpenCV's system libraries before running the tests
Some checks failed
CI / precommit (push) Successful in 32s
CI / test (push) Successful in 1m38s
CI / image (push) Has been cancelled
d63e84548b
opencv-python links against libGL, which the runner image does not ship, so importing cv2 fails during collection. The Dockerfile installs the same libraries for the same reason.
Add one extra matrix entry (Python 3.12) that installs interactions-py 6.0.0rc1 over the locked version before running the tests. Locally, jarvis-bot's 214 tests and jarvis-tasks' 28 pass on it. The tests run with --no-sync so uv does not put the locked version back.
ci: build images on ubuntu-latest instead of the 16GB full image
Some checks failed
CI / precommit (push) Successful in 1m4s
CI / test (6.0.0rc1, 3.12) (push) Successful in 3m12s
CI / test (locked, 3.13) (push) Successful in 3m43s
CI / test (locked, 3.14) (push) Successful in 4m33s
CI / test (locked, 3.15) (push) Successful in 2m52s
CI / test (locked, 3.12) (push) Has been cancelled
CI / image (push) Has been cancelled
868772c1e2
The slim act-latest runner image already ships the Docker CLI and buildx plugin, so the full image is not needed to build and push.
On a runner host whose containers default to a 1024 open-file limit, mongod aborts with WiredTiger 'Too many open files' partway through the suite and every later test fails with connection errors. Start the service with --ulimit nofile=64000:64000, the limit MongoDB recommends.
ci: run every test job against interactions-py 6.0.0rc1
Some checks failed
CI / precommit (push) Successful in 34s
CI / test (6.0.0rc1, 3.12) (push) Successful in 1m59s
CI / test (6.0.0rc1, 3.13) (push) Successful in 2m11s
CI / test (6.0.0rc1, 3.14) (push) Successful in 1m44s
CI / test (6.0.0rc1, 3.15) (push) Successful in 2m26s
CI / image (push) Failing after 6s
0cfabc30b7
Replace the single rc1 matrix entry with rc1 for all Python versions, so the tests no longer run against the locked 5.x release. Locally both suites pass on rc1.
The image job built an invalid reference when the registry secret was saved with a scheme, trailing slash or whitespace. Normalise the host, check it before building, and pass secrets through the environment instead of expanding them into the script. Tags are now: dev branch -> :dev, main -> :latest, git tags -> the tag, so people on :latest no longer get development builds. compose.yaml follows :stable to :latest, since :stable is no longer published.
Make the 6.0 release candidate the real dependency instead of overriding it in CI: allow >=6.0.0rc1,<7 and relock. The 214 tests pass on it.
ci: drop the interactions-py override now that the lock uses 6.0.0rc1
All checks were successful
CI / precommit (push) Successful in 34s
CI / test (3.12) (push) Successful in 1m53s
CI / test (3.13) (push) Successful in 1m32s
CI / test (3.15) (push) Successful in 1m48s
CI / test (3.14) (push) Successful in 2m2s
CI / image (push) Successful in 1m32s
14cdccfcbb
The test jobs install the locked dependencies as-is, so the matrix no longer needs an interactions version or a pip override step.
Add text_field(), which wraps an InputText in a LabelComponent, and use it for /tag create and /tag edit. Discord is moving field labels off the text input itself, and the wrapper also carries help text, which the create modal now uses to say which characters a name may contain. The responses keep the same keys.
Use text_field() for the Mute User and filter create/edit modals, add help text, and cap their titles at Discord's 45 characters, which a long display name or filter name could previously exceed.
Use the new modal components in /reminders set: a radio group for no repeat, daily, weekly or monthly (derived from the first run, so nobody has to write cron for the common cases), a checkbox for sending as a DM that the private option pre-fills, and labelled fields with help text. A custom cron pattern still works and wins over the radio choice. Monthly repeats are refused after the 28th because not every month has those days. Needs interactions-py 6, which is why the modal uses LabelComponent.
Open a modal with a role select menu (up to the group limit) and a group name, and add every selected role in one go. Each role goes through the same checks as /rolegiver add (not @everyone, assignable, below the invoker's top role, no moderation permissions), now shared in one helper, and the reply lists what was added and why anything was skipped. Move truncate() to jarvis.utils so cogs can share it.
Listen for AutoModExec, which the default intents already deliver, and post who triggered which rule, where, what the rule did and what matched to the guild's activity log channel, next to the bot's own filter hits. The quoted message cannot close its code span, a user who has left is still mentioned, and a log channel the bot cannot write to is logged instead of raising. LOOKALIKE_BACKTICK moves to jarvis.utils so both handlers share it.
Mark /pw, /timestamp, /calc, /dev, /avatar (and the Avatar user menu), /pigpen, /lmgtfy and /rc as user-installable and usable in DMs, group chats and servers the bot is not in. None of them use the guild they run in. Moderation, tags, rolegivers, server info and reminders stay guild installs only, since they depend on the server or on the bot being able to post in the channel.
style: format the new modal, AutoMod and install code; document the new features
All checks were successful
CI / precommit (push) Successful in 33s
CI / test (3.12) (push) Successful in 2m7s
CI / test (3.13) (push) Successful in 1m40s
CI / test (3.15) (push) Successful in 1m55s
CI / test (3.14) (push) Successful in 2m10s
CI / image (push) Successful in 1m31s
CI / precommit (pull_request) Successful in 33s
CI / test (3.12) (pull_request) Successful in 2m1s
CI / test (3.13) (pull_request) Successful in 1m38s
CI / test (3.15) (pull_request) Successful in 1m58s
CI / test (3.14) (pull_request) Successful in 2m12s
CI / image (pull_request) Has been skipped
c5b6b0d582
Black and ruff formatting for the code added with the interactions-py 6 work, a tidier AutoMod test, and README notes for the repeat and DM options, user installs, AutoMod logging and /rolegiver bulk.
/unban now suggests the server's active bans (by name, ID or reason), /case show/close/reopen/note suggest cases (close only offers open ones, reopen only closed ones, each with who it is about), and /autoreact remove suggests the emotes configured on the chosen channel. Previously moderators had to copy IDs and emoji by hand. Typing a value that is not suggested still works. Shared autocomplete_choices() keeps labels and values inside Discord's limits.
C:/Program Files/Git/purge only took a count. Add user, bots, contains, links and attachments filters (combined with AND) that look back through up to 1000 messages for matches, and before/after options that accept a message ID or link. Pinned messages are kept and messages older than 14 days, which Discord refuses to bulk delete, are skipped, and the reply says how many of each instead of the call failing.
/userinfo now shows the nickname, boosting since and the notable permissions a member holds, and gives moderators a private (ephemeral) moderation record: active warnings, open cases, bans, mutes and kicks on file. /serverinfo adds boost level and count, verification level, emoji and sticker counts and the server features, and still answers if the counts cannot be fetched. /avatar links the server avatar, the global avatar and the banner when they exist.
Point jarvis-core at 58a0f48, which adds Escalation for warning escalation rules. Relock.
Servers can now say what happens when a member reaches a number of active warnings: /escalation add <warnings> <mute|kick|ban> [minutes], list and remove (up to 10 rules, Manage Server; moderators can list). /warn counts the member's unexpired active warnings, shows the count, and runs the matching rule, recording the mute, kick or ban like a manual one with the bot as moderator. A rule fires when the count equals its threshold exactly, so it runs once per streak. Moderators are never escalated, and a failed action (missing permission or a higher role) is reported in the warning instead of silently skipped. Uses the new Escalation model from jarvis-core.
Choosing a timezone in /reminders set now saves it as that member's default, so it only has to be typed once; leaving it out uses the saved one. /reminders timezone shows, changes or clears it. Unknown zones are refused and not saved. It is stored in UserSetting, which /data delete already removes.
Change a pending reminder's text, time or both instead of deleting and recreating it. The time is read in the reminder's own timezone with the same parser as /reminders set, now shared as parse_when(). Only the owner's active reminders can be edited, the invite and past-time checks still apply, and the tasks service reschedules on its own because it re-reads the reminder when it fires.
A delivered reminder can carry Snooze buttons (10 minutes, 1 hour, 1 day, added by jarvis-tasks). Pressing one sets the same text again for later. The text comes from the delivered message, because one-off reminders are deleted once they fire. Only reminders the bot itself sent are accepted, only the person a server reminder mentions may snooze it, the reminder limit applies, and the buttons are removed after use.
/image shrink only produced PNG, which has no quality setting and can only get smaller by throwing pixels away. JPEG and WebP outputs first lower the quality at full size and only scale down if that is not enough, then raise the quality back up as far as the new size allows. The new format option defaults to the input's own format, transparency is flattened onto white for JPEG, and WebP inputs are now accepted. The reply names the format and the file gets the matching extension.
jarvis-core, jarvis-tasks and jarvis-bot now all share the version 3.0.0. Require jarvis-core >=3.0.0,<4, pin the 3.0.0 commit, relock, and note in the README that 3.x of each work together.
style: format the new autocomplete, purge, escalation, snooze and image code
All checks were successful
CI / precommit (push) Successful in 31s
CI / test (3.12) (push) Successful in 2m16s
CI / test (3.13) (push) Successful in 2m44s
CI / test (3.14) (push) Successful in 2m2s
CI / precommit (pull_request) Successful in 33s
CI / test (3.15) (push) Successful in 2m54s
CI / test (3.12) (pull_request) Successful in 2m18s
CI / test (3.13) (pull_request) Successful in 2m38s
CI / test (3.14) (pull_request) Successful in 2m4s
CI / image (push) Successful in 1m11s
CI / test (3.15) (pull_request) Successful in 2m58s
CI / image (pull_request) Has been skipped
cf59daae5b
Black formatting and ruff fixes for the improvements added since the last style pass, so pre-commit passes in CI.
Only /timestamp and /reminders could answer quietly. The four info commands now take a private option that sends the result so only the invoker sees it. A reply only they can see has no delete button, since there is nothing for anyone else to remove, and /userinfo defers quietly so nothing flashes in the channel first. The context menu versions stay public because menus cannot take options.
/tag list cut off at 'and N more' after about 3900 characters, /reminders list put up to 25 reminders of up to 500 characters in one embed (past Discord's 6000-character limit), and /pinboard list was a bare list of channels. Share chunk_lines() and send_pages(): one page is sent as before, several go through the paginator. Reminders come five to a page, soonest first, tags in pages of whole lines, and /pinboard list now shows how many pins each board has.
/lockdown locked every lockable channel, including staff and announcement channels. /lockdown exclude add/remove/list now keeps a per-server list of channels, or whole categories, that a lockdown skips. Taking Send Messages from the @everyone role would lock those channels anyway, so when anything is excluded the role is left alone and the reply says so, including that channels created during the lockdown stay open. Without exclusions nothing changes.
Members had no way to flag a message short of finding a moderator. The Report Message menu asks for an optional reason in a form and posts the author, channel, reporter, reason, the message text (safely quoted) and the attachment count to the channel set with /settings set reports, with a link to the message and a Resolve button that only moderators can use. Members cannot report themselves or the bot, there is a 30 second cooldown, and a report the bot cannot deliver says so instead of vanishing. /settings view now also shows the reports channel and the lockdown exclusions.
Point jarvis-core at b59020e, which adds tag use counts and aliases, pinboard thresholds, exclusive and gated rolegiver groups and the RoleMenu model. Relock.
Tags are counted each time they are fetched (/tag info shows it, /tag list sort:Most used orders by it). /tag alias add and remove let a tag answer to up to five other names, checked against every tag name and alias in the server and managed by the creator or a moderator; every command that takes a tag name now accepts an alias, and autocomplete offers them. /tag search finds tags by name, alias, content or a typo in the name and ranks exact names first. Renaming a tag to one of its own aliases drops that alias instead of duplicating it. Uses the new Tag.uses and Tag.aliases fields from jarvis-core.
Pinboards were manual-only. /pinboard create now takes an optional threshold and emoji, and /pinboard auto changes or turns them off later. When a message reaches the threshold the bot copies it to the board, counting reactions from people other than the author and not bots, once per message even if reactions arrive in a burst. To avoid leaking, it never copies a message from a channel that @everyone cannot read, nor an NSFW message into a board that is not NSFW, and never from the board's own channel. Posting is shared with the manual path, and /pinboard list shows which boards are automatic. Uses the new Pinboard.threshold and emoji fields from jarvis-core.
/rolegiver group options makes a group exclusive (taking a role gives up the member's other roles from that group) and/or gated behind a role members must already have. /role get now enforces both: a missing requirement is explained, a pick-one group offers a single choice and swaps, and the result lists what was added, given up and skipped. Role changes go through one helper that only accepts roles the group offers, re-checks that each can still be handed out (not dangerous, not managed, not above the bot), always lets a member give a role up, and reports roles the bot is not allowed to change instead of raising. Uses the new Rolegiver.exclusive and required_role fields from jarvis-core.
Role picking only worked through an ephemeral dropdown that lasted five minutes. /rolegiver menu post puts a select menu for a group in a channel; picking a role toggles it, and the menu follows the group's rules (pick one, required role). The select's id points at a stored RoleMenu, so menus keep working after a restart and are checked against the live group on every use: stale, foreign-server and unknown menus are refused, and only roles the group offers can be taken. Adding or removing roles, cleaning up, changing options or deleting the group updates the posted menus (deleted groups turn them into a notice), /rolegiver menu refresh and remove manage them by hand, and a menu whose message was deleted is forgotten. Up to 25 per server. Uses the new RoleMenu model from jarvis-core.
Black formatting and the ruff findings from the latest features: a return annotation, a plan/apply split of change_roles to bring its complexity down, a def instead of an assigned lambda and an unused class in tests, so pre-commit passes in CI.
docs(readme): list the new moderation, tag, pinboard and rolegiver features
All checks were successful
CI / precommit (push) Successful in 32s
CI / test (3.12) (push) Successful in 2m54s
CI / test (3.13) (push) Successful in 4m2s
CI / test (3.14) (push) Successful in 2m49s
CI / precommit (pull_request) Successful in 33s
CI / test (3.15) (push) Successful in 4m5s
CI / test (3.12) (pull_request) Successful in 2m55s
CI / test (3.14) (pull_request) Successful in 2m59s
CI / test (3.13) (pull_request) Successful in 4m3s
CI / image (push) Successful in 1m12s
CI / test (3.15) (pull_request) Successful in 3m4s
CI / image (pull_request) Has been skipped
39c4b39560
Warning escalation, message reports, lockdown exclusions, purge filters, automatic pinning, tag aliases and search, and pick-one groups, gated groups and role menus.
docs: describe the website, its cookies and sessions in the privacy policy and terms
All checks were successful
CI / precommit (push) Successful in 45s
CI / test (3.12) (push) Successful in 3m10s
CI / test (3.13) (push) Successful in 4m16s
CI / test (3.14) (push) Successful in 3m26s
CI / precommit (pull_request) Successful in 34s
CI / test (3.15) (push) Successful in 4m31s
CI / test (3.12) (pull_request) Successful in 3m8s
CI / test (3.13) (pull_request) Successful in 3m10s
CI / test (3.14) (pull_request) Successful in 4m12s
CI / test (3.15) (pull_request) Successful in 3m31s
CI / image (pull_request) Has been skipped
CI / image (push) Successful in 1m4s
72ebac2fb0
test: make the unban autocomplete order test deterministic
All checks were successful
CI / precommit (push) Successful in 33s
CI / test (3.12) (push) Successful in 3m32s
CI / test (3.13) (push) Successful in 4m46s
CI / test (3.14) (push) Successful in 3m41s
CI / precommit (pull_request) Successful in 37s
CI / test (3.15) (push) Successful in 4m52s
CI / test (3.12) (pull_request) Successful in 3m43s
CI / test (3.13) (pull_request) Successful in 4m52s
CI / test (3.14) (pull_request) Successful in 3m39s
CI / test (3.15) (pull_request) Successful in 5m4s
CI / image (pull_request) Has been skipped
CI / image (push) Successful in 1m14s
28dad81730
build(deps): pin the jarvis-core with erase_user, the alert limits and the new web actions
All checks were successful
CI / precommit (pull_request) Successful in 1m5s
CI / test (3.12) (pull_request) Successful in 3m59s
CI / test (3.13) (pull_request) Successful in 5m15s
CI / test (3.14) (pull_request) Successful in 4m1s
CI / test (3.15) (pull_request) Successful in 5m15s
CI / image (pull_request) Has been skipped
CI / precommit (push) Successful in 29s
CI / test (3.12) (push) Successful in 4m20s
CI / test (3.13) (push) Successful in 4m22s
CI / test (3.14) (push) Successful in 7m41s
CI / test (3.15) (push) Successful in 4m33s
CI / image (push) Successful in 1m41s
99706494b1
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jarvis/jarvis-bot!1
No description provided.