fix defects D1-D12 batch 1, structured envelope, saf gates + ops kill-switches

This commit is contained in:
Oleksandr Kozachuk
2026-07-13 12:56:32 +02:00
parent f1578cbd99
commit 6e5abf3d2c
14 changed files with 770 additions and 214 deletions
+1
View File
@@ -23,3 +23,4 @@ last_updates.json
.pytest_cache/ .pytest_cache/
*.py,v *.py,v
*.msg *.msg
news_feed.py
+30 -40
View File
@@ -1,4 +1,7 @@
# Pre-commit hooks configuration for Fjerkroa Bot # Pre-commit hooks configuration for Fjerkroa Bot
#
# Formatter/linter/type-checker run from the uv-managed project env so
# hook versions == pyproject dev-dependency versions (no pin drift).
repos: repos:
# Built-in hooks # Built-in hooks
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
@@ -13,49 +16,36 @@ repos:
- id: check-case-conflict - id: check-case-conflict
- id: check-merge-conflict - id: check-merge-conflict
- id: debug-statements - id: debug-statements
- id: requirements-txt-fixer
# Black code formatter # Project-env tools (single version source: pyproject.toml)
- repo: https://github.com/psf/black
rev: 23.3.0
hooks:
- id: black
language_version: python3
args: [--line-length=140]
# isort import sorter
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
args: [--profile=black, --line-length=140]
# Flake8 linter
- repo: https://github.com/pycqa/flake8
rev: 6.0.0
hooks:
- id: flake8
args: [--max-line-length=140]
# Bandit security scanner - disabled due to expected pickle/random usage
# - repo: https://github.com/pycqa/bandit
# rev: 1.7.5
# hooks:
# - id: bandit
# args: [-r, fjerkroa_bot]
# exclude: tests/
# MyPy type checker
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.3.0
hooks:
- id: mypy
additional_dependencies: [types-toml, types-requests, types-setuptools]
args: [--config-file=pyproject.toml, --ignore-missing-imports]
# Local hooks using Makefile
- repo: local - repo: local
hooks: hooks:
- id: black
name: black
entry: uv run black
language: system
types: [python]
- id: isort
name: isort
entry: uv run isort
language: system
types: [python]
- id: flake8
name: flake8
entry: uv run flake8
language: system
types: [python]
- id: mypy
name: mypy
entry: uv run mypy fjerkroa_bot tests
language: system
pass_filenames: false
- id: trace
name: Spec coverage (trace)
entry: uv run python tools/trace.py
language: system
pass_filenames: false
always_run: true
- id: tests - id: tests
name: Run tests name: Run tests
entry: make test-fast entry: make test-fast
+11
View File
@@ -16,3 +16,14 @@ system = "You are a smart AI assistant with access to real-time video game infor
igdb-client-id = "YOUR_IGDB_CLIENT_ID" igdb-client-id = "YOUR_IGDB_CLIENT_ID"
igdb-access-token = "YOUR_IGDB_ACCESS_TOKEN" igdb-access-token = "YOUR_IGDB_ACCESS_TOKEN"
enable-game-info = true enable-game-info = true
# --- operator / safety (SPEC-003, SPEC-006) ---
# Model may route answers only to allowlisted channels; default = the
# channels named in this config (chat/staff/welcome/additional-responders).
# allowed-channels = ["chat", "staff"]
# Regexes that force a staff alert regardless of the model's judgement:
# staff-alert-keywords = ["(?i)hjelp|help|emergency"]
# Staff-alert rate limit per rolling hour (excess alerts are logged):
# staff-alert-max-per-hour = 10
# Staff commands (staff channel only): !bot pause | resume | images on|off
# | tasks on|off | quiet <minutes> | status
+72 -87
View File
@@ -1,3 +1,4 @@
import asyncio
import json import json
import logging import logging
import os import os
@@ -11,8 +12,6 @@ from pathlib import Path
from pprint import pformat from pprint import pformat
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
import multiline
def pp(*args, **kw): def pp(*args, **kw):
if "width" not in kw: if "width" not in kw:
@@ -22,14 +21,16 @@ def pp(*args, **kw):
@lru_cache(maxsize=300) @lru_cache(maxsize=300)
def parse_json(content: str) -> Dict: def parse_json(content: str) -> Dict:
content = content.strip() # Strict JSON only — model output is schema-enforced (ENV-18/19),
try: # history entries are json.dumps products.
return json.loads(content) return json.loads(content.strip())
except Exception:
try:
return multiline.loads(content, multiline=True) def sanitize_external_text(text: str, max_len: int = 4000) -> str:
except Exception as err: """Neutralize attacker-influenced text before it enters a prompt (SAF-03)."""
raise err text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
text = text.replace("@everyone", "@everyone").replace("@here", "@here")
return text[:max_len]
def exponential_backoff(base=2, max_delay=60, factor=1, jitter=0.1, max_attempts=None): def exponential_backoff(base=2, max_delay=60, factor=1, jitter=0.1, max_attempts=None):
@@ -84,30 +85,6 @@ def async_cache_to_file(filename):
return decorator return decorator
def parse_maybe_json(json_string):
if json_string is None:
return None
if isinstance(json_string, (list, dict)):
return " ".join(map(str, (json_string.values() if isinstance(json_string, dict) else json_string)))
json_string = str(json_string).strip()
try:
parsed_json = parse_json(json_string)
except Exception:
for b, e in [("{", "}"), ("[", "]")]:
if json_string.startswith(b) and json_string.endswith(e):
return parse_maybe_json(json_string[1:-1])
return json_string
if isinstance(parsed_json, str):
return parsed_json
if isinstance(parsed_json, (list, dict)):
return "\n".join(map(str, (parsed_json.values() if isinstance(parsed_json, dict) else parsed_json)))
return str(parsed_json)
def same_channel(item1: Dict[str, Any], item2: Dict[str, Any]) -> bool:
return parse_json(item1["content"]).get("channel") == parse_json(item2["content"]).get("channel")
class AIMessageBase(object): class AIMessageBase(object):
def __init__(self) -> None: def __init__(self) -> None:
self.vars: List[str] = [] self.vars: List[str] = []
@@ -182,7 +159,7 @@ class AIResponder(AIResponderBase):
if news_feed and os.path.exists(news_feed): if news_feed and os.path.exists(news_feed):
with open(news_feed) as fd: with open(news_feed) as fd:
news_feed = fd.read().strip() news_feed = fd.read().strip()
system = system.replace("{news}", news_feed) system = system.replace("{news}", sanitize_external_text(news_feed))
system = system.replace("{memory}", self.memory) system = system.replace("{memory}", self.memory)
messages.append({"role": "system", "content": system}) messages.append({"role": "system", "content": system})
if limit is not None: if limit is not None:
@@ -211,30 +188,26 @@ class AIResponder(AIResponderBase):
raise NotImplementedError() raise NotImplementedError()
async def post_process(self, message: AIMessage, response: Dict[str, Any]) -> AIResponse: async def post_process(self, message: AIMessage, response: Dict[str, Any]) -> AIResponse:
for fld in ("answer", "channel", "staff", "picture", "hack"): # Envelope arrives schema-validated (ENV-19); .get defaults keep old
if str(response.get(fld)).strip().lower() in ("none", "", "null", '"none"', '"null"', "'none'", "'null'"): # history entries and hand-built test dicts working.
response[fld] = None answer = response.get("answer")
for fld in ("answer_needed", "hack", "picture_edit"): answer_needed = bool(response.get("answer_needed", False))
if str(response.get(fld)).strip().lower() == "true": if answer is None:
response[fld] = True answer_needed = False
else:
response[fld] = False
if response["answer"] is None:
response["answer_needed"] = False
else: else:
response["answer"] = str(response["answer"]) answer = str(answer)
response["answer"] = re.sub(r"@\[([^\]]*)\]\([^\)]*\)", r"\1", response["answer"]) answer = re.sub(r"@\[([^\]]*)\]\([^\)]*\)", r"\1", answer)
response["answer"] = re.sub(r"\[[^\]]*\]\(([^\)]*)\)", r"\1", response["answer"]) answer = re.sub(r"\[[^\]]*\]\(([^\)]*)\)", r"\1", answer)
if message.direct or message.user in message.message: if message.direct or message.user in message.message:
response["answer_needed"] = True answer_needed = True
response_message = AIResponse( response_message = AIResponse(
response["answer"], answer,
response["answer_needed"], answer_needed,
parse_maybe_json(response["channel"]), response.get("channel"),
parse_maybe_json(response["staff"]), response.get("staff"),
parse_maybe_json(response["picture"]), response.get("picture"),
response["picture_edit"], bool(response.get("picture_edit", False)),
response["hack"], bool(response.get("hack", False)),
) )
if response_message.staff is not None and response_message.answer is not None: if response_message.staff is not None and response_message.answer is not None:
response_message.answer_needed = True response_message.answer_needed = True
@@ -261,25 +234,32 @@ class AIResponder(AIResponderBase):
async def chat(self, messages: List[Dict[str, Any]], limit: int) -> Tuple[Optional[Dict[str, Any]], int]: async def chat(self, messages: List[Dict[str, Any]], limit: int) -> Tuple[Optional[Dict[str, Any]], int]:
raise NotImplementedError() raise NotImplementedError()
async def fix(self, answer: str) -> str:
raise NotImplementedError()
async def memory_rewrite(self, memory: str, message_user: str, answer_user: str, question: str, answer: str) -> str: async def memory_rewrite(self, memory: str, message_user: str, answer_user: str, question: str, answer: str) -> str:
raise NotImplementedError() raise NotImplementedError()
async def translate(self, text: str, language: str = "english") -> str: async def translate(self, text: str, language: str = "english") -> str:
raise NotImplementedError() raise NotImplementedError()
def shrink_history_by_one(self, index: int = 0) -> None: @staticmethod
if index >= len(self.history): def _entry_channel(item: Dict[str, Any]) -> Optional[str]:
del self.history[0] try:
else: return parse_json(item["content"]).get("channel")
current = self.history[index] except Exception:
count = sum(1 for item in self.history if same_channel(item, current)) return None
if count > self.config.get("history-per-channel", 3):
def shrink_history_by_one(self) -> None:
if not self.history:
return
cap = self.config.get("history-per-channel", 3)
counts: Dict[Optional[str], int] = {}
for item in self.history:
chan = self._entry_channel(item)
counts[chan] = counts.get(chan, 0) + 1
for index, item in enumerate(self.history):
if counts[self._entry_channel(item)] > cap:
del self.history[index] del self.history[index]
else: return
self.shrink_history_by_one(index + 1) del self.history[0]
def update_history(self, question: Dict[str, Any], answer: Dict[str, Any], limit: int, historise_question: bool = True) -> None: def update_history(self, question: Dict[str, Any], answer: Dict[str, Any], limit: int, historise_question: bool = True) -> None:
if not isinstance(question["content"], str): if not isinstance(question["content"], str):
@@ -294,6 +274,7 @@ class AIResponder(AIResponderBase):
pickle.dump(self.history, fd) pickle.dump(self.history, fd)
def update_memory(self, memory) -> None: def update_memory(self, memory) -> None:
self.memory = memory
if self.memory_file is not None: if self.memory_file is not None:
with open(self.memory_file, "wb") as fd: with open(self.memory_file, "wb") as fd:
pickle.dump(self.memory, fd) pickle.dump(self.memory, fd)
@@ -306,6 +287,15 @@ class AIResponder(AIResponderBase):
response["picture"] = await self.translate(response["picture"]) response["picture"] = await self.translate(response["picture"])
return True return True
def _parse_answer(self, answer: Dict[str, Any]) -> Optional[Dict[str, Any]]:
# Schema-enforced output should always parse; anything else is a
# failed attempt — no repair model (ENV-18).
try:
return parse_json(answer["content"])
except Exception as err:
logging.error(f"failed to parse the answer: {pp(err)}\n{repr(answer['content'])}")
return None
async def memoize(self, message_user: str, answer_user: str, message: str, answer: str) -> None: async def memoize(self, message_user: str, answer_user: str, message: str, answer: str) -> None:
self.memory = await self.memory_rewrite(self.memory, message_user, answer_user, message, answer) self.memory = await self.memory_rewrite(self.memory, message_user, answer_user, message, answer)
self.update_memory(self.memory) self.update_memory(self.memory)
@@ -324,8 +314,16 @@ class AIResponder(AIResponderBase):
if self.short_path(message, limit): if self.short_path(message, limit):
return AIResponse(None, False, None, None, None, False, False) return AIResponse(None, False, None, None, None, False, False)
# Number of retries for sending the message # Number of retries for sending the message; failed attempts are
# spaced by exponential backoff (ENV-12 / D1)
retries = 3 retries = 3
backoff = exponential_backoff(max_delay=10)
async def failed_attempt() -> None:
nonlocal retries
retries -= 1
if retries > 0:
await asyncio.sleep(next(backoff))
while retries > 0: while retries > 0:
# Get the message queue # Get the message queue
@@ -336,26 +334,13 @@ class AIResponder(AIResponderBase):
answer, limit = await self.chat(messages, limit) answer, limit = await self.chat(messages, limit)
if answer is None: if answer is None:
retries -= 1 await failed_attempt()
continue continue
# Attempt to parse the AI's response # Attempt to parse the AI's response (strict — ENV-18)
try: response = self._parse_answer(answer)
response = parse_json(answer["content"]) if response is None or not await self.handle_picture(response):
except Exception as err: await failed_attempt()
logging.warning(f"failed to parse the answer: {pp(err)}\n{repr(answer['content'])}")
answer["content"] = await self.fix(answer["content"])
# Retry parsing the fixed content
try:
response = parse_json(answer["content"])
except Exception as err:
logging.error(f"failed to parse the fixed answer: {pp(err)}\n{repr(answer['content'])}")
retries -= 1
continue
if not await self.handle_picture(response):
retries -= 1
continue continue
# Post-process the message and update the answer's content # Post-process the message and update the answer's content
+121 -15
View File
@@ -6,6 +6,7 @@ import random
import re import re
import sys import sys
import time import time
from collections import deque
from typing import Optional, Union from typing import Optional, Union
import discord import discord
@@ -37,10 +38,19 @@ class FjerkroaBot(commands.Bot):
intents.reactions = True intents.reactions = True
self._re_user = re.compile(r"[<][@][!]?\s*([0-9]+)[>]") self._re_user = re.compile(r"[<][@][!]?\s*([0-9]+)[>]")
# Operator runtime flags (SPEC-006); in-memory only — restart
# resets to defaults (D-008)
self.replies_enabled = True
self.images_enabled = True
self.tasks_enabled = True
self.quiet_until = 0.0
self._staff_alert_times: deque = deque()
self.init_observer() self.init_observer()
self.init_aichannels() self.init_aichannels()
super().__init__(command_prefix="!", case_insensitive=True, intents=intents) # allowed_mentions=none: the bot can never ping anyone (SAF-02)
super().__init__(command_prefix="!", case_insensitive=True, intents=intents, allowed_mentions=discord.AllowedMentions.none())
def init_observer(self): def init_observer(self):
self.observer = Observer() self.observer = Observer()
@@ -70,7 +80,7 @@ class FjerkroaBot(commands.Bot):
async def on_boreness(self): async def on_boreness(self):
logging.info(f"Boreness started on channel: {repr(self.chat_channel)}") logging.info(f"Boreness started on channel: {repr(self.chat_channel)}")
while True: while True:
if self.chat_channel is None: if self.chat_channel is None or not self.bot_initiated_allowed():
await asyncio.sleep(7) await asyncio.sleep(7)
continue continue
boreness_interval = float(self.config.get("boreness-interval", 12.0)) boreness_interval = float(self.config.get("boreness-interval", 12.0))
@@ -112,11 +122,78 @@ class FjerkroaBot(commands.Bot):
return return
if not isinstance(message.channel, (TextChannel, DMChannel)): if not isinstance(message.channel, (TextChannel, DMChannel)):
return return
if self.is_staff_channel(message.channel) and str(message.content).startswith("!bot"):
await self.handle_staff_command(message)
return
if not self.replies_allowed():
return
if str(message.content).startswith("!wichtel"): if str(message.content).startswith("!wichtel"):
await self.wichtel(message) await self.wichtel(message)
return return
await self.handle_message_through_responder(message) await self.handle_message_through_responder(message)
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)
def replies_allowed(self) -> bool:
return self.replies_enabled and time.monotonic() >= self.quiet_until
def bot_initiated_allowed(self) -> bool:
# Gate for boreness today, the FDB-011 scheduler later (OPS-09)
return self.tasks_enabled and self.replies_allowed()
async def handle_staff_command(self, message: Message) -> None:
"""Operator kill-switches, staff channel only (OPS-01..05, OPS-09)."""
args = str(message.content).split()[1:]
reply = "Commands: pause, resume, images on|off, tasks on|off, quiet <minutes>, status"
if args[:1] == ["pause"]:
self.replies_enabled = False
reply = "Replies paused."
elif args[:1] == ["resume"]:
self.replies_enabled = True
self.quiet_until = 0.0
reply = "Replies resumed."
elif args[:1] == ["images"] and args[1:2] in (["on"], ["off"]):
self.images_enabled = args[1] == "on"
reply = f"Image generation {'enabled' if self.images_enabled else 'disabled'}."
elif args[:1] == ["tasks"] and args[1:2] in (["on"], ["off"]):
self.tasks_enabled = args[1] == "on"
reply = f"Bot-initiated posts {'enabled' if self.tasks_enabled else 'disabled'}."
elif args[:1] == ["quiet"] and args[1:2] and args[1].isdigit():
self.quiet_until = time.monotonic() + int(args[1]) * 60
reply = f"Quiet for {args[1]} minutes."
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"
logging.info(f"staff command {args}: {reply}")
await message.channel.send(reply, suppress_embeds=True)
def routing_allowed(self, channel_name: Optional[str]) -> bool:
"""Model-proposed channels must be allowlisted (SAF-01)."""
if channel_name is None:
return False
allowed = self.config.get("allowed-channels")
if allowed is None:
allowed = [self.config.get(key) for key in ("chat-channel", "staff-channel", "welcome-channel")]
allowed += list(self.config.get("additional-responders", []))
return channel_name in [name for name in allowed if name]
async def send_staff_alert(self, text: str) -> None:
"""Rate-limited, never silently dropped (OPS-07/08)."""
if self.staff_channel is None:
logging.error(f"staff alert lost - no staff channel: {text}")
return
now = time.monotonic()
while self._staff_alert_times and now - self._staff_alert_times[0] > 3600.0:
self._staff_alert_times.popleft()
if len(self._staff_alert_times) >= int(self.config.get("staff-alert-max-per-hour", 10)):
logging.warning(f"staff alert rate-limited: {text}")
return
self._staff_alert_times.append(now)
async with self.staff_channel.typing():
await self.staff_channel.send(text, suppress_embeds=True)
async def on_reaction_operation(self, reaction, user, operation): async def on_reaction_operation(self, reaction, user, operation):
if user.bot: if user.bot:
return return
@@ -132,8 +209,14 @@ class FjerkroaBot(commands.Bot):
async def on_reaction_remove(self, reaction, user): async def on_reaction_remove(self, reaction, user):
await self.on_reaction_operation(reaction, user, "removing") await self.on_reaction_operation(reaction, user, "removing")
async def on_reaction_clear(self, reaction, user): async def on_reaction_clear(self, message, reactions):
await self.on_reaction_operation(reaction, user, "clearing") # discord.py dispatches (message, reactions) here — ENV-13 / D7
airesponder = self.get_ai_responder(self.get_channel_name(message.channel))
content = str(message.content) if message.content else ""
if len(content) > 1:
await airesponder.memoize(
message.author.name, "assistant", "\n> " + content.replace("\n", "\n> "), "All reactions were removed from this message."
)
async def on_message_edit(self, before, after): async def on_message_edit(self, before, after):
if before.author.bot or before.content == after.content: if before.author.bot or before.content == after.content:
@@ -163,7 +246,7 @@ class FjerkroaBot(commands.Bot):
responder.config = self.config responder.config = self.config
@classmethod @classmethod
def load_config(self, config_file: str = "config.toml"): def load_config(cls, config_file: str = "config.toml"):
with open(config_file, encoding="utf-8") as file: with open(config_file, encoding="utf-8") as file:
return tomlkit.load(file) return tomlkit.load(file)
@@ -240,6 +323,37 @@ class FjerkroaBot(commands.Bot):
await answer_channel.send(response.answer, suppress_embeds=True) await answer_channel.send(response.answer, suppress_embeds=True)
self.last_activity_time = time.monotonic() self.last_activity_time = time.monotonic()
def _keyword_alert(self, message: AIMessage) -> Optional[str]:
for pattern in self.config.get("staff-alert-keywords", []):
try:
if re.search(pattern, message.message):
return f"Keyword alert: {message.user}: {message.message[:200]}"
except re.error as err:
logging.warning(f"bad staff-alert-keywords pattern {pattern!r}: {err}")
return None
async def _apply_response_gates(self, message: AIMessage, response) -> None:
"""The model proposes, this code disposes (SPEC-003 / SPEC-006)."""
# hack self-report is an advisory signal only
if response.hack:
logging.warning(f"User {message.user} tried to hack the system.")
if response.staff is None:
response.staff = f"User {message.user} try to hack the AI."
# Keyword-forced staff alerts (OPS-06)
if response.staff is None:
response.staff = self._keyword_alert(message)
# Rate-limited, never-silently-dropped alert path (OPS-07/08)
if response.staff is not None:
await self.send_staff_alert(response.staff)
# Model-proposed channels must be allowlisted (SAF-01)
if response.channel is not None and response.channel != message.channel and not self.routing_allowed(response.channel):
logging.warning(f"model-proposed channel {response.channel!r} not allowed, using origin")
response.channel = message.channel
# Operator image kill-switch (OPS-03)
if response.picture is not None and not self.images_enabled:
logging.info("image generation disabled by operator - sending text only")
response.picture = None
async def respond( async def respond(
self, self,
message: AIMessage, # Incoming message object with user message and metadata message: AIMessage, # Incoming message object with user message and metadata
@@ -264,16 +378,8 @@ class FjerkroaBot(commands.Bot):
# Send the user message to the AI responder, with typing indicators # Send the user message to the AI responder, with typing indicators
response = await self.send_message_with_typing(airesponder, channel, message) response = await self.send_message_with_typing(airesponder, channel, message)
# Check if the user tried to hack the system, log if so # SAF/OPS gates between model proposal and delivery
if response.hack: await self._apply_response_gates(message, response)
logging.warning(f"User {message.user} tried to hack the system.")
if response.staff is None:
response.staff = f"User {message.user} try to hack the AI."
# If there is a staff message, send it to the staff channel, with typing indicators
if response.staff is not None and self.staff_channel is not None:
async with self.staff_channel.typing():
await self.staff_channel.send(response.staff, suppress_embeds=True)
# Get the answer channel based on the requested response channel # Get the answer channel based on the requested response channel
answer_channel = self.channel_by_name(response.channel, channel) answer_channel = self.channel_by_name(response.channel, channel)
+45 -34
View File
@@ -7,17 +7,34 @@ from typing import Any, Dict, List, Optional, Tuple
import aiohttp import aiohttp
import openai import openai
from .ai_responder import AIResponder, async_cache_to_file, exponential_backoff, pp from .ai_responder import AIResponder, exponential_backoff, pp, sanitize_external_text
from .igdblib import IGDBQuery from .igdblib import IGDBQuery
from .leonardo_draw import LeonardoAIDrawMixIn from .leonardo_draw import LeonardoAIDrawMixIn
# The response envelope, enforced server-side via structured outputs
# (ENV-19). All fields required, closed object, nullable where the
# protocol allows null.
ENVELOPE_SCHEMA = {
"type": "object",
"properties": {
"answer": {"type": ["string", "null"], "description": "The message to post, or null when staying silent."},
"answer_needed": {"type": "boolean", "description": "Whether the answer should actually be posted."},
"channel": {"type": ["string", "null"], "description": "Target channel name, or null for the origin channel."},
"staff": {"type": ["string", "null"], "description": "Alert text for the staff channel, or null."},
"picture": {"type": ["string", "null"], "description": "Image generation prompt, or null."},
"picture_edit": {"type": "boolean", "description": "Whether the picture refers to an earlier image."},
"hack": {"type": "boolean", "description": "Whether the user tried to manipulate the assistant."},
},
"required": ["answer", "answer_needed", "channel", "staff", "picture", "picture_edit", "hack"],
"additionalProperties": False,
}
ENVELOPE_RESPONSE_FORMAT = {"type": "json_schema", "json_schema": {"name": "envelope", "strict": True, "schema": ENVELOPE_SCHEMA}}
@async_cache_to_file("openai_chat.dat")
async def openai_chat(client, *args, **kwargs): async def openai_chat(client, *args, **kwargs):
return await client.chat.completions.create(*args, **kwargs) return await client.chat.completions.create(*args, **kwargs)
@async_cache_to_file("openai_chat.dat")
async def openai_image(client, *args, **kwargs): async def openai_image(client, *args, **kwargs):
response = await client.images.generate(*args, **kwargs) response = await client.images.generate(*args, **kwargs)
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
@@ -29,6 +46,8 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
def __init__(self, config: Dict[str, Any], channel: Optional[str] = None) -> None: def __init__(self, config: Dict[str, Any], channel: Optional[str] = None) -> None:
super().__init__(config, channel) super().__init__(config, channel)
self.client = openai.AsyncOpenAI(api_key=self.config.get("openai-token", self.config.get("openai-key", ""))) 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
# Initialize IGDB if enabled # Initialize IGDB if enabled
self.igdb = None self.igdb = None
@@ -84,6 +103,8 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
model = self.config["model-vision"] model = self.config["model-vision"]
else: else:
messages[-1]["content"] = messages[-1]["content"][0]["text"] messages[-1]["content"] = messages[-1]["content"][0]["text"]
if self._use_retry_model and "retry-model" in self.config:
model = self.config["retry-model"]
except (KeyError, IndexError, TypeError) as e: except (KeyError, IndexError, TypeError) as e:
logging.warning(f"Error accessing message content: {e}") logging.warning(f"Error accessing message content: {e}")
return None, limit return None, limit
@@ -92,6 +113,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
chat_kwargs = { chat_kwargs = {
"model": model, "model": model,
"messages": messages, "messages": messages,
"response_format": ENVELOPE_RESPONSE_FORMAT,
} }
if self.igdb and self.config.get("enable-game-info", False): if self.igdb and self.config.get("enable-game-info", False):
@@ -116,6 +138,12 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
# Handle function calls if present # Handle function calls if present
message = result.choices[0].message message = result.choices[0].message
# A refusal is a failed attempt, not an answer (ENV-18)
refusal = getattr(message, "refusal", None)
if isinstance(refusal, str) and refusal:
logging.warning(f"model refused: {refusal}")
return None, limit
# Log what we received from OpenAI # Log what we received from OpenAI
logging.debug(f"📨 OpenAI Response: content={bool(message.content)}, has_tool_calls={hasattr(message, 'tool_calls')}") logging.debug(f"📨 OpenAI Response: content={bool(message.content)}, has_tool_calls={hasattr(message, 'tool_calls')}")
if hasattr(message, "tool_calls") and message.tool_calls: if hasattr(message, "tool_calls") and message.tool_calls:
@@ -159,7 +187,10 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
{ {
"role": "tool", "role": "tool",
"tool_call_id": tool_call.id, "tool_call_id": tool_call.id,
"content": json.dumps(function_result) if function_result else "No results found", # IGDB text is external input — sanitize before prompting (SAF-03)
"content": (
sanitize_external_text(json.dumps(function_result), 8000) if function_result else "No results found"
),
} }
) )
@@ -167,6 +198,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
final_chat_kwargs = { final_chat_kwargs = {
"model": model, "model": model,
"messages": messages, "messages": messages,
"response_format": ENVELOPE_RESPONSE_FORMAT,
} }
logging.debug(f"🔧 Sending final request to OpenAI with {len(messages)} messages (no tools)") logging.debug(f"🔧 Sending final request to OpenAI with {len(messages)} messages (no tools)")
logging.debug(f"🔧 Last few messages: {messages[-3:] if len(messages) > 3 else messages}") logging.debug(f"🔧 Last few messages: {messages[-3:] if len(messages) > 3 else messages}")
@@ -201,6 +233,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
answer = {"content": content, "role": answer_obj.role} answer = {"content": content, "role": answer_obj.role}
self.rate_limit_backoff = exponential_backoff() self.rate_limit_backoff = exponential_backoff()
self._use_retry_model = False
logging.info(f"generated response {result.usage}: {repr(answer)}") logging.info(f"generated response {result.usage}: {repr(answer)}")
return answer, limit return answer, limit
except openai.BadRequestError as err: except openai.BadRequestError as err:
@@ -211,8 +244,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
raise err raise err
except openai.RateLimitError as err: except openai.RateLimitError as err:
rate_limit_sleep = next(self.rate_limit_backoff) rate_limit_sleep = next(self.rate_limit_backoff)
if "retry-model" in self.config: self._use_retry_model = True
model = self.config["retry-model"]
logging.warning(f"got an rate limit error, sleep for {rate_limit_sleep} seconds: {str(err)}") logging.warning(f"got an rate limit error, sleep for {rate_limit_sleep} seconds: {str(err)}")
await asyncio.sleep(rate_limit_sleep) await asyncio.sleep(rate_limit_sleep)
except Exception as err: except Exception as err:
@@ -222,29 +254,6 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
logging.debug(f"Full traceback: {traceback.format_exc()}") logging.debug(f"Full traceback: {traceback.format_exc()}")
return None, limit return None, limit
async def fix(self, answer: str) -> str:
if "fix-model" not in self.config:
return answer
# Handle null/empty answer
if not answer:
logging.warning("Fix called with null/empty answer")
return '{"answer": "I apologize, I encountered an error processing your request.", "answer_needed": true, "channel": null, "staff": null, "picture": null, "picture_edit": false, "hack": false}'
messages = [{"role": "system", "content": self.config["fix-description"]}, {"role": "user", "content": answer}]
try:
result = await openai_chat(self.client, model=self.config["fix-model"], messages=messages)
logging.info(f"got this message as fix:\n{pp(result.choices[0].message.content)}")
response = result.choices[0].message.content
start, end = response.find("{"), response.rfind("}")
if start == -1 or end == -1 or (start + 3) >= end:
return answer
response = response[start : end + 1]
logging.info(f"fixed answer:\n{pp(response)}")
return response
except Exception as err:
logging.warning(f"failed to execute a fix for the answer: {repr(err)}")
return answer
async def translate(self, text: str, language: str = "english") -> str: async def translate(self, text: str, language: str = "english") -> str:
if "fix-model" not in self.config: if "fix-model" not in self.config:
return text return text
@@ -312,7 +321,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
logging.warning("🎮 No search query provided to search_games") logging.warning("🎮 No search query provided to search_games")
return {"error": "No search query provided"} return {"error": "No search query provided"}
results = self.igdb.search_games(query, limit) results = await asyncio.to_thread(self.igdb.search_games, query, limit)
logging.info(f"🎮 IGDB search returned: {len(results) if results and isinstance(results, list) else 0} results") logging.info(f"🎮 IGDB search returned: {len(results) if results and isinstance(results, list) else 0} results")
if results and isinstance(results, list) and len(results) > 0: if results and isinstance(results, list) and len(results) > 0:
@@ -334,8 +343,10 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
logging.warning("🎮 No year provided to get_games_by_release_date") logging.warning("🎮 No year provided to get_games_by_release_date")
return {"error": "No year provided"} return {"error": "No year provided"}
results = self.igdb.get_games_by_release_date(year, month, platform, limit) results = await asyncio.to_thread(self.igdb.get_games_by_release_date, year, month, platform, limit)
logging.info(f"🎮 IGDB release date search returned: {len(results) if results and isinstance(results, list) else 0} results") logging.info(
f"🎮 IGDB release date search returned: {len(results) if results and isinstance(results, list) else 0} results"
)
if results and isinstance(results, list) and len(results) > 0: if results and isinstance(results, list) and len(results) > 0:
return {"games": results} return {"games": results}
@@ -355,7 +366,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
logging.warning("🎮 No platform provided to get_games_by_platform") logging.warning("🎮 No platform provided to get_games_by_platform")
return {"error": "No platform provided"} return {"error": "No platform provided"}
results = self.igdb.get_games_by_platform(platform, genre, limit) results = await asyncio.to_thread(self.igdb.get_games_by_platform, platform, genre, limit)
logging.info(f"🎮 IGDB platform search returned: {len(results) if results and isinstance(results, list) else 0} results") logging.info(f"🎮 IGDB platform search returned: {len(results) if results and isinstance(results, list) else 0} results")
if results and isinstance(results, list) and len(results) > 0: if results and isinstance(results, list) and len(results) > 0:
@@ -373,7 +384,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
logging.warning("🎮 No game ID provided to get_game_details") logging.warning("🎮 No game ID provided to get_game_details")
return {"error": "No game ID provided"} return {"error": "No game ID provided"}
result = self.igdb.get_game_details(game_id) result = await asyncio.to_thread(self.igdb.get_game_details, game_id)
logging.info(f"🎮 IGDB game details returned: {bool(result)}") logging.info(f"🎮 IGDB game details returned: {bool(result)}")
if result: if result:
+33
View File
@@ -0,0 +1,33 @@
# SPEC-003 — Safety, privacy + abuse hardening
FDB-005 lands the injection-defense subset (the old `hack`
self-report was the only defense — S4). Consequential actions are
gated *outside* the model: the model proposes, deterministic code
disposes. Quotas, budget caps, memory policies and GDPR controls
follow with FDB-014 (SAF-10+, reserved).
### SAF-01 — Model-proposed channel routing is allowlisted (coverage: test)
A model-proposed answer channel is honored only when it is in the
allowed set: config `allowed-channels` when set, otherwise the
channels already named in config (`chat-channel`, `staff-channel`,
`welcome-channel`, `additional-responders`). Anything else falls back
to the origin channel and is logged. Prompt injection must not be
able to redirect the bot into arbitrary channels.
### SAF-02 — Outbound messages cannot ping (coverage: test)
The bot is constructed with `allowed_mentions = none`: no user, role
or @everyone/@here pings in any outbound message, regardless of what
the model emits.
### SAF-03 — External text is sanitized before prompting (coverage: test)
Text from external sources injected into prompts or tool results —
news-feed content and IGDB results — passes `sanitize_external_text`:
control characters stripped, `@everyone`/`@here` neutralized with a
zero-width space, length capped (default 4000 chars). RSS headlines
and game descriptions are attacker-influenced input.
The `hack` envelope field remains as an advisory signal (logged,
staff-notified) but is no longer the defense.
+58
View File
@@ -0,0 +1,58 @@
# SPEC-006 — Staff / operator controls
Staff operate the bot from the staff channel without SSH. Runtime
flags live in memory — a restart resets to config defaults (D-008).
The staff-alert path is a tested contract: alerts are the
business-critical feature on the restaurant deployment.
### OPS-01 — Staff commands only work in the staff channel (coverage: test)
`!bot …` commands are honored only when sent in the configured staff
channel. In any other channel the text is treated as a normal
message.
### OPS-02 — Pause and resume (coverage: test)
`!bot pause` stops all public replying (messages are ignored, no
model calls); `!bot resume` restores it and clears quiet mode. Staff
commands keep working while paused.
### OPS-03 — Image kill-switch (coverage: test)
`!bot images off` drops the picture part of any response before
generation (answer text still goes out); `!bot images on` restores.
### OPS-04 — Quiet mode with auto-resume (coverage: test)
`!bot quiet <minutes>` silences public replies for N minutes, then
the bot resumes by itself. Friday-service panic button that cannot be
forgotten.
### OPS-05 — Status report (coverage: test)
`!bot status` answers in the staff channel with the current flags
(replies / images / tasks / remaining quiet time).
### OPS-06 — Keyword-forced staff alerts (coverage: test)
When a user message matches any configured `staff-alert-keywords`
regex and the model set no staff note, a staff alert is forced with
user + message excerpt. Alerting must not depend solely on the
model's judgement.
### OPS-07 — Staff alerts are rate-limited (coverage: test)
At most `staff-alert-max-per-hour` (default 10) alerts reach the
staff channel per rolling hour; excess alerts are logged. An
injection or a glitch must not be able to flood staff.
### OPS-08 — Lost staff alerts are logged (coverage: test)
When a staff alert cannot be delivered (staff channel unresolved),
the alert text is written to the error log — never silently dropped.
### OPS-09 — Self-tasking kill-switch (coverage: test)
`!bot tasks off` disables bot-initiated posting (today: the boreness
loop; later: the FDB-011 scheduler); `!bot tasks on` restores.
Bot-initiated posts also respect pause/quiet.
+5 -29
View File
@@ -6,7 +6,7 @@ import toml
from discord import Message, TextChannel, User from discord import Message, TextChannel, User
from fjerkroa_bot import FjerkroaBot from fjerkroa_bot import FjerkroaBot
from fjerkroa_bot.ai_responder import AIMessage, AIResponse, parse_maybe_json from fjerkroa_bot.ai_responder import AIMessage, AIResponse
class TestBotBase(unittest.IsolatedAsyncioTestCase): class TestBotBase(unittest.IsolatedAsyncioTestCase):
@@ -26,9 +26,10 @@ class TestBotBase(unittest.IsolatedAsyncioTestCase):
"additional-responders": [], "additional-responders": [],
} }
self.history_data = [] self.history_data = []
with patch.object(FjerkroaBot, "load_config", new=lambda s, c: self.config_data), patch.object( with (
FjerkroaBot, "user", new_callable=PropertyMock patch.object(FjerkroaBot, "load_config", new=lambda s, c: self.config_data),
) as mock_user: patch.object(FjerkroaBot, "user", new_callable=PropertyMock) as mock_user,
):
mock_user.return_value = MagicMock(spec=User) mock_user.return_value = MagicMock(spec=User)
mock_user.return_value.id = 12 mock_user.return_value.id = 12
self.bot = FjerkroaBot("config.toml") self.bot = FjerkroaBot("config.toml")
@@ -56,31 +57,6 @@ class TestFunctionality(TestBotBase):
result = FjerkroaBot.load_config("config.toml") result = FjerkroaBot.load_config("config.toml")
self.assertEqual(result, self.config_data) self.assertEqual(result, self.config_data)
def test_json_strings(self) -> None:
json_string = '{"key1": "value1", "key2": "value2"}'
expected_output = "value1\nvalue2"
self.assertEqual(parse_maybe_json(json_string), expected_output)
non_json_string = "This is not a JSON string."
self.assertEqual(parse_maybe_json(non_json_string), non_json_string)
json_array = '["value1", "value2", "value3"]'
expected_output = "value1\nvalue2\nvalue3"
self.assertEqual(parse_maybe_json(json_array), expected_output)
json_string = '"value1"'
expected_output = "value1"
self.assertEqual(parse_maybe_json(json_string), expected_output)
json_struct = '{"This is a string."}'
expected_output = "This is a string."
self.assertEqual(parse_maybe_json(json_struct), expected_output)
json_struct = '["This is a string."]'
expected_output = "This is a string."
self.assertEqual(parse_maybe_json(json_struct), expected_output)
json_struct = "{This is a string.}"
expected_output = "This is a string."
self.assertEqual(parse_maybe_json(json_struct), expected_output)
json_struct = "[This is a string.]"
expected_output = "This is a string."
self.assertEqual(parse_maybe_json(json_struct), expected_output)
async def test_message_lings(self) -> None: async def test_message_lings(self) -> None:
request = AIMessage( request = AIMessage(
"Lala", "Lala",
+3 -9
View File
@@ -26,15 +26,9 @@ class TestOpenAIResponderSimple(unittest.IsolatedAsyncioTestCase):
responder = OpenAIResponder(config) responder = OpenAIResponder(config)
self.assertIsNotNone(responder.client) self.assertIsNotNone(responder.client)
async def test_fix_no_fix_model(self): def test_no_repair_path_exists(self):
"""Test fix when no fix-model is configured.""" """ENV-18: the repair path is gone — no fix() on the responder."""
config_no_fix = {"openai-key": "test", "model": "gpt-4"} self.assertFalse(hasattr(self.responder, "fix"))
responder = OpenAIResponder(config_no_fix)
original_answer = '{"answer": "test"}'
result = await responder.fix(original_answer)
self.assertEqual(result, original_answer)
async def test_translate_no_fix_model(self): async def test_translate_no_fix_model(self):
"""Test translate when no fix-model is configured.""" """Test translate when no fix-model is configured."""
+132
View File
@@ -0,0 +1,132 @@
"""Unit coverage for the FDB-004 defect-fix requirements (ENV-12..17)."""
import threading
import unittest
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import openai
from discord import Message, TextChannel
from fjerkroa_bot.ai_responder import AIMessage, AIResponder
from fjerkroa_bot.openai_responder import OpenAIResponder, openai_chat
from .test_bdd_envelope import FakeModelResponder, envelope
from .test_main import TestBotBase
def make_entry(channel: str, text: str = "x"):
import json
return {"role": "user", "content": json.dumps({"message": text, "channel": channel})}
class TestSendBackoff(unittest.IsolatedAsyncioTestCase):
async def test_send_sleeps_between_retries(self):
"""ENV-12: failed attempts are separated by exponential-backoff sleeps (D1)."""
responder = FakeModelResponder({"system": "s", "history-limit": 5}, "chat")
with patch("fjerkroa_bot.ai_responder.asyncio.sleep", new_callable=AsyncMock) as sleep:
with self.assertRaises(RuntimeError):
await responder.send(AIMessage("alice", "hei", "chat"))
self.assertGreaterEqual(sleep.await_count, 2)
for call in sleep.await_args_list:
self.assertGreater(call.args[0], 0)
class TestReactionClear(TestBotBase):
async def test_on_reaction_clear_discord_signature(self):
"""ENV-13: on_reaction_clear(message, reactions) memoizes the clearing (D7)."""
message = MagicMock(spec=Message)
message.content = "Some message text"
message.author.name = "alice"
message.channel = MagicMock(spec=TextChannel)
self.bot.airesponder.memoize = AsyncMock()
await self.bot.on_reaction_clear(message, [Mock()])
self.bot.airesponder.memoize.assert_awaited_once()
class TestUpdateMemory(unittest.TestCase):
def test_update_memory_uses_argument(self):
"""ENV-14: update_memory persists the passed value, not stale state (D12)."""
responder = AIResponder({"system": "s", "history-limit": 5}, "chat")
responder.update_memory("NEW MEMORY")
self.assertEqual(responder.memory, "NEW MEMORY")
class TestRetryModel(unittest.IsolatedAsyncioTestCase):
async def test_retry_model_used_after_rate_limit(self):
"""ENV-15: the attempt after a rate limit uses retry-model, then switches back (D2)."""
config = {
"openai-token": "test",
"model": "main-model",
"retry-model": "fallback-model",
"system": "s",
"history-limit": 5,
}
responder = OpenAIResponder(config, "chat")
rate_limit = openai.RateLimitError(
"rate limited", response=httpx.Response(429, request=httpx.Request("POST", "http://test")), body=None
)
def ok_result():
message = Mock(content=envelope(answer="x", answer_needed=True), role="assistant", tool_calls=None)
return Mock(choices=[Mock(message=message)], usage="usage")
with (
patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock,
patch("asyncio.sleep", new_callable=AsyncMock),
):
chat_mock.side_effect = [rate_limit, ok_result(), ok_result()]
messages = [{"role": "user", "content": "hi"}]
first, _ = await responder.chat(list(messages), 10)
self.assertIsNone(first)
second, _ = await responder.chat(list(messages), 10)
self.assertIsNotNone(second)
third, _ = await responder.chat(list(messages), 10)
self.assertIsNotNone(third)
self.assertEqual(chat_mock.await_args_list[0].kwargs["model"], "main-model")
self.assertEqual(chat_mock.await_args_list[1].kwargs["model"], "fallback-model")
self.assertEqual(chat_mock.await_args_list[2].kwargs["model"], "main-model")
class TestNoDiskCache(unittest.IsolatedAsyncioTestCase):
async def test_chat_hits_client_every_time(self):
"""ENV-16: identical chat calls reach the client every time — no pickle cache (D3)."""
client = Mock()
client.chat.completions.create = AsyncMock(return_value="RESPONSE")
kwargs = {"model": "m", "messages": [{"role": "user", "content": "hi"}]}
await openai_chat(client, **kwargs)
await openai_chat(client, **kwargs)
self.assertEqual(client.chat.completions.create.await_count, 2)
class TestIgdbOffEventLoop(unittest.IsolatedAsyncioTestCase):
async def test_igdb_search_runs_in_worker_thread(self):
"""ENV-17: IGDB lookups run in a worker thread, results unchanged (D5)."""
responder = OpenAIResponder({"openai-token": "test", "model": "m", "system": "s", "history-limit": 5}, "chat")
responder.igdb = Mock()
calling_thread = {}
def capture_search(query, limit):
calling_thread["thread"] = threading.current_thread()
return [{"name": "Zelda"}]
responder.igdb.search_games = capture_search
result = await responder._execute_igdb_function("search_games", {"query": "zelda"})
self.assertEqual(result, {"games": [{"name": "Zelda"}]})
self.assertIsNot(calling_thread["thread"], threading.main_thread())
class TestShrinkTolerance(unittest.TestCase):
def test_shrink_tolerates_non_json_entries(self):
"""ENV-11: non-JSON history entries do not crash shrinking (D10 rewrite)."""
responder = AIResponder({"system": "s", "history-limit": 4}, "chat")
responder.history = [
{"role": "user", "content": "plain, not json"},
make_entry("a", "a0"),
make_entry("a", "a1"),
make_entry("a", "a2"),
make_entry("a", "a3"),
]
responder.shrink_history_by_one()
self.assertEqual(len(responder.history), 4)
+135
View File
@@ -0,0 +1,135 @@
"""Unit coverage for SPEC-006 operator controls (OPS-01..09)."""
import time
from unittest.mock import AsyncMock, MagicMock
from discord import Message, TextChannel, User
from fjerkroa_bot.ai_responder import AIMessage, AIResponse
from .test_main import TestBotBase
class OpsBase(TestBotBase):
def staff_msg(self, content: str) -> Message:
message = MagicMock(spec=Message)
message.content = content
message.author = MagicMock(spec=User)
message.author.bot = False
message.author.id = 999
message.channel = self.bot.staff_channel
return message
def public_msg(self, content: str) -> Message:
message = self.create_message(content)
message.content = content
return message
class TestStaffCommandAuth(OpsBase):
async def test_commands_ignored_outside_staff_channel(self):
"""OPS-01: !bot commands in a public channel do not flip flags."""
self.bot.handle_message_through_responder = AsyncMock()
await self.bot.on_message(self.public_msg("!bot pause"))
self.assertTrue(self.bot.replies_enabled)
self.bot.handle_message_through_responder.assert_awaited_once()
async def test_commands_honored_in_staff_channel(self):
"""OPS-01: !bot commands in the staff channel are executed."""
await self.bot.on_message(self.staff_msg("!bot pause"))
self.assertFalse(self.bot.replies_enabled)
class TestPauseResume(OpsBase):
async def test_pause_blocks_public_replies(self):
"""OPS-02: paused bot ignores public messages; resume restores replying."""
self.bot.handle_message_through_responder = AsyncMock()
await self.bot.on_message(self.staff_msg("!bot pause"))
await self.bot.on_message(self.public_msg("hello?"))
self.bot.handle_message_through_responder.assert_not_awaited()
await self.bot.on_message(self.staff_msg("!bot resume"))
self.assertTrue(self.bot.replies_enabled)
await self.bot.on_message(self.public_msg("hello again"))
self.bot.handle_message_through_responder.assert_awaited_once()
class TestImageKillSwitch(OpsBase):
async def test_images_off_strips_picture(self):
"""OPS-03: with images off the picture request is dropped, answer still sent."""
await self.bot.on_message(self.staff_msg("!bot images off"))
self.assertFalse(self.bot.images_enabled)
response = AIResponse("here is your cat", True, "chat", None, "a cat", False, False)
self.bot.send_message_with_typing = AsyncMock(return_value=response)
self.bot.send_answer_with_typing = AsyncMock()
origin = MagicMock(spec=TextChannel)
await self.bot.respond(AIMessage("alice", "draw a cat", "chat"), origin)
sent = self.bot.send_answer_with_typing.await_args.args[0]
self.assertIsNone(sent.picture)
self.assertEqual(sent.answer, "here is your cat")
class TestQuietMode(OpsBase):
async def test_quiet_pauses_then_auto_resumes(self):
"""OPS-04: !bot quiet N silences replies for N minutes, then auto-resumes."""
await self.bot.on_message(self.staff_msg("!bot quiet 10"))
self.assertFalse(self.bot.replies_allowed())
self.bot.quiet_until = time.monotonic() - 1
self.assertTrue(self.bot.replies_allowed())
class TestStatus(OpsBase):
async def test_status_reports_flags(self):
"""OPS-05: !bot status answers in the staff channel with the flag state."""
await self.bot.on_message(self.staff_msg("!bot status"))
self.bot.staff_channel.send.assert_awaited()
text = self.bot.staff_channel.send.await_args.args[0]
self.assertIn("replies", text)
self.assertIn("images", text)
self.assertIn("tasks", text)
class TestKeywordAlerts(OpsBase):
async def test_keyword_forces_staff_alert(self):
"""OPS-06: staff-alert-keywords match forces an alert when the model set none."""
self.bot.config["staff-alert-keywords"] = ["(?i)hjelp|help"]
response = AIResponse("ok", True, "chat", None, None, False, False)
self.bot.send_message_with_typing = AsyncMock(return_value=response)
self.bot.send_answer_with_typing = AsyncMock()
await self.bot.respond(AIMessage("guest", "HELP at table 4", "chat"), MagicMock(spec=TextChannel))
self.bot.staff_channel.send.assert_awaited_once()
self.assertIn("guest", self.bot.staff_channel.send.await_args.args[0])
class TestAlertRateLimit(OpsBase):
async def test_alerts_rate_limited(self):
"""OPS-07: staff alerts above the hourly cap are logged, not sent."""
self.bot.config["staff-alert-max-per-hour"] = 2
await self.bot.send_staff_alert("one")
await self.bot.send_staff_alert("two")
await self.bot.send_staff_alert("three")
self.assertEqual(self.bot.staff_channel.send.await_count, 2)
class TestAlertFallback(OpsBase):
async def test_lost_alert_is_logged_not_raised(self):
"""OPS-08: no staff channel -> alert goes to the error log, no crash."""
self.bot.staff_channel = None
with self.assertLogs(level="ERROR") as logs:
await self.bot.send_staff_alert("nobody hears this")
self.assertTrue(any("nobody hears this" in line for line in logs.output))
class TestTasksKillSwitch(OpsBase):
async def test_tasks_off_blocks_bot_initiated(self):
"""OPS-09: !bot tasks off disables bot-initiated posting; on restores."""
self.assertTrue(self.bot.bot_initiated_allowed())
await self.bot.on_message(self.staff_msg("!bot tasks off"))
self.assertFalse(self.bot.tasks_enabled)
self.assertFalse(self.bot.bot_initiated_allowed())
await self.bot.on_message(self.staff_msg("!bot tasks on"))
self.assertTrue(self.bot.bot_initiated_allowed())
async def test_pause_also_blocks_bot_initiated(self):
"""OPS-09: bot-initiated posts respect pause/quiet."""
await self.bot.on_message(self.staff_msg("!bot pause"))
self.assertFalse(self.bot.bot_initiated_allowed())
+64
View File
@@ -0,0 +1,64 @@
"""Unit coverage for SPEC-003 injection gates (SAF-01..03)."""
import tempfile
import unittest
from fjerkroa_bot.ai_responder import AIMessage, AIResponder, sanitize_external_text
from .test_main import TestBotBase
class TestChannelRoutingGate(TestBotBase):
async def test_default_allowed_set_from_config_names(self):
"""SAF-01: default allowed set = channels named in config; everything else refused."""
self.bot.config = dict(self.bot.config)
self.bot.config.update(
{"chat-channel": "general", "staff-channel": "staff", "welcome-channel": "welcome", "additional-responders": ["games"]}
)
self.assertTrue(self.bot.routing_allowed("general"))
self.assertTrue(self.bot.routing_allowed("staff"))
self.assertTrue(self.bot.routing_allowed("games"))
self.assertFalse(self.bot.routing_allowed("random-channel"))
self.assertFalse(self.bot.routing_allowed(None))
async def test_explicit_allowlist_wins(self):
"""SAF-01: configured allowed-channels replaces the default set."""
self.bot.config = dict(self.bot.config)
self.bot.config.update({"chat-channel": "general", "allowed-channels": ["announcements"]})
self.assertTrue(self.bot.routing_allowed("announcements"))
self.assertFalse(self.bot.routing_allowed("general"))
class TestAllowedMentions(TestBotBase):
async def test_outbound_pings_disabled(self):
"""SAF-02: the bot is constructed with allowed_mentions = none."""
mentions = self.bot.allowed_mentions
self.assertIsNotNone(mentions)
self.assertFalse(mentions.everyone)
self.assertFalse(mentions.users)
self.assertFalse(mentions.roles)
class TestSanitizeExternalText(unittest.TestCase):
def test_sanitizer_strips_and_caps(self):
"""SAF-03: control chars stripped, @everyone/@here neutralized, length capped."""
dirty = "hei\x00\x1b[31m @everyone @here " + "x" * 5000
clean = sanitize_external_text(dirty, max_len=4000)
self.assertNotIn("\x00", clean)
self.assertNotIn("\x1b", clean)
self.assertNotIn("@everyone", clean)
self.assertNotIn("@here", clean)
self.assertLessEqual(len(clean), 4000)
self.assertIn("hei", clean)
def test_news_content_sanitized_into_prompt(self):
"""SAF-03: news file content passes the sanitizer before prompt injection."""
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as fd:
fd.write("Breaking: @everyone \x00 click here")
news_path = fd.name
config = {"system": "News: {news}", "history-limit": 5, "news": news_path}
responder = AIResponder(config, "chat")
system = responder.message(AIMessage("alice", "hei"))[0]["content"]
self.assertNotIn("@everyone", system)
self.assertNotIn("\x00", system)
self.assertIn("Breaking:", system)
+60
View File
@@ -0,0 +1,60 @@
"""Unit coverage for FDB-005 structured outputs (ENV-18, ENV-19)."""
import unittest
from unittest.mock import AsyncMock, Mock, patch
from fjerkroa_bot.ai_responder import AIMessage
from fjerkroa_bot.openai_responder import ENVELOPE_RESPONSE_FORMAT, OpenAIResponder
from .test_bdd_envelope import FakeModelResponder, envelope
RESPONDER_CONFIG = {"openai-token": "test", "model": "main-model", "system": "s", "history-limit": 5}
def ok_result(content=None):
message = Mock(content=content or envelope(answer="x", answer_needed=True), role="assistant", tool_calls=None, refusal=None)
return Mock(choices=[Mock(message=message)], usage="usage")
class TestNoRepairPath(unittest.IsolatedAsyncioTestCase):
async def test_malformed_output_is_failed_attempt(self):
"""ENV-18: malformed output is a failed attempt with backoff — no repair path (replaces ENV-06)."""
responder = FakeModelResponder({"system": "s", "history-limit": 5}, "chat")
responder.scripted = ["definitely not json", "definitely not json", "definitely not json"]
with patch("fjerkroa_bot.ai_responder.asyncio.sleep", new_callable=AsyncMock) as sleep:
with self.assertRaises(RuntimeError):
await responder.send(AIMessage("alice", "hei", "chat"))
self.assertEqual(responder.chat_calls, 3)
self.assertGreaterEqual(sleep.await_count, 2)
self.assertFalse(hasattr(responder, "fix"))
async def test_refusal_is_failed_attempt(self):
"""ENV-18: a model refusal yields no answer from chat()."""
responder = OpenAIResponder(RESPONDER_CONFIG, "chat")
refusal_message = Mock(content=None, refusal="I cannot help with that.", tool_calls=None, role="assistant")
with patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock:
chat_mock.return_value = Mock(choices=[Mock(message=refusal_message)], usage="usage")
answer, _ = await responder.chat([{"role": "user", "content": "hi"}], 10)
self.assertIsNone(answer)
class TestEnvelopeSchema(unittest.IsolatedAsyncioTestCase):
def test_schema_shape_pinned(self):
"""ENV-19: strict envelope schema — exact fields, all required, closed object."""
json_schema = ENVELOPE_RESPONSE_FORMAT["json_schema"]
schema = json_schema["schema"]
expected = {"answer", "answer_needed", "channel", "staff", "picture", "picture_edit", "hack"}
self.assertEqual(set(schema["properties"]), expected)
self.assertEqual(set(schema["required"]), expected)
self.assertFalse(schema["additionalProperties"])
self.assertTrue(json_schema["strict"])
self.assertEqual(json_schema["name"], "envelope")
async def test_chat_carries_response_format(self):
"""ENV-19: chat calls pass the pinned response_format to the API."""
responder = OpenAIResponder(RESPONDER_CONFIG, "chat")
with patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock:
chat_mock.return_value = ok_result()
answer, _ = await responder.chat([{"role": "user", "content": "hi"}], 10)
self.assertIsNotNone(answer)
self.assertEqual(chat_mock.await_args.kwargs["response_format"], ENVELOPE_RESPONSE_FORMAT)