From 8734f1883bc9aae9c06f619b0ab9858a73abad45 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 25 Feb 2026 11:24:38 -0500 Subject: [PATCH] fix: persist last_offense_time and reset offenses after 24h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit last_offense_time was in-memory only — lost on restart, so the offense_reset_minutes check never fired after a reboot. Now persisted as LastOffenseAt FLOAT in UserState. On startup hydration, stale offenses (and warned flag) are auto-cleared if the reset window has passed. Bumped offense_reset_minutes from 2h to 24h. Co-Authored-By: Claude Opus 4.6 --- cogs/sentiment.py | 2 ++ config.yaml | 2 +- utils/database.py | 25 +++++++++++++++++-------- utils/drama_tracker.py | 7 +++++++ 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/cogs/sentiment.py b/cogs/sentiment.py index 74b5b9a..2e90d2a 100644 --- a/cogs/sentiment.py +++ b/cogs/sentiment.py @@ -913,6 +913,7 @@ class SentimentCog(commands.Cog): baseline_coherence=user_data.baseline_coherence, user_notes=user_data.notes or None, warned=user_data.warned_since_reset, + last_offense_at=user_data.last_offense_time or None, )) self._dirty_users.discard(user_id) @@ -940,6 +941,7 @@ class SentimentCog(commands.Cog): baseline_coherence=user_data.baseline_coherence, user_notes=user_data.notes or None, warned=user_data.warned_since_reset, + last_offense_at=user_data.last_offense_time or None, ) logger.info("Flushed %d dirty user states to DB.", len(dirty)) diff --git a/config.yaml b/config.yaml index 7630e82..20d09cd 100644 --- a/config.yaml +++ b/config.yaml @@ -41,7 +41,7 @@ mention_scan: timeouts: escalation_minutes: [30, 60, 120, 240] # Escalating timeout durations - offense_reset_minutes: 120 # Reset offense counter after this much good behavior + offense_reset_minutes: 1440 # Reset offense counter after this much good behavior (24h) warning_cooldown_minutes: 5 # Don't warn same user more than once per this window messages: diff --git a/utils/database.py b/utils/database.py index 15a2350..0bf6214 100644 --- a/utils/database.py +++ b/utils/database.py @@ -132,6 +132,12 @@ class Database: ALTER TABLE UserState ADD Warned BIT NOT NULL DEFAULT 0 """) + # --- Schema migration for persisting last offense time --- + cursor.execute(""" + IF COL_LENGTH('UserState', 'LastOffenseAt') IS NULL + ALTER TABLE UserState ADD LastOffenseAt FLOAT NULL + """) + cursor.execute(""" IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'BotSettings') CREATE TABLE BotSettings ( @@ -291,19 +297,20 @@ class Database: baseline_coherence: float = 0.85, user_notes: str | None = None, warned: bool = False, + last_offense_at: float | None = None, ) -> None: - """Upsert user state (offense count, immunity, off-topic count, coherence baseline, notes, warned).""" + """Upsert user state (offense count, immunity, off-topic count, coherence baseline, notes, warned, last offense time).""" if not self._available: return try: await asyncio.to_thread( self._save_user_state_sync, - user_id, offense_count, immune, off_topic_count, baseline_coherence, user_notes, warned, + user_id, offense_count, immune, off_topic_count, baseline_coherence, user_notes, warned, last_offense_at, ) except Exception: logger.exception("Failed to save user state") - def _save_user_state_sync(self, user_id, offense_count, immune, off_topic_count, baseline_coherence, user_notes, warned): + def _save_user_state_sync(self, user_id, offense_count, immune, off_topic_count, baseline_coherence, user_notes, warned, last_offense_at): conn = self._connect() try: cursor = conn.cursor() @@ -314,13 +321,14 @@ class Database: WHEN MATCHED THEN UPDATE SET OffenseCount = ?, Immune = ?, OffTopicCount = ?, BaselineCoherence = ?, UserNotes = ?, Warned = ?, + LastOffenseAt = ?, UpdatedAt = SYSUTCDATETIME() WHEN NOT MATCHED THEN - INSERT (UserId, OffenseCount, Immune, OffTopicCount, BaselineCoherence, UserNotes, Warned) - VALUES (?, ?, ?, ?, ?, ?, ?);""", + INSERT (UserId, OffenseCount, Immune, OffTopicCount, BaselineCoherence, UserNotes, Warned, LastOffenseAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?);""", user_id, - offense_count, 1 if immune else 0, off_topic_count, baseline_coherence, user_notes, 1 if warned else 0, - user_id, offense_count, 1 if immune else 0, off_topic_count, baseline_coherence, user_notes, 1 if warned else 0, + offense_count, 1 if immune else 0, off_topic_count, baseline_coherence, user_notes, 1 if warned else 0, last_offense_at, + user_id, offense_count, 1 if immune else 0, off_topic_count, baseline_coherence, user_notes, 1 if warned else 0, last_offense_at, ) cursor.close() finally: @@ -363,7 +371,7 @@ class Database: try: cursor = conn.cursor() cursor.execute( - "SELECT UserId, OffenseCount, Immune, OffTopicCount, BaselineCoherence, UserNotes, Warned FROM UserState" + "SELECT UserId, OffenseCount, Immune, OffTopicCount, BaselineCoherence, UserNotes, Warned, LastOffenseAt FROM UserState" ) rows = cursor.fetchall() cursor.close() @@ -376,6 +384,7 @@ class Database: "baseline_coherence": float(row[4]), "user_notes": row[5] or "", "warned": bool(row[6]), + "last_offense_at": float(row[7]) if row[7] is not None else 0.0, } for row in rows ] diff --git a/utils/drama_tracker.py b/utils/drama_tracker.py index 490c4dd..e0c1bfc 100644 --- a/utils/drama_tracker.py +++ b/utils/drama_tracker.py @@ -286,6 +286,13 @@ class DramaTracker: user.notes = state["user_notes"] if state.get("warned"): user.warned_since_reset = True + if state.get("last_offense_at"): + user.last_offense_time = state["last_offense_at"] + # Apply time-based offense reset at load time + if time.time() - user.last_offense_time > self.offense_reset_seconds: + user.offense_count = 0 + user.warned_since_reset = False + user.last_offense_time = 0.0 count += 1 return count