safety layer: hard daily budget, user quotas, spend report, forgetme + privacy
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user