From e7e51e42306f00ee18ef9547a609f318c711f920 Mon Sep 17 00:00:00 2001 From: Oleksandr Kozachuk Date: Mon, 13 Jul 2026 16:50:58 +0200 Subject: [PATCH] img-17: cache image-only posts; news pipeline notes --- fjerkroa_bot/discord_bot.py | 43 ++++++++++++++++++++++--------------- specs/SPEC-004-images.md | 8 +++++++ tests/test_spec_imgcache.py | 22 +++++++++++++++++++ 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/fjerkroa_bot/discord_bot.py b/fjerkroa_bot/discord_bot.py index caa8972..b802eb7 100644 --- a/fjerkroa_bot/discord_bot.py +++ b/fjerkroa_bot/discord_bot.py @@ -401,35 +401,44 @@ class FjerkroaBot(commands.Bot): def get_ai_responder(self, channel_name): return self.aichannels[channel_name] if channel_name in self.aichannels else self.airesponder + async def _ingest_attachments(self, message, channel_name: str, airesponder) -> list: + """Cache-first attachment handling; CDN URLs never travel further (IMG-10/11).""" + urls = [] + for attachment in message.attachments: + if airesponder.image_cache is None: + urls.append(attachment.url) + continue + 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: + urls.append(data_url) + return urls + async def handle_message_through_responder(self, message): """Handle a message through the AI responder""" message_content = str(message.content).strip() if message.reference and message.reference.resolved and isinstance(message.reference.resolved.content, str): reference_content = str(message.reference.resolved.content).replace("\n", "> \n") message_content = f"> {reference_content}\n\n{message_content}" + channel_name = self.get_channel_name(message.channel) + airesponder = self.get_ai_responder(channel_name) + attachment_urls = [] + if message.attachments: + attachment_urls = await self._ingest_attachments(message, channel_name, airesponder) if len(message_content) < 1: + # image-only posts: cached + observed, no reply (IMG-17) + if attachment_urls: + await airesponder.observe_event(message.author.name, "image", f"posted {len(attachment_urls)} image(s)") return message_content = self._resolve_mentions(message_content) - channel_name = self.get_channel_name(message.channel) 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 = [] - 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) + if attachment_urls: + msg.urls = attachment_urls # Reply/ignore classifier gate — direct messages bypass (BEH-01/02/03/07) handled, factual = await self._classifier_gate(message, msg, airesponder, channel_name) diff --git a/specs/SPEC-004-images.md b/specs/SPEC-004-images.md index 0efe95f..b650331 100644 --- a/specs/SPEC-004-images.md +++ b/specs/SPEC-004-images.md @@ -82,6 +82,14 @@ SAF-08/MEM-09). 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-17 — Image-only messages are cached (coverage: test) + +A message consisting only of attachments (no text) is ingested into +the cache and recorded as an observation, even though no reply is +produced — the image must be available for later `picture_edit` and +vision follow-ups. (Previously the empty-text early-return dropped +such posts entirely.) + ### IMG-16 — The prompt announces editable images (coverage: test) When the answer channel has cached images, the context suffix states diff --git a/tests/test_spec_imgcache.py b/tests/test_spec_imgcache.py index 52e55c1..a0fed1a 100644 --- a/tests/test_spec_imgcache.py +++ b/tests/test_spec_imgcache.py @@ -158,6 +158,28 @@ class TestGeneratedImagesCached(OpsBase): self.assertEqual(rows[0]["user"], "assistant") +class TestImageOnlyMessages(OpsBase): + async def test_image_only_post_cached_no_reply(self): + """IMG-17: attachment without text -> cached + observed, no reply.""" + with tempfile.TemporaryDirectory() as tmp: + store, cache = make_cache(tmp) + self.bot.airesponder.image_cache = cache + self.bot.airesponder.observe_event = AsyncMock() + self.bot.respond = AsyncMock() + message = self.public_msg("") + message.content = "" + message.channel.name = "chat" + attachment = Mock() + attachment.url = "https://cdn.discordapp.com/attachments/1/2/silent.png" + message.attachments = [attachment] + message.id = 77 + with patch.object(ImageCache, "_download", new_callable=AsyncMock, return_value=PNG): + await self.bot.on_message(message) + self.assertEqual(len(store.images_recent("chat", 5)), 1) + self.bot.airesponder.observe_event.assert_awaited_once() + self.bot.respond.assert_not_awaited() + + class TestContextAnnouncesImages(unittest.IsolatedAsyncioTestCase): def test_suffix_mentions_picture_edit(self): """IMG-16: cached channel images are announced in the context suffix."""