fix defects D1-D12 batch 1, structured envelope, saf gates + ops kill-switches
This commit is contained in:
@@ -23,3 +23,4 @@ last_updates.json
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.py,v
|
*.py,v
|
||||||
*.msg
|
*.msg
|
||||||
|
news_feed.py
|
||||||
|
|||||||
+30
-40
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user