0.4.1.0.a4
- Correction to earlier patch. *Testing on live server is a pain, but I'm too lazy to fire up a dev server..*
This commit is contained in:
parent
730d479e2d
commit
73175bbecd
2
bot.py
2
bot.py
@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice
|
|||||||
|
|
||||||
# Version consists of:
|
# Version consists of:
|
||||||
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update)
|
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update)
|
||||||
VERSION = "0.4.1.0.a3"
|
VERSION = "0.4.1.0.a4"
|
||||||
|
|
||||||
# ---------- Env loading ----------
|
# ---------- Env loading ----------
|
||||||
|
|
||||||
|
105
data_manager.py
105
data_manager.py
@ -3,6 +3,7 @@ import json
|
|||||||
import threading
|
import threading
|
||||||
import shutil
|
import shutil
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from typing import Callable, Any
|
from typing import Callable, Any
|
||||||
|
|
||||||
class DataManager:
|
class DataManager:
|
||||||
@ -11,32 +12,51 @@ class DataManager:
|
|||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self._data = self._load()
|
self._data = self._load()
|
||||||
|
|
||||||
|
def _default_payload(self):
|
||||||
|
return {
|
||||||
|
'agreed_rules': [],
|
||||||
|
'agreed_engagement': [],
|
||||||
|
'agreed_nickname': [],
|
||||||
|
'nick_same_confirmed': [],
|
||||||
|
'nick_nudged': [],
|
||||||
|
'nick_dm_map': [],
|
||||||
|
'pirates': [],
|
||||||
|
'modlog': [],
|
||||||
|
'reports': [],
|
||||||
|
'encounters': [],
|
||||||
|
'vc_channels': [],
|
||||||
|
'user_cards': [],
|
||||||
|
'pirates_list_posts': [],
|
||||||
|
'spicepay_prefs': [],
|
||||||
|
'nick_verified': [],
|
||||||
|
'nick_claim_pending': [],
|
||||||
|
'nick_reviews': [],
|
||||||
|
'rr_msg_channels': [],
|
||||||
|
'_counters': {}, # key -> int (metrics)
|
||||||
|
'_events_seen': {}, # stamp-key -> unix_ts (dedup window)
|
||||||
|
}
|
||||||
|
|
||||||
def _load(self):
|
def _load(self):
|
||||||
try:
|
try:
|
||||||
with open(self.json_path, 'r', encoding='utf-8') as f:
|
with open(self.json_path, 'r', encoding='utf-8') as f:
|
||||||
return json.load(f)
|
data = json.load(f)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("root is not an object")
|
||||||
|
data.setdefault('_counters', {})
|
||||||
|
data.setdefault('_events_seen', {})
|
||||||
|
return data
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
default = {
|
default = self._default_payload()
|
||||||
'agreed_rules': [],
|
self._save(default)
|
||||||
'agreed_engagement': [],
|
return default
|
||||||
'agreed_nickname': [],
|
except Exception:
|
||||||
'nick_same_confirmed': [],
|
# Backup the broken file if it exists, then start fresh
|
||||||
'nick_nudged': [],
|
try:
|
||||||
'nick_dm_map': [],
|
if os.path.exists(self.json_path):
|
||||||
'pirates': [],
|
shutil.copy2(self.json_path, self.json_path + ".corrupt.bak")
|
||||||
'modlog': [],
|
except Exception:
|
||||||
'reports': [],
|
pass
|
||||||
'encounters': [],
|
default = self._default_payload()
|
||||||
'vc_channels': [],
|
|
||||||
'user_cards': [],
|
|
||||||
'pirates_list_posts': [],
|
|
||||||
'spicepay_prefs': [],
|
|
||||||
'nick_verified': [],
|
|
||||||
'nick_claim_pending': [],
|
|
||||||
'nick_reviews': [],
|
|
||||||
'rr_msg_channels': [],
|
|
||||||
'_counters': {},
|
|
||||||
}
|
|
||||||
self._save(default)
|
self._save(default)
|
||||||
return default
|
return default
|
||||||
|
|
||||||
@ -44,7 +64,7 @@ class DataManager:
|
|||||||
os.makedirs(os.path.dirname(self.json_path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(self.json_path) or ".", exist_ok=True)
|
||||||
tmp = self.json_path + ".tmp"
|
tmp = self.json_path + ".tmp"
|
||||||
with open(tmp, 'w', encoding='utf-8') as f:
|
with open(tmp, 'w', encoding='utf-8') as f:
|
||||||
json.dump(data, f, indent=4)
|
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||||
if os.path.exists(self.json_path):
|
if os.path.exists(self.json_path):
|
||||||
try:
|
try:
|
||||||
shutil.copy2(self.json_path, self.json_path + ".bak")
|
shutil.copy2(self.json_path, self.json_path + ".bak")
|
||||||
@ -55,7 +75,7 @@ class DataManager:
|
|||||||
def _save(self, data: dict):
|
def _save(self, data: dict):
|
||||||
self._safe_write(data)
|
self._safe_write(data)
|
||||||
|
|
||||||
# ------------- existing list helpers -------------
|
# ------------- list helpers -------------
|
||||||
def get(self, category: str):
|
def get(self, category: str):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return list(self._data.get(category, []))
|
return list(self._data.get(category, []))
|
||||||
@ -72,7 +92,6 @@ class DataManager:
|
|||||||
self._save(self._data)
|
self._save(self._data)
|
||||||
|
|
||||||
def update(self, category: str, predicate: Callable[[Any], bool], updater: Callable[[dict], dict]) -> bool:
|
def update(self, category: str, predicate: Callable[[Any], bool], updater: Callable[[dict], dict]) -> bool:
|
||||||
"""Atomically find one item in `category` and update it with `updater`."""
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
arr = self._data.get(category, [])
|
arr = self._data.get(category, [])
|
||||||
for idx, item in enumerate(arr):
|
for idx, item in enumerate(arr):
|
||||||
@ -85,7 +104,7 @@ class DataManager:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# ------------- NEW: tiny key→counter helpers -------------
|
# ------------- counters -------------
|
||||||
def incr_counter(self, key: str, by: int = 1) -> int:
|
def incr_counter(self, key: str, by: int = 1) -> int:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
c = self._data.setdefault('_counters', {})
|
c = self._data.setdefault('_counters', {})
|
||||||
@ -101,3 +120,37 @@ class DataManager:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
c = dict(self._data.get('_counters', {}))
|
c = dict(self._data.get('_counters', {}))
|
||||||
return {k: v for k, v in c.items() if (not prefix or k.startswith(prefix))}
|
return {k: v for k, v in c.items() if (not prefix or k.startswith(prefix))}
|
||||||
|
|
||||||
|
# ------------- NEW: once-per-event increment with TTL dedup -------------
|
||||||
|
def incr_counter_once(self, counter_key: str, event_key: str, window_sec: float = 3.0, namespace: str = "cmd") -> int | None:
|
||||||
|
"""
|
||||||
|
Atomically: if `event_key` wasn't seen within `window_sec`, mark it seen and increment `counter_key`.
|
||||||
|
Returns new counter value, or None if this event is a duplicate.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
stamp = f"{namespace}:{event_key}"
|
||||||
|
with self.lock:
|
||||||
|
seen = self._data.setdefault('_events_seen', {})
|
||||||
|
last = float(seen.get(stamp, 0.0))
|
||||||
|
if now - last <= float(window_sec):
|
||||||
|
# duplicate; ignore
|
||||||
|
return None
|
||||||
|
|
||||||
|
# mark seen
|
||||||
|
seen[stamp] = now
|
||||||
|
|
||||||
|
# opportunistic pruning to keep file small
|
||||||
|
if len(seen) > 5000:
|
||||||
|
cutoff = now - (window_sec * 2)
|
||||||
|
for k in list(seen.keys()):
|
||||||
|
try:
|
||||||
|
if float(seen.get(k, 0.0)) < cutoff:
|
||||||
|
seen.pop(k, None)
|
||||||
|
except Exception:
|
||||||
|
seen.pop(k, None)
|
||||||
|
|
||||||
|
# increment
|
||||||
|
counters = self._data.setdefault('_counters', {})
|
||||||
|
counters[counter_key] = int(counters.get(counter_key, 0)) + 1
|
||||||
|
self._save(self._data)
|
||||||
|
return counters[counter_key]
|
||||||
|
@ -2,81 +2,50 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
import discord
|
import discord
|
||||||
from collections import OrderedDict
|
|
||||||
|
|
||||||
COUNTER_KEY_PREFIX = "cmd::"
|
COUNTER_KEY_PREFIX = "cmd::"
|
||||||
|
|
||||||
def _key_from_app(cmd: discord.app_commands.Command) -> str:
|
def _key_from_app(cmd: discord.app_commands.Command) -> str:
|
||||||
# app command qualified_name is "group sub" or "name"
|
|
||||||
name = getattr(cmd, "qualified_name", None) or getattr(cmd, "name", "unknown")
|
name = getattr(cmd, "qualified_name", None) or getattr(cmd, "name", "unknown")
|
||||||
return f"{COUNTER_KEY_PREFIX}{name}"
|
return f"{COUNTER_KEY_PREFIX}{name}"
|
||||||
|
|
||||||
def _key_from_ctx(ctx: commands.Context) -> str:
|
def _key_from_ctx(ctx: commands.Context) -> str:
|
||||||
# prefix/hybrid qualified_name is "group sub" or "name"
|
|
||||||
c = getattr(ctx, "command", None)
|
c = getattr(ctx, "command", None)
|
||||||
name = getattr(c, "qualified_name", None) or getattr(c, "name", "unknown")
|
name = getattr(c, "qualified_name", None) or getattr(c, "name", "unknown")
|
||||||
return f"{COUNTER_KEY_PREFIX}{name}"
|
return f"{COUNTER_KEY_PREFIX}{name}"
|
||||||
|
|
||||||
class UsageStatsCog(commands.Cog):
|
class UsageStatsCog(commands.Cog):
|
||||||
"""Lightweight command run counters with de-dup + hybrid guards."""
|
"""Lightweight command run counters with storage-level dedup."""
|
||||||
|
|
||||||
def __init__(self, bot: commands.Bot):
|
def __init__(self, bot: commands.Bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
# Simple LRU caches for seen events (avoid double/quad increments)
|
|
||||||
self._seen_interactions: OrderedDict[int, int] = OrderedDict() # slash
|
|
||||||
self._seen_messages: OrderedDict[int, int] = OrderedDict() # prefix/hybrid
|
|
||||||
print("[usage] UsageStatsCog init")
|
print("[usage] UsageStatsCog init")
|
||||||
|
|
||||||
# ----- small LRU helpers -----
|
# ---- successful completions ----
|
||||||
@staticmethod
|
|
||||||
def _lru_mark_seen(lru: OrderedDict, key, cap: int) -> bool:
|
|
||||||
"""Return True if already seen; otherwise record and trim to cap."""
|
|
||||||
if key is None:
|
|
||||||
return False
|
|
||||||
if key in lru:
|
|
||||||
# refresh order
|
|
||||||
lru.move_to_end(key, last=True)
|
|
||||||
return True
|
|
||||||
lru[key] = 1
|
|
||||||
# trim if needed
|
|
||||||
while len(lru) > cap:
|
|
||||||
lru.popitem(last=False)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ----- successful completions -----
|
|
||||||
|
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_app_command_completion(self, interaction: discord.Interaction, command: discord.app_commands.Command):
|
async def on_app_command_completion(self, interaction: discord.Interaction, command: discord.app_commands.Command):
|
||||||
# De-dup by interaction id
|
|
||||||
iid = getattr(interaction, "id", None)
|
|
||||||
if self._lru_mark_seen(self._seen_interactions, iid, cap=2048):
|
|
||||||
print(f"[usage] app ~~ dup ignored (iid={iid})")
|
|
||||||
return
|
|
||||||
|
|
||||||
dm = getattr(self.bot, "data_manager", None)
|
dm = getattr(self.bot, "data_manager", None)
|
||||||
if not dm:
|
if not dm:
|
||||||
print("[usage] app ~~ skip (no data_manager)")
|
print("[usage] app ~~ skip (no data_manager)")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
key = _key_from_app(command)
|
# Use the interaction id as the dedup event key
|
||||||
newv = dm.incr_counter(key, 1)
|
event_key = str(getattr(interaction, "id", None) or "no-iid")
|
||||||
print(f"[usage] app ++ {key} -> {newv}")
|
counter_key = _key_from_app(command)
|
||||||
|
newv = dm.incr_counter_once(counter_key=counter_key, event_key=event_key, window_sec=3.0, namespace="app")
|
||||||
|
if newv is not None:
|
||||||
|
print(f"[usage] app ++ {counter_key} -> {newv}")
|
||||||
|
else:
|
||||||
|
print(f"[usage] app ~~ dup ignored ({event_key})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("[usage] app !! incr failed:", repr(e))
|
print("[usage] app !! incr failed:", repr(e))
|
||||||
|
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_command_completion(self, ctx: commands.Context):
|
async def on_command_completion(self, ctx: commands.Context):
|
||||||
# If a HybridCommand was invoked via slash, some setups can still fire this.
|
# If a HybridCommand was invoked via slash, skip here (the app listener handled it).
|
||||||
# Guard: only count here when NOT an interaction-backed invoke.
|
|
||||||
if isinstance(getattr(ctx, "command", None), commands.HybridCommand) and getattr(ctx, "interaction", None):
|
if isinstance(getattr(ctx, "command", None), commands.HybridCommand) and getattr(ctx, "interaction", None):
|
||||||
print("[usage] px ~~ hybrid-as-slash; ignore here (app listener handled it)")
|
print("[usage] px ~~ hybrid-as-slash; ignore here")
|
||||||
return
|
|
||||||
|
|
||||||
# De-dup by message id
|
|
||||||
msg = getattr(ctx, "message", None)
|
|
||||||
mid = getattr(msg, "id", None)
|
|
||||||
if self._lru_mark_seen(self._seen_messages, mid, cap=4096):
|
|
||||||
print(f"[usage] px ~~ dup ignored (mid={mid})")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
dm = getattr(self.bot, "data_manager", None)
|
dm = getattr(self.bot, "data_manager", None)
|
||||||
@ -84,17 +53,26 @@ class UsageStatsCog(commands.Cog):
|
|||||||
print("[usage] px ~~ skip (no data_manager)")
|
print("[usage] px ~~ skip (no data_manager)")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
key = _key_from_ctx(ctx)
|
# Prefer interaction id when present (hybrid-as-slash defense), else message id
|
||||||
newv = dm.incr_counter(key, 1)
|
event_key = None
|
||||||
print(f"[usage] px ++ {key} -> {newv}")
|
if getattr(ctx, "interaction", None):
|
||||||
|
event_key = str(getattr(ctx.interaction, "id", None) or "")
|
||||||
|
if not event_key:
|
||||||
|
msg = getattr(ctx, "message", None)
|
||||||
|
event_key = str(getattr(msg, "id", None) or "no-mid")
|
||||||
|
|
||||||
|
counter_key = _key_from_ctx(ctx)
|
||||||
|
newv = dm.incr_counter_once(counter_key=counter_key, event_key=event_key, window_sec=3.0, namespace="px")
|
||||||
|
if newv is not None:
|
||||||
|
print(f"[usage] px ++ {counter_key} -> {newv}")
|
||||||
|
else:
|
||||||
|
print(f"[usage] px ~~ dup ignored ({event_key})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("[usage] px !! incr failed:", repr(e))
|
print("[usage] px !! incr failed:", repr(e))
|
||||||
|
|
||||||
# ----- attempts that error (optional, keep while validating) -----
|
# ---- error visibility (optional) ----
|
||||||
|
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_app_command_error(self, interaction: discord.Interaction, error: Exception):
|
async def on_app_command_error(self, interaction: discord.Interaction, error: Exception):
|
||||||
# We won't increment exec counters on error; this is only for debug visibility.
|
|
||||||
iid = getattr(interaction, "id", None)
|
iid = getattr(interaction, "id", None)
|
||||||
print(f"[usage] app ** error ({type(error).__name__}) iid={iid}")
|
print(f"[usage] app ** error ({type(error).__name__}) iid={iid}")
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user