fix: persist last_offense_time and reset offenses after 24h

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 11:24:38 -05:00
parent 71c7b45e9a
commit 8734f1883b
4 changed files with 27 additions and 9 deletions
+2
View File
@@ -913,6 +913,7 @@ class SentimentCog(commands.Cog):
baseline_coherence=user_data.baseline_coherence, baseline_coherence=user_data.baseline_coherence,
user_notes=user_data.notes or None, user_notes=user_data.notes or None,
warned=user_data.warned_since_reset, warned=user_data.warned_since_reset,
last_offense_at=user_data.last_offense_time or None,
)) ))
self._dirty_users.discard(user_id) self._dirty_users.discard(user_id)
@@ -940,6 +941,7 @@ class SentimentCog(commands.Cog):
baseline_coherence=user_data.baseline_coherence, baseline_coherence=user_data.baseline_coherence,
user_notes=user_data.notes or None, user_notes=user_data.notes or None,
warned=user_data.warned_since_reset, 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)) logger.info("Flushed %d dirty user states to DB.", len(dirty))
+1 -1
View File
@@ -41,7 +41,7 @@ mention_scan:
timeouts: timeouts:
escalation_minutes: [30, 60, 120, 240] # Escalating timeout durations 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 warning_cooldown_minutes: 5 # Don't warn same user more than once per this window
messages: messages:
+17 -8
View File
@@ -132,6 +132,12 @@ class Database:
ALTER TABLE UserState ADD Warned BIT NOT NULL DEFAULT 0 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(""" cursor.execute("""
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'BotSettings') IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'BotSettings')
CREATE TABLE BotSettings ( CREATE TABLE BotSettings (
@@ -291,19 +297,20 @@ class Database:
baseline_coherence: float = 0.85, baseline_coherence: float = 0.85,
user_notes: str | None = None, user_notes: str | None = None,
warned: bool = False, warned: bool = False,
last_offense_at: float | None = 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: if not self._available:
return return
try: try:
await asyncio.to_thread( await asyncio.to_thread(
self._save_user_state_sync, 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: except Exception:
logger.exception("Failed to save user state") 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() conn = self._connect()
try: try:
cursor = conn.cursor() cursor = conn.cursor()
@@ -314,13 +321,14 @@ class Database:
WHEN MATCHED THEN WHEN MATCHED THEN
UPDATE SET OffenseCount = ?, Immune = ?, OffTopicCount = ?, UPDATE SET OffenseCount = ?, Immune = ?, OffTopicCount = ?,
BaselineCoherence = ?, UserNotes = ?, Warned = ?, BaselineCoherence = ?, UserNotes = ?, Warned = ?,
LastOffenseAt = ?,
UpdatedAt = SYSUTCDATETIME() UpdatedAt = SYSUTCDATETIME()
WHEN NOT MATCHED THEN WHEN NOT MATCHED THEN
INSERT (UserId, OffenseCount, Immune, OffTopicCount, BaselineCoherence, UserNotes, Warned) INSERT (UserId, OffenseCount, Immune, OffTopicCount, BaselineCoherence, UserNotes, Warned, LastOffenseAt)
VALUES (?, ?, ?, ?, ?, ?, ?);""", VALUES (?, ?, ?, ?, ?, ?, ?, ?);""",
user_id, 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, 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() cursor.close()
finally: finally:
@@ -363,7 +371,7 @@ class Database:
try: try:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( 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() rows = cursor.fetchall()
cursor.close() cursor.close()
@@ -376,6 +384,7 @@ class Database:
"baseline_coherence": float(row[4]), "baseline_coherence": float(row[4]),
"user_notes": row[5] or "", "user_notes": row[5] or "",
"warned": bool(row[6]), "warned": bool(row[6]),
"last_offense_at": float(row[7]) if row[7] is not None else 0.0,
} }
for row in rows for row in rows
] ]
+7
View File
@@ -286,6 +286,13 @@ class DramaTracker:
user.notes = state["user_notes"] user.notes = state["user_notes"]
if state.get("warned"): if state.get("warned"):
user.warned_since_reset = True 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 count += 1
return count return count