diff --git a/.gitignore b/.gitignore index b63e296..7fc0043 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ last_updates.json .pytest_cache/ *.py,v *.msg +news_feed.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 89363b0..3186dea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,7 @@ # 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: # Built-in hooks - repo: https://github.com/pre-commit/pre-commit-hooks @@ -13,49 +16,36 @@ repos: - id: check-case-conflict - id: check-merge-conflict - id: debug-statements - - id: requirements-txt-fixer - # Black code formatter - - 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 + # Project-env tools (single version source: pyproject.toml) - repo: local 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 name: Run tests entry: make test-fast diff --git a/config.toml b/config.toml index fcd8e57..028014b 100644 --- a/config.toml +++ b/config.toml @@ -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-access-token = "YOUR_IGDB_ACCESS_TOKEN" 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 | status diff --git a/fjerkroa_bot/ai_responder.py b/fjerkroa_bot/ai_responder.py index 00d185a..2b5130f 100644 --- a/fjerkroa_bot/ai_responder.py +++ b/fjerkroa_bot/ai_responder.py @@ -1,3 +1,4 @@ +import asyncio import json import logging import os @@ -11,8 +12,6 @@ from pathlib import Path from pprint import pformat from typing import Any, Dict, List, Optional, Tuple, Union -import multiline - def pp(*args, **kw): if "width" not in kw: @@ -22,14 +21,16 @@ def pp(*args, **kw): @lru_cache(maxsize=300) def parse_json(content: str) -> Dict: - content = content.strip() - try: - return json.loads(content) - except Exception: - try: - return multiline.loads(content, multiline=True) - except Exception as err: - raise err + # Strict JSON only — model output is schema-enforced (ENV-18/19), + # history entries are json.dumps products. + return json.loads(content.strip()) + + +def sanitize_external_text(text: str, max_len: int = 4000) -> str: + """Neutralize attacker-influenced text before it enters a prompt (SAF-03).""" + 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): @@ -84,30 +85,6 @@ def async_cache_to_file(filename): 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): def __init__(self) -> None: self.vars: List[str] = [] @@ -182,7 +159,7 @@ class AIResponder(AIResponderBase): if news_feed and os.path.exists(news_feed): with open(news_feed) as fd: 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) messages.append({"role": "system", "content": system}) if limit is not None: @@ -211,30 +188,26 @@ class AIResponder(AIResponderBase): raise NotImplementedError() async def post_process(self, message: AIMessage, response: Dict[str, Any]) -> AIResponse: - for fld in ("answer", "channel", "staff", "picture", "hack"): - if str(response.get(fld)).strip().lower() in ("none", "", "null", '"none"', '"null"', "'none'", "'null'"): - response[fld] = None - for fld in ("answer_needed", "hack", "picture_edit"): - if str(response.get(fld)).strip().lower() == "true": - response[fld] = True - else: - response[fld] = False - if response["answer"] is None: - response["answer_needed"] = False + # Envelope arrives schema-validated (ENV-19); .get defaults keep old + # history entries and hand-built test dicts working. + answer = response.get("answer") + answer_needed = bool(response.get("answer_needed", False)) + if answer is None: + answer_needed = False else: - response["answer"] = str(response["answer"]) - response["answer"] = re.sub(r"@\[([^\]]*)\]\([^\)]*\)", r"\1", response["answer"]) - response["answer"] = re.sub(r"\[[^\]]*\]\(([^\)]*)\)", r"\1", response["answer"]) + answer = str(answer) + answer = re.sub(r"@\[([^\]]*)\]\([^\)]*\)", r"\1", answer) + answer = re.sub(r"\[[^\]]*\]\(([^\)]*)\)", r"\1", answer) if message.direct or message.user in message.message: - response["answer_needed"] = True + answer_needed = True response_message = AIResponse( - response["answer"], - response["answer_needed"], - parse_maybe_json(response["channel"]), - parse_maybe_json(response["staff"]), - parse_maybe_json(response["picture"]), - response["picture_edit"], - response["hack"], + answer, + answer_needed, + response.get("channel"), + response.get("staff"), + response.get("picture"), + bool(response.get("picture_edit", False)), + bool(response.get("hack", False)), ) if response_message.staff is not None and response_message.answer is not None: 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]: 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: raise NotImplementedError() async def translate(self, text: str, language: str = "english") -> str: raise NotImplementedError() - def shrink_history_by_one(self, index: int = 0) -> None: - if index >= len(self.history): - del self.history[0] - else: - current = self.history[index] - count = sum(1 for item in self.history if same_channel(item, current)) - if count > self.config.get("history-per-channel", 3): + @staticmethod + def _entry_channel(item: Dict[str, Any]) -> Optional[str]: + try: + return parse_json(item["content"]).get("channel") + except Exception: + return None + + 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] - else: - self.shrink_history_by_one(index + 1) + return + del self.history[0] 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): @@ -294,6 +274,7 @@ class AIResponder(AIResponderBase): pickle.dump(self.history, fd) def update_memory(self, memory) -> None: + self.memory = memory if self.memory_file is not None: with open(self.memory_file, "wb") as fd: pickle.dump(self.memory, fd) @@ -306,6 +287,15 @@ class AIResponder(AIResponderBase): response["picture"] = await self.translate(response["picture"]) 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: self.memory = await self.memory_rewrite(self.memory, message_user, answer_user, message, answer) self.update_memory(self.memory) @@ -324,8 +314,16 @@ class AIResponder(AIResponderBase): if self.short_path(message, limit): 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 + 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: # Get the message queue @@ -336,26 +334,13 @@ class AIResponder(AIResponderBase): answer, limit = await self.chat(messages, limit) if answer is None: - retries -= 1 + await failed_attempt() continue - # Attempt to parse the AI's response - try: - response = parse_json(answer["content"]) - except Exception as err: - 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 + # Attempt to parse the AI's response (strict — ENV-18) + response = self._parse_answer(answer) + if response is None or not await self.handle_picture(response): + await failed_attempt() continue # Post-process the message and update the answer's content diff --git a/fjerkroa_bot/discord_bot.py b/fjerkroa_bot/discord_bot.py index 0f0f85b..83c17e3 100644 --- a/fjerkroa_bot/discord_bot.py +++ b/fjerkroa_bot/discord_bot.py @@ -6,6 +6,7 @@ import random import re import sys import time +from collections import deque from typing import Optional, Union import discord @@ -37,10 +38,19 @@ class FjerkroaBot(commands.Bot): intents.reactions = True 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_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): self.observer = Observer() @@ -70,7 +80,7 @@ class FjerkroaBot(commands.Bot): async def on_boreness(self): logging.info(f"Boreness started on channel: {repr(self.chat_channel)}") while True: - if self.chat_channel is None: + if self.chat_channel is None or not self.bot_initiated_allowed(): await asyncio.sleep(7) continue boreness_interval = float(self.config.get("boreness-interval", 12.0)) @@ -112,11 +122,78 @@ class FjerkroaBot(commands.Bot): return if not isinstance(message.channel, (TextChannel, DMChannel)): 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"): await self.wichtel(message) return 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 , 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): if user.bot: return @@ -132,8 +209,14 @@ class FjerkroaBot(commands.Bot): async def on_reaction_remove(self, reaction, user): await self.on_reaction_operation(reaction, user, "removing") - async def on_reaction_clear(self, reaction, user): - await self.on_reaction_operation(reaction, user, "clearing") + async def on_reaction_clear(self, message, reactions): + # 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): if before.author.bot or before.content == after.content: @@ -163,7 +246,7 @@ class FjerkroaBot(commands.Bot): responder.config = self.config @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: return tomlkit.load(file) @@ -240,6 +323,37 @@ class FjerkroaBot(commands.Bot): await answer_channel.send(response.answer, suppress_embeds=True) 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( self, 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 response = await self.send_message_with_typing(airesponder, channel, message) - # Check if the user tried to hack the system, log if so - 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." - - # 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) + # SAF/OPS gates between model proposal and delivery + await self._apply_response_gates(message, response) # Get the answer channel based on the requested response channel answer_channel = self.channel_by_name(response.channel, channel) diff --git a/fjerkroa_bot/openai_responder.py b/fjerkroa_bot/openai_responder.py index d73bffc..78571ac 100644 --- a/fjerkroa_bot/openai_responder.py +++ b/fjerkroa_bot/openai_responder.py @@ -7,17 +7,34 @@ from typing import Any, Dict, List, Optional, Tuple import aiohttp 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 .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): return await client.chat.completions.create(*args, **kwargs) -@async_cache_to_file("openai_chat.dat") async def openai_image(client, *args, **kwargs): response = await client.images.generate(*args, **kwargs) 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: super().__init__(config, channel) 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 self.igdb = None @@ -84,6 +103,8 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): model = self.config["model-vision"] else: 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: logging.warning(f"Error accessing message content: {e}") return None, limit @@ -92,6 +113,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): chat_kwargs = { "model": model, "messages": messages, + "response_format": ENVELOPE_RESPONSE_FORMAT, } if self.igdb and self.config.get("enable-game-info", False): @@ -116,6 +138,12 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): # Handle function calls if present 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 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: @@ -159,7 +187,10 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): { "role": "tool", "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 = { "model": model, "messages": messages, + "response_format": ENVELOPE_RESPONSE_FORMAT, } 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}") @@ -201,6 +233,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): answer = {"content": content, "role": answer_obj.role} self.rate_limit_backoff = exponential_backoff() + self._use_retry_model = False logging.info(f"generated response {result.usage}: {repr(answer)}") return answer, limit except openai.BadRequestError as err: @@ -211,8 +244,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): raise err except openai.RateLimitError as err: rate_limit_sleep = next(self.rate_limit_backoff) - if "retry-model" in self.config: - model = self.config["retry-model"] + self._use_retry_model = True logging.warning(f"got an rate limit error, sleep for {rate_limit_sleep} seconds: {str(err)}") await asyncio.sleep(rate_limit_sleep) except Exception as err: @@ -222,29 +254,6 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): logging.debug(f"Full traceback: {traceback.format_exc()}") 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: if "fix-model" not in self.config: return text @@ -312,7 +321,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): logging.warning("🎮 No search query provided to search_games") 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") 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") return {"error": "No year provided"} - results = 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") + 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" + ) if results and isinstance(results, list) and len(results) > 0: return {"games": results} @@ -355,7 +366,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): logging.warning("🎮 No platform provided to get_games_by_platform") 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") 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") 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)}") if result: diff --git a/specs/SPEC-003-safety.md b/specs/SPEC-003-safety.md new file mode 100644 index 0000000..64cb5c0 --- /dev/null +++ b/specs/SPEC-003-safety.md @@ -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. diff --git a/specs/SPEC-006-operator-controls.md b/specs/SPEC-006-operator-controls.md new file mode 100644 index 0000000..7230844 --- /dev/null +++ b/specs/SPEC-006-operator-controls.md @@ -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 ` 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. diff --git a/tests/test_main.py b/tests/test_main.py index 8559efb..74f921b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,7 +6,7 @@ import toml from discord import Message, TextChannel, User 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): @@ -26,9 +26,10 @@ class TestBotBase(unittest.IsolatedAsyncioTestCase): "additional-responders": [], } self.history_data = [] - with patch.object(FjerkroaBot, "load_config", new=lambda s, c: self.config_data), patch.object( - FjerkroaBot, "user", new_callable=PropertyMock - ) as mock_user: + with ( + patch.object(FjerkroaBot, "load_config", new=lambda s, c: self.config_data), + patch.object(FjerkroaBot, "user", new_callable=PropertyMock) as mock_user, + ): mock_user.return_value = MagicMock(spec=User) mock_user.return_value.id = 12 self.bot = FjerkroaBot("config.toml") @@ -56,31 +57,6 @@ class TestFunctionality(TestBotBase): result = FjerkroaBot.load_config("config.toml") 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: request = AIMessage( "Lala", diff --git a/tests/test_openai_responder_simple.py b/tests/test_openai_responder_simple.py index ac93f97..5d889d8 100644 --- a/tests/test_openai_responder_simple.py +++ b/tests/test_openai_responder_simple.py @@ -26,15 +26,9 @@ class TestOpenAIResponderSimple(unittest.IsolatedAsyncioTestCase): responder = OpenAIResponder(config) self.assertIsNotNone(responder.client) - async def test_fix_no_fix_model(self): - """Test fix when no fix-model is configured.""" - config_no_fix = {"openai-key": "test", "model": "gpt-4"} - responder = OpenAIResponder(config_no_fix) - - original_answer = '{"answer": "test"}' - result = await responder.fix(original_answer) - - self.assertEqual(result, original_answer) + def test_no_repair_path_exists(self): + """ENV-18: the repair path is gone — no fix() on the responder.""" + self.assertFalse(hasattr(self.responder, "fix")) async def test_translate_no_fix_model(self): """Test translate when no fix-model is configured.""" diff --git a/tests/test_spec_defects.py b/tests/test_spec_defects.py new file mode 100644 index 0000000..ebfed0c --- /dev/null +++ b/tests/test_spec_defects.py @@ -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) diff --git a/tests/test_spec_ops.py b/tests/test_spec_ops.py new file mode 100644 index 0000000..196467c --- /dev/null +++ b/tests/test_spec_ops.py @@ -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()) diff --git a/tests/test_spec_saf.py b/tests/test_spec_saf.py new file mode 100644 index 0000000..11bbbe2 --- /dev/null +++ b/tests/test_spec_saf.py @@ -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) diff --git a/tests/test_spec_structured.py b/tests/test_spec_structured.py new file mode 100644 index 0000000..b735539 --- /dev/null +++ b/tests/test_spec_structured.py @@ -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)