From e21c26229948573f03235858639e8606f84be343 Mon Sep 17 00:00:00 2001 From: Oleksandr Kozachuk Date: Mon, 13 Jul 2026 16:33:48 +0200 Subject: [PATCH] image input pipeline: content-hash cache, data-url vision, picture_edit real --- config.toml | 4 + fjerkroa_bot/ai_responder.py | 24 +++- fjerkroa_bot/discord_bot.py | 32 +++++- fjerkroa_bot/images.py | 124 ++++++++++++++++++++ fjerkroa_bot/openai_responder.py | 27 +++++ fjerkroa_bot/persistence.py | 55 ++++++++- specs/SPEC-004-images.md | 50 ++++++++ tests/test_spec_imgcache.py | 190 +++++++++++++++++++++++++++++++ 8 files changed, 496 insertions(+), 10 deletions(-) create mode 100644 fjerkroa_bot/images.py create mode 100644 tests/test_spec_imgcache.py diff --git a/config.toml b/config.toml index 2115087..2da7ecd 100644 --- a/config.toml +++ b/config.toml @@ -55,3 +55,7 @@ enable-game-info = true # image-model = "gpt-image-2" # default; dall-e-3 gets clamped to n=1 # image-size = "1024x1024" # image-quality = "medium" # passed through only when set +# Image input pipeline (SPEC-004, FDB-010) — active with history-directory: +# image-cache-mb = 500 # LRU cap (ggg: consider 2000 — screenshots) +# image-cache-ttl-days = 90 +# image-max-bytes = 8388608 # 8 MB upload cap diff --git a/fjerkroa_bot/ai_responder.py b/fjerkroa_bot/ai_responder.py index 2551411..6f4be01 100644 --- a/fjerkroa_bot/ai_responder.py +++ b/fjerkroa_bot/ai_responder.py @@ -12,6 +12,7 @@ from pathlib import Path from pprint import pformat from typing import Any, Dict, List, Optional, Tuple, Union +from .images import ImageCache from .memory import MemoryManager from .persistence import PersistentStore @@ -153,17 +154,16 @@ class AIResponder(AIResponderBase): if stored_memory is not None: self.memory = stored_memory self.memory_manager = MemoryManager(self.store, lambda: self.config, self.consolidate, self.channel) + self.image_cache: Optional[ImageCache] = None + if self.store is not None: + self.image_cache = ImageCache(self.store, Path(self.config["history-directory"]).expanduser() / "images", lambda: self.config) logging.info(f"memmory:\n{self.memory}") # Dynamic values move to a context suffix so the persona prefix # stays byte-stable for the prompt cache (ENV-20) DYNAMIC_PLACEHOLDERS = ("{date}", "{time}", "{news}", "{memory}") - def message(self, message: AIMessage, limit: Optional[int] = None) -> List[Dict[str, Any]]: - messages = [] - persona = self.config.get(self.channel, self.config["system"]) - for placeholder in self.DYNAMIC_PLACEHOLDERS: - persona = persona.replace(placeholder, "") + def _context_lines(self, message: AIMessage) -> List[str]: context = [f"date: {time.strftime('%Y-%m-%d')} ({time.strftime('%A')})", f"time: {time.strftime('%H:%M:%S')}"] news_feed = self.config.get("news") if news_feed and os.path.exists(news_feed): @@ -173,7 +173,19 @@ class AIResponder(AIResponderBase): memory_block = self.memory_manager.memory_block(participants, self.memory) if memory_block: context.append("memory:\n" + memory_block) - system = persona.rstrip() + "\n\n## Context\n" + "\n".join(context) + if self.image_cache is not None: + recent_images = self.image_cache.recent(message.channel, 4) + if recent_images: + # the model cannot use picture_edit unless told images exist (IMG-16) + context.append(f"recent images in this channel: {len(recent_images)} (set picture_edit=true to edit/remix the newest)") + return context + + def message(self, message: AIMessage, limit: Optional[int] = None) -> List[Dict[str, Any]]: + messages = [] + persona = self.config.get(self.channel, self.config["system"]) + for placeholder in self.DYNAMIC_PLACEHOLDERS: + persona = persona.replace(placeholder, "") + system = persona.rstrip() + "\n\n## Context\n" + "\n".join(self._context_lines(message)) messages.append({"role": "system", "content": system}) if limit is not None: while len(self.history) > limit: diff --git a/fjerkroa_bot/discord_bot.py b/fjerkroa_bot/discord_bot.py index 480aff2..caa8972 100644 --- a/fjerkroa_bot/discord_bot.py +++ b/fjerkroa_bot/discord_bot.py @@ -197,6 +197,8 @@ class FjerkroaBot(commands.Bot): removed += self.airesponder.store.delete_history_of_user(user) # facts + observations + episode traces (MEM-09) removed += self.airesponder.store.purge_user_memory(user) + if self.airesponder.image_cache is not None: + removed += self.airesponder.image_cache.purge_user(user) # IMG-14 logging.info(f"forgetme: removed {removed} entries for {user}") await message.channel.send( f"Removed your messages, facts and memory traces ({removed} entries).", @@ -337,6 +339,8 @@ class FjerkroaBot(commands.Bot): async def on_message_delete(self, message): airesponder = self.get_ai_responder(self.get_channel_name(message.channel)) + if airesponder.image_cache is not None: + airesponder.image_cache.purge_message(str(message.id)) # IMG-14 await airesponder.observe_event(message.author.name, "delete", f"deleted: {message.content}") def on_config_file_modified(self, event): @@ -410,14 +414,24 @@ class FjerkroaBot(commands.Bot): msg = AIMessage( message.author.name, message_content, channel_name, self.user in message.mentions or isinstance(message.channel, DMChannel) ) + airesponder = self.get_ai_responder(channel_name) if message.attachments: for attachment in message.attachments: if not msg.urls: msg.urls = [] - msg.urls.append(attachment.url) + if airesponder.image_cache is not None: + # cache-first: CDN URLs never travel further (IMG-10/11) + sha = await airesponder.image_cache.ingest_url(attachment.url, channel_name, message.author.name, str(message.id)) + if sha is not None: + recent = airesponder.image_cache.recent(channel_name, 8) + ext = next((row["ext"] for row in recent if row["sha256"] == sha), "png") + data_url = airesponder.image_cache.data_url(sha, ext) + if data_url: + msg.urls.append(data_url) + else: + msg.urls.append(attachment.url) # Reply/ignore classifier gate — direct messages bypass (BEH-01/02/03/07) - airesponder = self.get_ai_responder(channel_name) handled, factual = await self._classifier_gate(message, msg, airesponder, channel_name) if handled: return @@ -462,7 +476,19 @@ class FjerkroaBot(commands.Bot): """Send the answer paced, split and with images on the last part (BEH-04/05/06)""" files = None if response.picture is not None: - buffers = await airesponder.draw(response.picture, getattr(response, "picture_count", 1)) + count = getattr(response, "picture_count", 1) + channel_name = self.get_channel_name(answer_channel) + buffers = None + if getattr(response, "picture_edit", False) and airesponder.image_cache is not None: + sources = airesponder.image_cache.recent_paths(channel_name, 4) + if sources: + buffers = await airesponder.edit_openai(response.picture, sources, count) + if buffers is None: + # empty cache or no edit request: plain generation (IMG-13 fallback) + buffers = await airesponder.draw(response.picture, count) + if airesponder.image_cache is not None: + for buffer in buffers: + airesponder.image_cache.ingest_bytes(buffer.getvalue(), channel_name, "assistant", None) # IMG-15 files = [discord.File(fp=buffer, filename=f"image-{index}.png") for index, buffer in enumerate(buffers)] parts = split_answer(response.answer, int(self.config.get("split-threshold", 1200)), int(self.config.get("split-max-parts", 3))) pace = float(self.config.get("typing-chars-per-second", 0) or 0) diff --git a/fjerkroa_bot/images.py b/fjerkroa_bot/images.py new file mode 100644 index 0000000..205e279 --- /dev/null +++ b/fjerkroa_bot/images.py @@ -0,0 +1,124 @@ +"""Content-hash image cache (SPEC-004, FDB-010). + +Attachments are downloaded once, sniffed, stored under their sha256 +and served to vision as data: URLs — Discord's expiring CDN links +never travel further (IMG-10/11). LRU + TTL keep the cache bounded +(IMG-12); deletions and !forgetme propagate here (IMG-14). +""" + +import base64 +import hashlib +import logging +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +import aiohttp + +from .persistence import PersistentStore + +DEFAULT_CACHE_MB = 500 +DEFAULT_TTL_DAYS = 90 +DEFAULT_MAX_BYTES = 8 * 1024 * 1024 +DOWNLOAD_TIMEOUT_S = 20 + +MAGIC = [ + (b"\x89PNG", "png"), + (b"\xff\xd8\xff", "jpg"), + (b"GIF87a", "gif"), + (b"GIF89a", "gif"), +] + + +def sniff_ext(data: bytes) -> Optional[str]: + """Extension from magic bytes only — names and headers lie (IMG-10).""" + for magic, ext in MAGIC: + if data.startswith(magic): + return ext + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "webp" + return None + + +class ImageCache: + def __init__(self, store: PersistentStore, root: Path, config_getter: Callable[[], Dict[str, Any]]) -> None: + self.store = store + self.root = Path(root) + self._config = config_getter + self.root.mkdir(parents=True, exist_ok=True) + + def _path(self, sha256: str, ext: str) -> Path: + return self.root / f"{sha256}.{ext}" + + def ingest_bytes(self, data: bytes, channel: str, user: str, message_id: Optional[str]) -> Optional[str]: + ext = sniff_ext(data) + if ext is None: + logging.warning(f"image cache: rejected non-image bytes from {user} (IMG-10)") + return None + if len(data) > int(self._config().get("image-max-bytes", DEFAULT_MAX_BYTES)): + logging.warning(f"image cache: rejected oversized upload from {user} ({len(data)} bytes)") + return None + sha256 = hashlib.sha256(data).hexdigest() + path = self._path(sha256, ext) + if not path.exists(): + path.write_bytes(data) + self.store.image_add(sha256, channel, user, message_id, ext, len(data)) + self.evict() + return sha256 + + async def ingest_url(self, url: str, channel: str, user: str, message_id: Optional[str]) -> Optional[str]: + try: + data = await self._download(url) + except Exception as err: + logging.warning(f"image cache: download failed for {user}: {repr(err)}") + return None + return self.ingest_bytes(data, channel, user, message_id) + + async def _download(self, url: str) -> bytes: + limit = int(self._config().get("image-max-bytes", DEFAULT_MAX_BYTES)) + timeout = aiohttp.ClientTimeout(total=DOWNLOAD_TIMEOUT_S) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url) as response: + response.raise_for_status() + return await response.content.read(limit + 1) + + def data_url(self, sha256: str, ext: str) -> Optional[str]: + path = self._path(sha256, ext) + if not path.exists(): + return None + mime = "jpeg" if ext == "jpg" else ext + return f"data:image/{mime};base64," + base64.b64encode(path.read_bytes()).decode() + + def recent(self, channel: str, count: int) -> List[Dict[str, Any]]: + return self.store.images_recent(channel, count) + + def recent_paths(self, channel: str, count: int) -> List[Path]: + paths = [self._path(row["sha256"], row["ext"]) for row in self.recent(channel, count)] + return [path for path in paths if path.exists()] + + def _remove(self, sha256: str, ext: str) -> None: + self._path(sha256, ext).unlink(missing_ok=True) + self.store.images_delete(sha256) + + def evict(self) -> None: + """TTL first, then LRU down to the byte cap (IMG-12).""" + config = self._config() + for row in self.store.images_expired(int(config.get("image-cache-ttl-days", DEFAULT_TTL_DAYS))): + self._remove(row["sha256"], row["ext"]) + cap = int(config.get("image-cache-mb", DEFAULT_CACHE_MB)) * 1024 * 1024 + while self.store.images_total_bytes() > cap: + victims = self.store.images_oldest(1) + if not victims: + break + self._remove(victims[0]["sha256"], victims[0]["ext"]) + + def purge_user(self, user: str) -> int: + rows = self.store.images_for_user(user) + for row in rows: + self._remove(row["sha256"], row["ext"]) + return len(rows) + + def purge_message(self, message_id: str) -> int: + rows = self.store.images_for_message(message_id) + for row in rows: + self._remove(row["sha256"], row["ext"]) + return len(rows) diff --git a/fjerkroa_bot/openai_responder.py b/fjerkroa_bot/openai_responder.py index ad4b373..5a17755 100644 --- a/fjerkroa_bot/openai_responder.py +++ b/fjerkroa_bot/openai_responder.py @@ -96,6 +96,10 @@ async def openai_image(client, *args, **kwargs): return await client.images.generate(*args, **kwargs) +async def openai_image_edit(client, *args, **kwargs): + return await client.images.edit(*args, **kwargs) + + class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): def __init__(self, config: Dict[str, Any], channel: Optional[str] = None) -> None: super().__init__(config, channel) @@ -354,6 +358,29 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): logging.debug(f"Full traceback: {traceback.format_exc()}") return None, limit + async def edit_openai(self, description: str, paths: List[Any], count: int = 1) -> List[BytesIO]: + """Edit/remix from cached inputs, ≤4 files (IMG-13).""" + if not self.ledger.budget_ok(): + raise RuntimeError("daily budget exhausted - refusing image edit") + model = self.config.get("image-model", "gpt-image-2") + handles = [open(path, "rb") for path in paths[:4]] + try: + response = await openai_image_edit( + self.client, + model=model, + image=handles if len(handles) > 1 else handles[0], + prompt=description, + n=max(1, min(int(count), 4)), + size=self.config.get("image-size", "1024x1024"), + ) + finally: + for handle in handles: + handle.close() + buffers = [BytesIO(base64.b64decode(item.b64_json)) for item in response.data] + self.ledger.add_images(len(buffers)) + logging.info(f"edited {len(buffers)} image(s) on {model} from {len(handles)} input(s)") + return buffers + async def classify(self, message: Any, history_tail: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: """~100-token reply/factual/emoji verdict on classifier-model (BEH-01/03).""" if "classifier-model" not in self.config or not self.ledger.budget_ok(): diff --git a/fjerkroa_bot/persistence.py b/fjerkroa_bot/persistence.py index f6fa2af..8e8291e 100644 --- a/fjerkroa_bot/persistence.py +++ b/fjerkroa_bot/persistence.py @@ -13,7 +13,7 @@ from contextlib import closing from pathlib import Path from typing import Any, Dict, List, Optional -SCHEMA_VERSION = 3 +SCHEMA_VERSION = 4 class PersistentStore: @@ -60,6 +60,12 @@ class PersistentStore: ) # Legacy single-string memories carry over as one episode each (MEM-08) conn.execute("INSERT INTO episodes (channel, summary) SELECT channel, content FROM memory") + if version < 4: + conn.execute( + "CREATE TABLE IF NOT EXISTS images (id INTEGER PRIMARY KEY, sha256 TEXT UNIQUE NOT NULL, channel TEXT NOT NULL," + " user TEXT NOT NULL, message_id TEXT, ext TEXT NOT NULL, bytes INTEGER NOT NULL," + " created_at TEXT NOT NULL DEFAULT (datetime('now')))" + ) if version < SCHEMA_VERSION: conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") os.chmod(self.db_path, 0o600) # conversation data (PER-04) @@ -189,6 +195,53 @@ class PersistentStore: ) return cursor.rowcount + # --- image cache index (SPEC-004, FDB-010) --- + + def image_add(self, sha256: str, channel: str, user: str, message_id: Optional[str], ext: str, nbytes: int) -> None: + with closing(self._connect()) as conn, conn: + conn.execute( + "INSERT OR IGNORE INTO images (sha256, channel, user, message_id, ext, bytes) VALUES (?, ?, ?, ?, ?, ?)", + (sha256, channel, user, message_id, ext, nbytes), + ) + + def images_recent(self, channel: str, count: int) -> List[Dict[str, Any]]: + with closing(self._connect()) as conn: + rows = conn.execute( + "SELECT sha256, user, ext FROM images WHERE channel = ? ORDER BY id DESC LIMIT ?", (channel, count) + ).fetchall() + return [{"sha256": row[0], "user": row[1], "ext": row[2]} for row in rows] + + def images_total_bytes(self) -> int: + with closing(self._connect()) as conn: + row = conn.execute("SELECT COALESCE(SUM(bytes), 0) FROM images").fetchone() + return int(row[0]) + + def images_oldest(self, count: int) -> List[Dict[str, Any]]: + with closing(self._connect()) as conn: + rows = conn.execute("SELECT sha256, ext, bytes FROM images ORDER BY id LIMIT ?", (count,)).fetchall() + return [{"sha256": row[0], "ext": row[1], "bytes": row[2]} for row in rows] + + def images_expired(self, ttl_days: int) -> List[Dict[str, Any]]: + with closing(self._connect()) as conn: + rows = conn.execute( + "SELECT sha256, ext FROM images WHERE created_at < datetime('now', ?)", (f"-{int(ttl_days)} days",) + ).fetchall() + return [{"sha256": row[0], "ext": row[1]} for row in rows] + + def images_delete(self, sha256: str) -> None: + with closing(self._connect()) as conn, conn: + conn.execute("DELETE FROM images WHERE sha256 = ?", (sha256,)) + + def images_for_user(self, user: str) -> List[Dict[str, Any]]: + with closing(self._connect()) as conn: + rows = conn.execute("SELECT sha256, ext FROM images WHERE user = ?", (user,)).fetchall() + return [{"sha256": row[0], "ext": row[1]} for row in rows] + + def images_for_message(self, message_id: str) -> List[Dict[str, Any]]: + with closing(self._connect()) as conn: + rows = conn.execute("SELECT sha256, ext FROM images WHERE message_id = ?", (message_id,)).fetchall() + return [{"sha256": row[0], "ext": row[1]} for row in rows] + def purge_user_memory(self, user: str) -> int: """Facts, observations and episode traces of one user (MEM-09).""" removed = 0 diff --git a/specs/SPEC-004-images.md b/specs/SPEC-004-images.md index 7bfd650..0efe95f 100644 --- a/specs/SPEC-004-images.md +++ b/specs/SPEC-004-images.md @@ -37,3 +37,53 @@ The translate-before-draw step is deleted: the model's picture prompt reaches the image API verbatim (current image models handle Norwegian/German natively). The `translate()` method and its `fix-model` dependency are gone (closes D-009). + +## Input pipeline (FDB-010) + +Attachments live in a content-hash cache +(`/images/.`, index in the store, +schema v4). Active only with a store; without one the legacy CDN-URL +path remains. + +### IMG-10 — Attachments are ingested at message time (coverage: test) + +Every image attachment is downloaded immediately (timeout, size cap +`image-max-bytes` default 8 MB) and stored under its content hash. +Only sniffed png/jpeg/gif/webp bytes are accepted — extension and +declared MIME are ignored (attacker-controlled). Rejected content is +dropped and logged (D11 root fix + cache-abuse hardening). + +### IMG-11 — Vision reads from the cache, never CDN URLs (coverage: test) + +Vision parts are `data:` URLs built from cached bytes. Discord's +signed, expiring CDN URLs never reach the model or the history. + +### IMG-12 — The cache is capped and aged (coverage: test) + +`image-cache-mb` (default 500) LRU-evicts oldest-first; +`image-cache-ttl-days` (default 90) ages entries out. Eviction always +removes file and index row together. + +### IMG-13 — picture_edit edits the newest channel images (coverage: test) + +`picture_edit=true` calls `images.edit` with up to the 4 newest +cached images of the answer channel as inputs (API max is 16; 4 keeps +prompts sane). An empty cache falls back to plain generation — the +flag alone must never fail a reply. + +### IMG-14 — Deletion propagates to the cache (coverage: test) + +Deleting a Discord message purges its cached images; `!forgetme` +purges all of the user's images — files and rows (extends +SAF-08/MEM-09). + +### IMG-15 — Generated images join the cache (coverage: test) + +Bot-generated images are ingested like uploads (user `assistant`), so +"make a variant of that" remix chains work on the bot's own output. + +### IMG-16 — The prompt announces editable images (coverage: test) + +When the answer channel has cached images, the context suffix states +how many and that `picture_edit=true` edits the newest — the model +cannot use a capability it does not know about. diff --git a/tests/test_spec_imgcache.py b/tests/test_spec_imgcache.py new file mode 100644 index 0000000..52e55c1 --- /dev/null +++ b/tests/test_spec_imgcache.py @@ -0,0 +1,190 @@ +"""Unit coverage for SPEC-004 input pipeline (IMG-10..16).""" + +import base64 +import sqlite3 +import tempfile +import unittest +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from fjerkroa_bot.ai_responder import AIMessage, AIResponse +from fjerkroa_bot.images import ImageCache, sniff_ext +from fjerkroa_bot.openai_responder import OpenAIResponder +from fjerkroa_bot.persistence import PersistentStore + +from .test_bdd_envelope import FakeModelResponder +from .test_spec_ops import OpsBase + +PNG = b"\x89PNG\r\n\x1a\n" + b"x" * 64 + + +def make_cache(tmp, config=None): + store = PersistentStore(Path(tmp) / "bot.db") + cache = ImageCache(store, Path(tmp) / "images", lambda: config or {}) + return store, cache + + +class TestIngest(unittest.TestCase): + def test_sniffed_types_only(self): + """IMG-10: magic bytes decide; garbage and foreign types are rejected.""" + self.assertEqual(sniff_ext(PNG), "png") + self.assertEqual(sniff_ext(b"\xff\xd8\xff\xe0rest"), "jpg") + self.assertIsNone(sniff_ext(b"MZ\x90\x00 definitely-an-exe")) + with tempfile.TemporaryDirectory() as tmp: + store, cache = make_cache(tmp) + self.assertIsNone(cache.ingest_bytes(b"not an image", "chat", "alice", "1")) + sha = cache.ingest_bytes(PNG, "chat", "alice", "1") + self.assertIsNotNone(sha) + self.assertTrue((Path(tmp) / "images" / f"{sha}.png").exists()) + self.assertEqual(store.images_recent("chat", 5)[0]["sha256"], sha) + + def test_size_cap(self): + """IMG-10: oversized uploads are dropped.""" + with tempfile.TemporaryDirectory() as tmp: + _, cache = make_cache(tmp, {"image-max-bytes": 32}) + self.assertIsNone(cache.ingest_bytes(PNG, "chat", "alice", "1")) + + +class TestVisionDataUrls(OpsBase): + async def test_attachment_becomes_data_url(self): + """IMG-11: the model sees a data: URL, never the CDN link.""" + with tempfile.TemporaryDirectory() as tmp: + _, cache = make_cache(tmp) + self.bot.airesponder.image_cache = cache + self.bot.respond = AsyncMock() + message = self.public_msg("look at this") + attachment = Mock() + attachment.url = "https://cdn.discordapp.com/attachments/1/2/cat.png?ex=deadbeef" + message.attachments = [attachment] + message.id = 42 + with patch.object(ImageCache, "_download", new_callable=AsyncMock, return_value=PNG): + await self.bot.on_message(message) + sent_msg = self.bot.respond.await_args.args[0] + self.assertTrue(sent_msg.urls[0].startswith("data:image/png;base64,")) + self.assertNotIn("cdn.discordapp.com", sent_msg.urls[0]) + + +class TestEviction(unittest.TestCase): + def test_lru_cap(self): + """IMG-12: byte cap evicts oldest first, file + row together.""" + big = b"\x89PNG\r\n\x1a\n" + b"a" * (700 * 1024) + big2 = b"\x89PNG\r\n\x1a\n" + b"b" * (700 * 1024) + with tempfile.TemporaryDirectory() as tmp: + store, cache = make_cache(tmp, {"image-cache-mb": 1}) + first = cache.ingest_bytes(big, "chat", "alice", "1") + second = cache.ingest_bytes(big2, "chat", "alice", "2") + shas = [row["sha256"] for row in store.images_recent("chat", 5)] + self.assertNotIn(first, shas) + self.assertIn(second, shas) + self.assertFalse((Path(tmp) / "images" / f"{first}.png").exists()) + + def test_ttl(self): + """IMG-12: entries past image-cache-ttl-days age out.""" + with tempfile.TemporaryDirectory() as tmp: + store, cache = make_cache(tmp, {"image-cache-ttl-days": 30}) + sha = cache.ingest_bytes(PNG, "chat", "alice", "1") + with sqlite3.connect(store.db_path) as conn: + conn.execute("UPDATE images SET created_at = datetime('now', '-60 days') WHERE sha256 = ?", (sha,)) + cache.evict() + self.assertEqual(store.images_recent("chat", 5), []) + self.assertFalse((Path(tmp) / "images" / f"{sha}.png").exists()) + + +class TestEditPath(OpsBase): + async def prepare(self, with_images): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + _, cache = make_cache(self.tmp.name) + self.bot.airesponder.image_cache = cache + if with_images: + cache.ingest_bytes(PNG, "chat", "alice", "1") + self.bot.airesponder.edit_openai = AsyncMock(return_value=[__import__("io").BytesIO(PNG)]) + self.bot.airesponder.draw = AsyncMock(return_value=[__import__("io").BytesIO(PNG)]) + response = AIResponse("her", True, "chat", None, "als wikinger", True, False) + channel = MagicMock() + channel.name = "chat" + channel.send = AsyncMock() + channel.typing = MagicMock(return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock())) + await self.bot.send_answer_with_typing(response, channel, self.bot.airesponder, factual=True) + + async def test_edit_uses_cached_sources(self): + """IMG-13: picture_edit + cached images -> images.edit path.""" + await self.prepare(with_images=True) + self.bot.airesponder.edit_openai.assert_awaited_once() + self.bot.airesponder.draw.assert_not_awaited() + + async def test_empty_cache_falls_back_to_generate(self): + """IMG-13: empty cache -> plain generation, the flag never fails a reply.""" + await self.prepare(with_images=False) + self.bot.airesponder.edit_openai.assert_not_awaited() + self.bot.airesponder.draw.assert_awaited_once() + + +class TestPurges(OpsBase): + async def test_message_delete_and_forgetme_purge_images(self): + """IMG-14: message deletion and !forgetme remove files + rows.""" + with tempfile.TemporaryDirectory() as tmp: + store, cache = make_cache(tmp) + self.bot.airesponder.image_cache = cache + cache.ingest_bytes(PNG, "chat", "alice", "99") + deleted = MagicMock() + deleted.id = 99 + deleted.content = "pic" + deleted.author.name = "alice" + deleted.channel = MagicMock() + await self.bot.on_message_delete(deleted) + self.assertEqual(store.images_recent("chat", 5), []) + cache.ingest_bytes(b"\x89PNG\r\n\x1a\n" + b"z" * 32, "chat", "alice", "100") + message = self.public_msg("!forgetme") + message.author.name = "alice" + await self.bot.on_message(message) + self.assertEqual(store.images_recent("chat", 5), []) + + +class TestGeneratedImagesCached(OpsBase): + async def test_bot_output_joins_cache(self): + """IMG-15: generated images are ingested as user 'assistant'.""" + with tempfile.TemporaryDirectory() as tmp: + store, cache = make_cache(tmp) + self.bot.airesponder.image_cache = cache + self.bot.airesponder.draw = AsyncMock(return_value=[__import__("io").BytesIO(PNG)]) + response = AIResponse("her", True, "chat", None, "en katt", False, False) + channel = MagicMock() + channel.name = "chat" + channel.send = AsyncMock() + await self.bot.send_answer_with_typing(response, channel, self.bot.airesponder, factual=True) + rows = store.images_recent("chat", 5) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["user"], "assistant") + + +class TestContextAnnouncesImages(unittest.IsolatedAsyncioTestCase): + def test_suffix_mentions_picture_edit(self): + """IMG-16: cached channel images are announced in the context suffix.""" + with tempfile.TemporaryDirectory() as tmp: + config = {"system": "s", "history-limit": 5, "history-directory": tmp} + responder = FakeModelResponder(config, "chat") + responder.image_cache.ingest_bytes(PNG, "chat", "alice", "1") + system = responder.message(AIMessage("alice", "hei", "chat"))[0]["content"] + self.assertIn("picture_edit", system) + self.assertIn("recent images in this channel: 1", system) + + +class TestEditOpenai(unittest.IsolatedAsyncioTestCase): + async def test_edit_call_shape_and_metering(self): + """IMG-13: images.edit gets the file handles, n clamped, ledger counts.""" + responder = OpenAIResponder({"openai-token": "t", "model": "m", "system": "s", "history-limit": 5}, "chat") + with tempfile.TemporaryDirectory() as tmp: + paths = [] + for index in range(2): + path = Path(tmp) / f"in{index}.png" + path.write_bytes(PNG) + paths.append(path) + api_result = Mock(data=[Mock(b64_json=base64.b64encode(b"out").decode())]) + with patch("fjerkroa_bot.openai_responder.openai_image_edit", new_callable=AsyncMock) as edit_mock: + edit_mock.return_value = api_result + buffers = await responder.edit_openai("wikinger", paths, 9) + self.assertEqual(buffers[0].read(), b"out") + self.assertEqual(edit_mock.await_args.kwargs["n"], 4) + self.assertEqual(len(edit_mock.await_args.kwargs["image"]), 2) + self.assertEqual(responder.ledger.images_today(), 1)