safety layer: hard daily budget, user quotas, spend report, forgetme + privacy

This commit is contained in:
Oleksandr Kozachuk
2026-07-13 13:21:45 +02:00
parent f6c3e7d8e5
commit 02c989946b
11 changed files with 465 additions and 6 deletions
+60
View File
@@ -19,6 +19,11 @@ from watchdog.observers import Observer
from .ai_responder import AIMessage
from .openai_responder import OpenAIResponder
DEFAULT_PRIVACY_NOTICE = (
"I keep recent channel messages and a short conversation summary to answer better. "
"Type !forgetme to remove your messages from my history. Questions: ask the staff."
)
class ConfigFileHandler(FileSystemEventHandler):
def __init__(self, on_modified):
@@ -125,6 +130,14 @@ class FjerkroaBot(commands.Bot):
if self.is_staff_channel(message.channel) and str(message.content).startswith("!bot"):
await self.handle_staff_command(message)
return
# user-rights commands work even while paused (SAF-08/09)
content = str(message.content).strip().lower()
if content.startswith("!forgetme"):
await self.forget_user(message)
return
if content.startswith("!privacy"):
await message.channel.send(self.config.get("privacy-notice", DEFAULT_PRIVACY_NOTICE), suppress_embeds=True)
return
if not self.replies_allowed():
return
if str(message.content).startswith("!wichtel"):
@@ -132,6 +145,24 @@ class FjerkroaBot(commands.Bot):
return
await self.handle_message_through_responder(message)
async def forget_user(self, message: Message) -> None:
"""Purge the requesting user's messages everywhere (SAF-08)."""
user = message.author.name
removed = 0
for responder in [self.airesponder, *self.aichannels.values()]:
before = len(responder.history)
responder.history = [item for item in responder.history if f'"user": "{user}"' not in str(item.get("content", ""))]
removed += before - len(responder.history)
await responder._persist_history()
if self.airesponder.store is not None:
removed += self.airesponder.store.delete_history_of_user(user)
logging.info(f"forgetme: removed {removed} history entries for {user}")
await message.channel.send(
f"Removed your messages from my history ({removed} entries). "
"Note: channel memory summaries may still hold traces until the structured-memory upgrade.",
suppress_embeds=True,
)
def is_staff_channel(self, channel) -> bool:
staff = getattr(self, "staff_channel", None)
return staff is not None and getattr(channel, "id", None) == getattr(staff, "id", None)
@@ -166,6 +197,11 @@ class FjerkroaBot(commands.Bot):
elif args[:1] == ["status"]:
quiet_left = max(0, int(self.quiet_until - time.monotonic()))
reply = f"replies={self.replies_enabled} images={self.images_enabled} tasks={self.tasks_enabled} quiet_left={quiet_left}s"
elif args[:1] == ["spend"]:
ledger = self.airesponder.ledger
tokens_in, tokens_out = ledger.tokens_today()
budget = self.config.get("daily-budget-usd", "none")
reply = f"spend today: ${ledger.spent_usd():.2f} (tokens {tokens_in}/{tokens_out}, images {ledger.images_today()}), budget: {budget}"
logging.info(f"staff command {args}: {reply}")
await message.channel.send(reply, suppress_embeds=True)
@@ -179,6 +215,12 @@ class FjerkroaBot(commands.Bot):
allowed += list(self.config.get("additional-responders", []))
return channel_name in [name for name in allowed if name]
async def _budget_alert_once(self) -> None:
today = time.strftime("%Y-%m-%d")
if getattr(self, "_budget_alert_day", None) != today:
self._budget_alert_day = today
await self.send_staff_alert("Daily budget exhausted - bot stays silent until midnight (SAF-04).")
async def send_staff_alert(self, text: str) -> None:
"""Rate-limited, never silently dropped (OPS-07/08)."""
if self.staff_channel is None:
@@ -366,6 +408,13 @@ class FjerkroaBot(commands.Bot):
if response.picture is not None and not self.images_enabled:
logging.info("image generation disabled by operator - sending text only")
response.picture = None
# Per-user daily image quota (SAF-07)
if response.picture is not None and message.user != "system" and "user-daily-images" in self.config:
if self.airesponder.ledger.user_images(message.user) >= int(self.config["user-daily-images"]):
logging.warning(f"user {message.user} over daily image quota - stripping picture")
response.picture = None
else:
self.airesponder.ledger.count_user_image(message.user)
async def respond(
self,
@@ -385,6 +434,17 @@ class FjerkroaBot(commands.Bot):
# In case the message shouldn't be ignored, log the handling action
self.log_message_action("handle", message, channel_name)
# Hard daily budget, fail-closed; staff hears once per day (SAF-04)
if not self.airesponder.ledger.budget_ok():
await self._budget_alert_once()
return
# Per-user daily message quota; system (bot-initiated) exempt (SAF-06)
if message.user != "system" and "user-daily-messages" in self.config:
if self.airesponder.ledger.count_user_message(message.user) > int(self.config["user-daily-messages"]):
logging.warning(f"user {message.user} over daily message quota - ignoring")
return
# Get the AI responder based on the channel name
airesponder = self.get_ai_responder(channel_name)
+20
View File
@@ -10,6 +10,7 @@ import openai
from .ai_responder import AIResponder, exponential_backoff, pp, sanitize_external_text
from .igdblib import IGDBQuery
from .leonardo_draw import LeonardoAIDrawMixIn
from .quota import QuotaLedger
# The response envelope, enforced server-side via structured outputs
# (ENV-19). All fields required, closed object, nullable where the
@@ -48,6 +49,8 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
self.client = openai.AsyncOpenAI(api_key=self.config.get("openai-token", self.config.get("openai-key", "")))
# After a rate limit the next attempt runs on retry-model (ENV-15 / D2)
self._use_retry_model = False
# Daily usage metering + hard budget, fail-closed (SAF-04/05)
self.ledger = QuotaLedger(self.store, lambda: self.config)
# Initialize IGDB if enabled
self.igdb = None
@@ -69,21 +72,36 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
logging.warning("❌ IGDB integration DISABLED - missing configuration or disabled in config")
async def draw_openai(self, description: str) -> BytesIO:
if not self.ledger.budget_ok():
raise RuntimeError("daily budget exhausted - refusing image call")
for _ in range(3):
try:
response = await openai_image(self.client, prompt=description, n=1, size="1024x1024", model="dall-e-3")
self.ledger.add_images(1)
logging.info(f"Drawed a picture with DALL-E on this description: {repr(description)}")
return response
except Exception as err:
logging.warning(f"Failed to generate image {repr(description)}: {repr(err)}")
raise RuntimeError(f"Failed to generate image {repr(description)} after multiple retries")
def _record_usage(self, result: Any) -> None:
usage = getattr(result, "usage", None)
prompt_tokens = getattr(usage, "prompt_tokens", None)
completion_tokens = getattr(usage, "completion_tokens", None)
if isinstance(prompt_tokens, int) and isinstance(completion_tokens, int):
self.ledger.add_tokens(prompt_tokens, completion_tokens)
async def chat(self, messages: List[Dict[str, Any]], limit: int) -> Tuple[Optional[Dict[str, Any]], int]:
# Safety check for mock objects in tests
if not isinstance(messages, list) or len(messages) == 0:
logging.warning("Invalid messages format in chat method")
return None, limit
# Hard daily budget, fail-closed (SAF-04)
if not self.ledger.budget_ok():
logging.error("daily budget exhausted - refusing model call")
return None, limit
try:
# Clean up any orphaned tool messages from previous conversations
clean_messages = []
@@ -134,6 +152,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
)
result = await openai_chat(self.client, **chat_kwargs)
self._record_usage(result)
# Handle function calls if present
message = result.choices[0].message
@@ -204,6 +223,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
logging.debug(f"🔧 Last few messages: {messages[-3:] if len(messages) > 3 else messages}")
final_result = await openai_chat(self.client, **final_chat_kwargs)
self._record_usage(final_result)
answer_obj = final_result.choices[0].message
logging.debug(
+27 -2
View File
@@ -13,7 +13,7 @@ from contextlib import closing
from pathlib import Path
from typing import Any, Dict, List, Optional
SCHEMA_VERSION = 1
SCHEMA_VERSION = 2
class PersistentStore:
@@ -27,14 +27,21 @@ class PersistentStore:
return conn
def _init_db(self) -> None:
# Forward-only migrations keyed on user_version (PER-06)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
with closing(self._connect()) as conn, conn:
if conn.execute("PRAGMA user_version").fetchone()[0] < SCHEMA_VERSION:
version = conn.execute("PRAGMA user_version").fetchone()[0]
if version < 1:
conn.execute(
"CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, channel TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL)"
)
conn.execute("CREATE INDEX IF NOT EXISTS history_channel ON history (channel)")
conn.execute("CREATE TABLE IF NOT EXISTS memory (channel TEXT PRIMARY KEY, content TEXT NOT NULL)")
if version < 2:
conn.execute(
"CREATE TABLE IF NOT EXISTS usage (day TEXT NOT NULL, key TEXT NOT NULL, value REAL NOT NULL, PRIMARY KEY (day, key))"
)
if version < SCHEMA_VERSION:
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
os.chmod(self.db_path, 0o600) # conversation data (PER-04)
@@ -65,6 +72,24 @@ class PersistentStore:
(channel, content),
)
def usage_add(self, day: str, key: str, amount: float) -> None:
with closing(self._connect()) as conn, conn:
conn.execute(
"INSERT INTO usage (day, key, value) VALUES (?, ?, ?) ON CONFLICT(day, key) DO UPDATE SET value = value + excluded.value",
(day, key, amount),
)
def usage_get(self, day: str, key: str) -> float:
with closing(self._connect()) as conn:
row = conn.execute("SELECT value FROM usage WHERE day = ? AND key = ?", (day, key)).fetchone()
return float(row[0]) if row else 0.0
def delete_history_of_user(self, user: str) -> int:
"""Remove persisted rows carrying this user's messages (SAF-08)."""
with closing(self._connect()) as conn, conn:
cursor = conn.execute("DELETE FROM history WHERE content LIKE ?", (f'%"user": "{user}"%',))
return cursor.rowcount
def migrate_pickles(self, channel: str, history_file: Path, memory_file: Path) -> None:
"""Import legacy pickles once; rename them *.migrated (PER-03)."""
if self.load_history(channel) or self.load_memory(channel) is not None:
+77
View File
@@ -0,0 +1,77 @@
"""Daily usage metering + hard budget (SPEC-003, SAF-04..07).
The ledger estimates spend from configured prices and answers the one
question that matters fail-closed: may the bot still call the API
today? Counters live in the store's usage table when a store exists
(SAF-05), else in memory (degraded but safe).
"""
import time
from typing import Callable, Dict, Optional, Tuple
from .persistence import PersistentStore
DEFAULT_PRICE_INPUT_PER_M = 1.0
DEFAULT_PRICE_OUTPUT_PER_M = 6.0
DEFAULT_PRICE_PER_IMAGE = 0.05
class QuotaLedger:
def __init__(self, store: Optional[PersistentStore], config_getter: Callable[[], Dict]) -> None:
self.store = store
self._config = config_getter
self._memory: Dict[Tuple[str, str], float] = {}
@staticmethod
def _day() -> str:
return time.strftime("%Y-%m-%d")
def _add(self, key: str, amount: float) -> None:
if self.store is not None:
self.store.usage_add(self._day(), key, amount)
else:
slot = (self._day(), key)
self._memory[slot] = self._memory.get(slot, 0.0) + amount
def _get(self, key: str) -> float:
if self.store is not None:
return self.store.usage_get(self._day(), key)
return self._memory.get((self._day(), key), 0.0)
def add_tokens(self, prompt_tokens: int, completion_tokens: int) -> None:
self._add("tokens-in", prompt_tokens)
self._add("tokens-out", completion_tokens)
def add_images(self, count: int = 1) -> None:
self._add("images", count)
def tokens_today(self) -> Tuple[int, int]:
return int(self._get("tokens-in")), int(self._get("tokens-out"))
def images_today(self) -> int:
return int(self._get("images"))
def count_user_message(self, user: str) -> int:
self._add(f"msg-user:{user}", 1)
return int(self._get(f"msg-user:{user}"))
def count_user_image(self, user: str) -> int:
self._add(f"img-user:{user}", 1)
return int(self._get(f"img-user:{user}"))
def user_images(self, user: str) -> int:
return int(self._get(f"img-user:{user}"))
def spent_usd(self) -> float:
config = self._config()
tokens_in, tokens_out = self.tokens_today()
price_in = float(config.get("price-input-per-m", DEFAULT_PRICE_INPUT_PER_M))
price_out = float(config.get("price-output-per-m", DEFAULT_PRICE_OUTPUT_PER_M))
price_image = float(config.get("price-per-image", DEFAULT_PRICE_PER_IMAGE))
return (tokens_in * price_in + tokens_out * price_out) / 1_000_000.0 + self.images_today() * price_image
def budget_ok(self) -> bool:
config = self._config()
if "daily-budget-usd" not in config:
return True
return self.spent_usd() < float(config["daily-budget-usd"])