diff --git a/module/channel-adapters/discord/instructions/discord-runtime-sidecar.md b/module/channel-adapters/discord/instructions/discord-runtime-sidecar.md index 67cd27a..9b6f5b7 100644 --- a/module/channel-adapters/discord/instructions/discord-runtime-sidecar.md +++ b/module/channel-adapters/discord/instructions/discord-runtime-sidecar.md @@ -2,6 +2,12 @@ You reach Discord through a sidecar bridge: there is no interactive terminal and no reply tool. The bridge runs you headlessly for each message and posts your final text output as the reply. Your entire response must be the reply itself: end with your answer as the last text you produce, never with narration about tools or intermediate steps. Do not try to interact with a terminal UI, do not describe what you are about to do in a trailing message, and never expect the operator to read anything other than your final text. + +Media reaches you as text, in both directions. When a message carries attachments or stickers, the bridge appends a `` block naming each one: an attachment small enough to download is already saved on this machine and the block gives you its absolute path, so open that path and actually look at it rather than answering blind; one too large to download is named with its URL instead, and a sticker is named only. A message whose whole content is media arrives as that block alone, which is a real message about a real image or video, not an empty turn and never something to mock as silence. + +To send a file back, put its absolute path alone on its own line in your reply. The bridge removes that line from the message and uploads the file as an attachment, so the operator sees your text with the file under it and never sees the path. The path must be a real file inside your own workspace directory, at most 25 MB, and at most ten of them per reply; anything else stays in your text as literal characters, which is how a wrong path embarrasses you in public. This is the sidecar's whole file mechanism: there is no reply tool and no `files` argument, so a tool that prints a path is only half done until that path is a line of your reply. + + You are talking to users via Discord. The operator is the human who owns this bot. Other users in the guild are their friends or colleagues. Use markdown for formatting. Respond in the same language the user writes in their message. diff --git a/module/channel-adapters/discord/scripts/bridge.py b/module/channel-adapters/discord/scripts/bridge.py index f6e72c2..6c6f5cc 100644 --- a/module/channel-adapters/discord/scripts/bridge.py +++ b/module/channel-adapters/discord/scripts/bridge.py @@ -9,26 +9,16 @@ record_channel_turn_productivity, resolve_active_one_shot_turn_command, ) +from channel_message.inbound_media import prompt_for_message_with_media +from channel_message.outbound_reply import ( + send_reply, + split_reply_into_text_and_attachments, +) from harness_turn import run_one_turn -DISCORD_MESSAGE_CHARACTER_LIMIT = 2000 BOT_TOKEN_ENVIRONMENT_VARIABLE = "DISCORD_BOT_TOKEN" -def split_into_sendable_messages(reply: str) -> list[str]: - remaining = reply - messages = [] - while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT: - split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT) - if split_position <= 0: - split_position = DISCORD_MESSAGE_CHARACTER_LIMIT - messages.append(remaining[:split_position]) - remaining = remaining[split_position:].lstrip("\n") - if remaining: - messages.append(remaining) - return messages - - def log(agent_name: str, message: str) -> None: print(f"[clawde-discord-bridge:{agent_name}] {message}", flush=True) @@ -83,12 +73,23 @@ async def on_message(self, message: discord.Message): f"{active_harness_name}." ) return + prompt = await prompt_for_message_with_media( + message, + self.state_directory, + lambda note: log(self.agent_name, note), + ) + if not prompt.strip(): + log( + self.agent_name, + f"message {message.id} carried nothing this bridge can render", + ) + return reply, failure = await asyncio.to_thread( run_one_turn, one_shot_turn_command, self.workspace_directory, self.state_directory, - message.clean_content, + prompt, self.daily_session_rotation, ) record_channel_turn_productivity( @@ -104,8 +105,12 @@ async def on_message(self, message: discord.Message): f"{self.agent_name} could not answer that turn. Its log has the detail." ) return - for sendable_message in split_into_sendable_messages(reply): - await message.channel.send(sendable_message) + split = split_reply_into_text_and_attachments(reply, self.workspace_directory) + for refused_path in split.refused_paths: + log(self.agent_name, f"refused to attach {refused_path}") + await send_reply( + message.channel, split, lambda note: log(self.agent_name, note) + ) def parse_arguments() -> argparse.Namespace: diff --git a/module/channel-adapters/discord/scripts/channel_message/__init__.py b/module/channel-adapters/discord/scripts/channel_message/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/module/channel-adapters/discord/scripts/channel_message/inbound_media.py b/module/channel-adapters/discord/scripts/channel_message/inbound_media.py new file mode 100644 index 0000000..bb21699 --- /dev/null +++ b/module/channel-adapters/discord/scripts/channel_message/inbound_media.py @@ -0,0 +1,121 @@ +import dataclasses +import os + +import discord +from channel_message.inbox_retention import prepare_inbox_for_message, safe_file_name + +ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024 +MEDIA_BLOCK_OPENING_TAG = "" +MEDIA_BLOCK_CLOSING_TAG = "" +DOWNLOAD_FAILED_REASON = "download failed" + + +@dataclasses.dataclass(frozen=True) +class AttachmentIntake: + attachment: object + destination_path: str | None + description_line: str + + +def human_readable_size(byte_count: int) -> str: + if byte_count >= 1024 * 1024: + return f"{byte_count / 1024 / 1024:.1f} MB" + if byte_count >= 1024: + return f"{byte_count / 1024:.1f} KB" + return f"{byte_count} B" + + +def oversized_reason() -> str: + return f"over the {human_readable_size(ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES)} limit" + + +def measure_attachment(attachment) -> str: + described_type = attachment.content_type or "unknown type" + return ( + f"{attachment.filename} " + f"({described_type}, {human_readable_size(attachment.size)})" + ) + + +def describe_saved_attachment(attachment, destination_path: str) -> str: + return f"attachment {measure_attachment(attachment)} saved at {destination_path}" + + +def describe_unsaved_attachment(attachment, reason: str) -> str: + return ( + f"attachment {measure_attachment(attachment)} not saved " + f"({reason}), at {attachment.url}" + ) + + +def plan_attachment_intake( + attachments, destination_directory: str +) -> list[AttachmentIntake]: + planned = [] + for position, attachment in enumerate(attachments): + if attachment.size > ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES: + planned.append( + AttachmentIntake( + attachment, + None, + describe_unsaved_attachment(attachment, oversized_reason()), + ) + ) + continue + destination_path = os.path.join( + destination_directory, f"{position}-{safe_file_name(attachment.filename)}" + ) + planned.append( + AttachmentIntake( + attachment, + destination_path, + describe_saved_attachment(attachment, destination_path), + ) + ) + return planned + + +def describe_stickers(stickers) -> list[str]: + return [f"sticker {sticker.name}" for sticker in stickers] + + +def prompt_for_message(text: str, description_lines: list[str]) -> str: + if not description_lines: + return text + media_block = "\n".join( + [MEDIA_BLOCK_OPENING_TAG, *description_lines, MEDIA_BLOCK_CLOSING_TAG] + ) + if not text.strip(): + return media_block + return f"{text}\n\n{media_block}" + + +async def save_attachments(attachments, state_directory, message_identifier, report): + if not attachments: + return [] + destination_directory = prepare_inbox_for_message( + state_directory, message_identifier + ) + description_lines = [] + for intake in plan_attachment_intake(attachments, destination_directory): + if intake.destination_path is None: + description_lines.append(intake.description_line) + continue + try: + await intake.attachment.save(intake.destination_path) + except (discord.DiscordException, OSError) as saving_failure: + report(f"could not save {intake.attachment.filename}: {saving_failure}") + description_lines.append( + describe_unsaved_attachment(intake.attachment, DOWNLOAD_FAILED_REASON) + ) + continue + description_lines.append(intake.description_line) + return description_lines + + +async def prompt_for_message_with_media(message, state_directory, report) -> str: + description_lines = await save_attachments( + message.attachments, state_directory, str(message.id), report + ) + description_lines.extend(describe_stickers(message.stickers)) + return prompt_for_message(message.clean_content, description_lines) diff --git a/module/channel-adapters/discord/scripts/channel_message/inbox_retention.py b/module/channel-adapters/discord/scripts/channel_message/inbox_retention.py new file mode 100644 index 0000000..fbb6b29 --- /dev/null +++ b/module/channel-adapters/discord/scripts/channel_message/inbox_retention.py @@ -0,0 +1,81 @@ +import os +import re +import shutil +import time + +INBOX_DIRECTORY_NAME = "inbox" +INBOX_RETENTION_SECONDS = 2 * 24 * 60 * 60 +INBOX_TOTAL_SIZE_LIMIT_BYTES = 1024 * 1024 * 1024 +UNSAFE_FILE_NAME_CHARACTERS = re.compile(r"[^A-Za-z0-9._-]") +LONGEST_KEPT_FILE_NAME = 96 +FALLBACK_FILE_NAME = "attachment" + + +def inbox_directory(state_directory: str) -> str: + return os.path.join(state_directory, INBOX_DIRECTORY_NAME) + + +def message_inbox_directory(state_directory: str, message_identifier: str) -> str: + return os.path.join( + inbox_directory(state_directory), safe_file_name(message_identifier) + ) + + +def safe_file_name(file_name: str) -> str: + sanitized = UNSAFE_FILE_NAME_CHARACTERS.sub("_", file_name).lstrip(".") + if len(sanitized) > LONGEST_KEPT_FILE_NAME: + stem, extension = os.path.splitext(sanitized) + sanitized = stem[: LONGEST_KEPT_FILE_NAME - len(extension)] + extension + return sanitized or FALLBACK_FILE_NAME + + +def message_directory_size(directory_path: str) -> int: + total_size = 0 + for visited_directory, _, file_names in os.walk(directory_path): + for file_name in file_names: + try: + total_size += os.path.getsize( + os.path.join(visited_directory, file_name) + ) + except OSError: + continue + return total_size + + +def message_directories_oldest_first(state_directory: str) -> list: + try: + with os.scandir(inbox_directory(state_directory)) as entries: + message_directories = [entry for entry in entries if entry.is_dir()] + except OSError: + return [] + return sorted(message_directories, key=lambda entry: entry.stat().st_mtime) + + +def evict_until_the_inbox_fits(message_directories) -> None: + measured = [ + (entry, message_directory_size(entry.path)) for entry in message_directories + ] + kept_size = sum(size for _, size in measured) + for entry, size in measured: + if kept_size <= INBOX_TOTAL_SIZE_LIMIT_BYTES: + return + shutil.rmtree(entry.path, ignore_errors=True) + kept_size -= size + + +def prune_expired_inbox(state_directory: str, now: float) -> None: + cutoff = now - INBOX_RETENTION_SECONDS + surviving_directories = [] + for entry in message_directories_oldest_first(state_directory): + if entry.stat().st_mtime < cutoff: + shutil.rmtree(entry.path, ignore_errors=True) + else: + surviving_directories.append(entry) + evict_until_the_inbox_fits(surviving_directories) + + +def prepare_inbox_for_message(state_directory: str, message_identifier: str) -> str: + prune_expired_inbox(state_directory, time.time()) + destination_directory = message_inbox_directory(state_directory, message_identifier) + os.makedirs(destination_directory, exist_ok=True) + return destination_directory diff --git a/module/channel-adapters/discord/scripts/channel_message/outbound_reply.py b/module/channel-adapters/discord/scripts/channel_message/outbound_reply.py new file mode 100644 index 0000000..54b6e94 --- /dev/null +++ b/module/channel-adapters/discord/scripts/channel_message/outbound_reply.py @@ -0,0 +1,101 @@ +import dataclasses +import os + +import discord + +DISCORD_MESSAGE_CHARACTER_LIMIT = 2000 +DISCORD_ATTACHMENT_COUNT_LIMIT = 10 +ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES = 25 * 1024 * 1024 + + +@dataclasses.dataclass(frozen=True) +class ReplyAttachmentSplit: + text: str + attachment_paths: list[str] + refused_paths: list[str] + + +def split_into_sendable_messages(reply: str) -> list[str]: + remaining = reply + messages = [] + while len(remaining) > DISCORD_MESSAGE_CHARACTER_LIMIT: + split_position = remaining.rfind("\n", 0, DISCORD_MESSAGE_CHARACTER_LIMIT) + if split_position <= 0: + split_position = DISCORD_MESSAGE_CHARACTER_LIMIT + messages.append(remaining[:split_position]) + remaining = remaining[split_position:].lstrip("\n") + if remaining: + messages.append(remaining) + return messages + + +def path_lies_inside_directory(candidate_path: str, directory: str) -> bool: + resolved_directory = os.path.realpath(directory) + resolved_candidate = os.path.realpath(candidate_path) + if resolved_candidate == resolved_directory: + return False + return ( + os.path.commonpath([resolved_directory, resolved_candidate]) + == resolved_directory + ) + + +def line_names_a_workspace_file(line: str, workspace_directory: str) -> bool: + candidate_path = line.strip() + if not os.path.isabs(candidate_path): + return False + if not path_lies_inside_directory(candidate_path, workspace_directory): + return False + return os.path.isfile(candidate_path) + + +def attachment_is_small_enough_to_upload(attachment_path: str) -> bool: + return os.path.getsize(attachment_path) <= ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES + + +def split_reply_into_text_and_attachments( + reply: str, workspace_directory: str +) -> ReplyAttachmentSplit: + kept_lines = [] + attachment_paths = [] + refused_paths = [] + for line in reply.split("\n"): + if not line_names_a_workspace_file(line, workspace_directory): + kept_lines.append(line) + continue + attachment_path = line.strip() + if len(attachment_paths) >= DISCORD_ATTACHMENT_COUNT_LIMIT: + refused_paths.append(attachment_path) + elif not attachment_is_small_enough_to_upload(attachment_path): + refused_paths.append(attachment_path) + else: + attachment_paths.append(attachment_path) + return ReplyAttachmentSplit( + "\n".join(kept_lines).strip(), attachment_paths, refused_paths + ) + + +async def send_leading_message(channel, leading_message, attachment_paths, report): + if not attachment_paths: + await channel.send(leading_message) + return + try: + await channel.send( + leading_message, files=[discord.File(path) for path in attachment_paths] + ) + except discord.HTTPException as sending_failure: + report( + f"discord refused {len(attachment_paths)} attachments: {sending_failure}" + ) + if leading_message is not None: + await channel.send(leading_message) + + +async def send_reply(channel, split: ReplyAttachmentSplit, report) -> None: + sendable_messages = split_into_sendable_messages(split.text) + leading_message = sendable_messages[0] if sendable_messages else None + if leading_message is None and not split.attachment_paths: + return + await send_leading_message(channel, leading_message, split.attachment_paths, report) + for sendable_message in sendable_messages[1:]: + await channel.send(sendable_message) diff --git a/module/scripts/tests/unit/discord_stub_test_support.py b/module/scripts/tests/unit/discord_stub_test_support.py new file mode 100644 index 0000000..7147abc --- /dev/null +++ b/module/scripts/tests/unit/discord_stub_test_support.py @@ -0,0 +1,29 @@ +import sys +import types + + +class StubDiscordException(Exception): + pass + + +class StubDiscordHTTPException(StubDiscordException): + pass + + +class StubDiscordFile: + def __init__(self, path): + self.path = path + + +def install_discord_stub(): + discord_stub = types.ModuleType("discord") + discord_stub.Client = type("Client", (), {}) + discord_stub.Message = object + discord_stub.Intents = type( + "Intents", (), {"default": staticmethod(lambda: object())} + ) + discord_stub.DiscordException = StubDiscordException + discord_stub.HTTPException = StubDiscordHTTPException + discord_stub.File = StubDiscordFile + sys.modules["discord"] = discord_stub + return discord_stub diff --git a/module/scripts/tests/unit/test_discord_bridge.py b/module/scripts/tests/unit/test_discord_bridge.py index a9aa844..634a000 100644 --- a/module/scripts/tests/unit/test_discord_bridge.py +++ b/module/scripts/tests/unit/test_discord_bridge.py @@ -2,9 +2,9 @@ import pathlib import subprocess import sys -import types import harness_turn +from discord_stub_test_support import install_discord_stub OPENCODE_SCRIPTS_DIRECTORY = ( pathlib.Path(__file__).resolve().parent.parent.parent.parent @@ -206,13 +206,7 @@ def raise_timeout(*_arguments, **_keyword_arguments): def load_bridge_module_with_stubbed_discord(): - discord_stub = types.ModuleType("discord") - discord_stub.Client = type("Client", (), {}) - discord_stub.Message = object - discord_stub.Intents = type( - "Intents", (), {"default": staticmethod(lambda: object())} - ) - sys.modules["discord"] = discord_stub + install_discord_stub() return load_module_from_path("bridge", DISCORD_SCRIPTS_DIRECTORY / "bridge.py") diff --git a/module/scripts/tests/unit/test_discord_bridge_turn.py b/module/scripts/tests/unit/test_discord_bridge_turn.py new file mode 100644 index 0000000..782e1be --- /dev/null +++ b/module/scripts/tests/unit/test_discord_bridge_turn.py @@ -0,0 +1,153 @@ +import asyncio +import contextlib +import importlib.util +import json +import pathlib +import sys + +from discord_stub_test_support import install_discord_stub + +install_discord_stub() + +DISCORD_SCRIPTS_DIRECTORY = ( + pathlib.Path(__file__).resolve().parent.parent.parent.parent + / "channel-adapters" + / "discord" + / "scripts" +) + + +def load_bridge_module(): + specification = importlib.util.spec_from_file_location( + "bridge", DISCORD_SCRIPTS_DIRECTORY / "bridge.py" + ) + module = importlib.util.module_from_spec(specification) + sys.modules["bridge"] = module + specification.loader.exec_module(module) + return module + + +bridge = load_bridge_module() + + +class StubAttachment: + def __init__(self, filename, content_type, size, payload): + self.filename = filename + self.content_type = content_type + self.size = size + self.payload = payload + self.url = "https://cdn.discordapp.example/file" + + async def save(self, destination_path): + with open(destination_path, "wb") as destination_file: + destination_file.write(self.payload) + + +class RecordingChannel: + def __init__(self): + self.id = 640612380338028606 + self.sent = [] + + @contextlib.asynccontextmanager + async def typing(self): + yield + + async def send(self, content, files=None): + self.sent.append((content, files)) + + +class StubAuthor: + def __init__(self): + self.id = 284143065877184512 + self.bot = False + + +class StubMessage: + def __init__(self, channel, clean_content="", attachments=(), stickers=()): + self.id = 991 + self.channel = channel + self.author = StubAuthor() + self.clean_content = clean_content + self.attachments = list(attachments) + self.stickers = list(stickers) + self.mentions = [] + self.guild = object() + + +def write_launch_config(tmp_path, one_shot_turn_command): + launch_config_path = tmp_path / "launch-config" / "monster.json" + launch_config_path.parent.mkdir(parents=True, exist_ok=True) + launch_config_path.write_text( + json.dumps( + { + "declared_harness": "codex", + "harness_one_shot_turn_commands": {"codex": one_shot_turn_command}, + } + ) + ) + return launch_config_path + + +def build_client(tmp_path, one_shot_turn_command): + launch_config_path = write_launch_config(tmp_path, one_shot_turn_command) + workspace_directory = tmp_path / "workspace" + workspace_directory.mkdir() + state_directory = tmp_path / "state" + client = bridge.AgentBridgeClient.__new__(bridge.AgentBridgeClient) + client.agent_name = "monster" + client.launch_config_path = str(launch_config_path) + client.workspace_directory = str(workspace_directory) + client.state_directory = str(state_directory) + client.daily_session_rotation = False + client.access_document_reader = lambda: { + "groups": {"640612380338028606": {"requireMention": False, "allowFrom": []}} + } + client.turn_lock = asyncio.Lock() + client.user = object() + return client, workspace_directory + + +def test_a_video_only_message_runs_a_turn_whose_prompt_names_the_saved_file(tmp_path): + channel = RecordingChannel() + client, _ = build_client( + tmp_path, 'printf "%s" "$CLAWDE_CHANNEL_PROMPT" > "$CLAWDE_CHANNEL_REPLY_FILE"' + ) + message = StubMessage( + channel, + attachments=[StubAttachment("spiderman.mp4", "video/mp4", 7, b"MOOVATOM")], + ) + + asyncio.run(client.on_message(message)) + + echoed_prompt = channel.sent[0][0] + saved_path = tmp_path / "state" / "inbox" / "991" / "0-spiderman.mp4" + assert str(saved_path) in echoed_prompt + assert saved_path.read_bytes() == b"MOOVATOM" + + +def test_a_reply_naming_a_workspace_file_arrives_as_an_attachment(tmp_path): + channel = RecordingChannel() + client, workspace_directory = build_client(tmp_path, "") + gif_path = workspace_directory / "media" / "sneer.gif" + gif_path.parent.mkdir(parents=True) + gif_path.write_bytes(b"GIF89a") + write_launch_config( + tmp_path, f'printf "toma\\n{gif_path}" > "$CLAWDE_CHANNEL_REPLY_FILE"' + ) + + asyncio.run(client.on_message(StubMessage(channel, clean_content="manda um gif"))) + + content, files = channel.sent[0] + assert content == "toma" + assert [attached.path for attached in files] == [str(gif_path)] + + +def test_a_message_the_bridge_cannot_render_never_reaches_the_harness(tmp_path): + channel = RecordingChannel() + client, _ = build_client( + tmp_path, 'printf "answered" > "$CLAWDE_CHANNEL_REPLY_FILE"' + ) + + asyncio.run(client.on_message(StubMessage(channel))) + + assert channel.sent == [] diff --git a/module/scripts/tests/unit/test_discord_inbound_media.py b/module/scripts/tests/unit/test_discord_inbound_media.py new file mode 100644 index 0000000..8492aaa --- /dev/null +++ b/module/scripts/tests/unit/test_discord_inbound_media.py @@ -0,0 +1,163 @@ +import asyncio +import os + +from discord_stub_test_support import install_discord_stub + +install_discord_stub() + +from channel_message import inbound_media # noqa: E402 + + +class StubAttachment: + def __init__( + self, + filename, + content_type="image/png", + size=1024, + url="https://cdn.discordapp.example/file", + payload=b"payload", + saving_failure=None, + ): + self.filename = filename + self.content_type = content_type + self.size = size + self.url = url + self.payload = payload + self.saving_failure = saving_failure + + async def save(self, destination_path): + if self.saving_failure is not None: + raise self.saving_failure + with open(destination_path, "wb") as destination_file: + destination_file.write(self.payload) + + +class StubSticker: + def __init__(self, name): + self.name = name + + +class StubMessage: + def __init__(self, identifier, clean_content="", attachments=(), stickers=()): + self.id = identifier + self.clean_content = clean_content + self.attachments = list(attachments) + self.stickers = list(stickers) + + +def build_prompt(message, state_directory): + reported = [] + prompt = asyncio.run( + inbound_media.prompt_for_message_with_media( + message, str(state_directory), reported.append + ) + ) + return prompt, reported + + +def test_a_message_carrying_only_a_video_no_longer_reaches_the_agent_as_an_empty_prompt( + tmp_path, +): + message = StubMessage( + 7, attachments=[StubAttachment("spiderman.mp4", "video/mp4", 3 * 1024 * 1024)] + ) + + prompt, _ = build_prompt(message, tmp_path) + + assert prompt.strip() + assert "spiderman.mp4" in prompt + assert "video/mp4" in prompt + assert inbound_media.MEDIA_BLOCK_OPENING_TAG in prompt + + +def test_an_attachment_is_saved_into_the_agents_inbox_and_named_by_absolute_path( + tmp_path, +): + message = StubMessage(7, attachments=[StubAttachment("cat.png", payload=b"gif89a")]) + + prompt, _ = build_prompt(message, tmp_path) + + saved_path = str(tmp_path / "inbox" / "7" / "0-cat.png") + assert saved_path in prompt + assert os.path.isfile(saved_path) + with open(saved_path, "rb") as saved_file: + assert saved_file.read() == b"gif89a" + + +def test_an_attachment_filename_cannot_escape_the_inbox_directory(tmp_path): + message = StubMessage(7, attachments=[StubAttachment("../../escaped.sh")]) + + prompt, _ = build_prompt(message, tmp_path) + + saved_files = list((tmp_path / "inbox" / "7").iterdir()) + assert len(saved_files) == 1 + assert str(saved_files[0]) in prompt + assert not os.path.exists(str(tmp_path.parent / "escaped.sh")) + assert not os.path.exists(str(tmp_path / "escaped.sh")) + + +def test_an_oversized_attachment_is_offered_by_url_instead_of_downloaded(tmp_path): + oversized = inbound_media.ATTACHMENT_DOWNLOAD_SIZE_LIMIT_BYTES + 1 + message = StubMessage( + 7, + attachments=[ + StubAttachment( + "huge.mp4", "video/mp4", oversized, url="https://cdn.example/huge.mp4" + ) + ], + ) + + prompt, _ = build_prompt(message, tmp_path) + + assert "not saved" in prompt + assert "https://cdn.example/huge.mp4" in prompt + assert not os.path.exists(str(tmp_path / "inbox" / "7" / "0-huge.mp4")) + + +def test_a_failed_download_still_tells_the_agent_the_attachment_arrived(tmp_path): + message = StubMessage( + 7, + attachments=[ + StubAttachment("broken.png", saving_failure=OSError("disk is full")) + ], + ) + + prompt, reported = build_prompt(message, tmp_path) + + assert inbound_media.DOWNLOAD_FAILED_REASON in prompt + assert "broken.png" in prompt + assert any("disk is full" in note for note in reported) + + +def test_a_caption_stays_above_the_media_block(tmp_path): + message = StubMessage( + 7, clean_content="olha isso", attachments=[StubAttachment("cat.png")] + ) + + prompt, _ = build_prompt(message, tmp_path) + + assert prompt.startswith("olha isso\n\n") + assert prompt.index("olha isso") < prompt.index("cat.png") + + +def test_a_sticker_only_message_names_the_sticker(tmp_path): + message = StubMessage(7, stickers=[StubSticker("capivara")]) + + prompt, _ = build_prompt(message, tmp_path) + + assert prompt == "\n".join( + [ + inbound_media.MEDIA_BLOCK_OPENING_TAG, + "sticker capivara", + inbound_media.MEDIA_BLOCK_CLOSING_TAG, + ] + ) + + +def test_a_plain_text_message_reaches_the_agent_unchanged(tmp_path): + message = StubMessage(7, clean_content="bom dia") + + prompt, _ = build_prompt(message, tmp_path) + + assert prompt == "bom dia" + assert not os.path.exists(str(tmp_path / "inbox")) diff --git a/module/scripts/tests/unit/test_discord_inbox_retention.py b/module/scripts/tests/unit/test_discord_inbox_retention.py new file mode 100644 index 0000000..e594866 --- /dev/null +++ b/module/scripts/tests/unit/test_discord_inbox_retention.py @@ -0,0 +1,64 @@ +import os +import time + +from channel_message import inbox_retention + + +def write_message_directory( + state_directory, message_identifier, file_size, age_seconds +): + message_directory = state_directory / "inbox" / message_identifier + message_directory.mkdir(parents=True) + (message_directory / "attachment.bin").write_bytes(b"x" * file_size) + written_at = time.time() - age_seconds + os.utime(message_directory, (written_at, written_at)) + return message_directory + + +def test_message_directories_older_than_the_retention_window_are_pruned(tmp_path): + expired = write_message_directory( + tmp_path, "1", 16, inbox_retention.INBOX_RETENTION_SECONDS + 1 + ) + kept = write_message_directory(tmp_path, "2", 16, 0) + + inbox_retention.prune_expired_inbox(str(tmp_path), time.time()) + + assert not expired.exists() + assert kept.exists() + + +def test_the_inbox_evicts_its_oldest_messages_once_it_outgrows_its_ceiling( + tmp_path, monkeypatch +): + monkeypatch.setattr(inbox_retention, "INBOX_TOTAL_SIZE_LIMIT_BYTES", 300) + oldest = write_message_directory(tmp_path, "1", 200, 300) + middle = write_message_directory(tmp_path, "2", 200, 200) + newest = write_message_directory(tmp_path, "3", 200, 100) + + inbox_retention.prune_expired_inbox(str(tmp_path), time.time()) + + assert not oldest.exists() + assert not middle.exists() + assert newest.exists() + + +def test_an_inbox_within_its_ceiling_is_left_alone(tmp_path): + kept = write_message_directory(tmp_path, "1", 16, 0) + + inbox_retention.prune_expired_inbox(str(tmp_path), time.time()) + + assert kept.exists() + + +def test_pruning_a_state_directory_with_no_inbox_is_harmless(tmp_path): + inbox_retention.prune_expired_inbox(str(tmp_path), time.time()) + + assert not (tmp_path / "inbox").exists() + + +def test_a_message_identifier_cannot_steer_the_inbox_out_of_the_state_directory( + tmp_path, +): + destination = inbox_retention.prepare_inbox_for_message(str(tmp_path), "../escaped") + + assert os.path.realpath(destination).startswith(os.path.realpath(str(tmp_path))) diff --git a/module/scripts/tests/unit/test_discord_outbound_reply.py b/module/scripts/tests/unit/test_discord_outbound_reply.py new file mode 100644 index 0000000..aa18376 --- /dev/null +++ b/module/scripts/tests/unit/test_discord_outbound_reply.py @@ -0,0 +1,228 @@ +import asyncio +import os + +from discord_stub_test_support import StubDiscordHTTPException, install_discord_stub + +install_discord_stub() + +from channel_message import outbound_reply # noqa: E402 + + +class RecordingChannel: + def __init__(self, refuse_attachments=False): + self.refuse_attachments = refuse_attachments + self.sent = [] + + async def send(self, content, files=None): + if files and self.refuse_attachments: + raise StubDiscordHTTPException("payload too large") + self.sent.append((content, files)) + + +def write_workspace_file(workspace_directory, relative_path, size=16): + absolute_path = workspace_directory / relative_path + absolute_path.parent.mkdir(parents=True, exist_ok=True) + absolute_path.write_bytes(b"x" * size) + return str(absolute_path) + + +def test_a_reply_line_naming_a_workspace_file_becomes_an_attachment(tmp_path): + gif_path = write_workspace_file(tmp_path, "media/sneer.gif") + + split = outbound_reply.split_reply_into_text_and_attachments( + f"olha o seu retrato\n{gif_path}", str(tmp_path) + ) + + assert split.text == "olha o seu retrato" + assert split.attachment_paths == [gif_path] + assert split.refused_paths == [] + + +def test_a_reply_that_is_only_a_path_sends_the_file_with_no_text(tmp_path): + voice_note_path = write_workspace_file(tmp_path, "media/sigh.mp3") + + split = outbound_reply.split_reply_into_text_and_attachments( + voice_note_path, str(tmp_path) + ) + + assert split.text == "" + assert split.attachment_paths == [voice_note_path] + + +def test_a_path_outside_the_workspace_is_never_attached(tmp_path): + outside_path = tmp_path.parent / "outside.txt" + outside_path.write_text("secret") + workspace_directory = tmp_path / "workspace" + workspace_directory.mkdir() + + split = outbound_reply.split_reply_into_text_and_attachments( + f"toma\n{outside_path}", str(workspace_directory) + ) + + assert split.attachment_paths == [] + assert str(outside_path) in split.text + + +def test_a_symlink_pointing_out_of_the_workspace_is_never_attached(tmp_path): + outside_path = tmp_path / "outside.txt" + outside_path.write_text("secret") + workspace_directory = tmp_path / "workspace" + workspace_directory.mkdir() + escaping_link = workspace_directory / "link.txt" + escaping_link.symlink_to(outside_path) + + split = outbound_reply.split_reply_into_text_and_attachments( + str(escaping_link), str(workspace_directory) + ) + + assert split.attachment_paths == [] + + +def test_prose_that_merely_mentions_a_path_stays_in_the_text(tmp_path): + gif_path = write_workspace_file(tmp_path, "media/sneer.gif") + + split = outbound_reply.split_reply_into_text_and_attachments( + f"o arquivo {gif_path} existe", str(tmp_path) + ) + + assert split.attachment_paths == [] + assert split.text == f"o arquivo {gif_path} existe" + + +def test_an_oversized_file_is_refused_rather_than_uploaded(tmp_path): + oversized_path = write_workspace_file( + tmp_path, + "media/huge.mp4", + outbound_reply.ATTACHMENT_UPLOAD_SIZE_LIMIT_BYTES + 1, + ) + + split = outbound_reply.split_reply_into_text_and_attachments( + f"toma\n{oversized_path}", str(tmp_path) + ) + + assert split.attachment_paths == [] + assert split.refused_paths == [oversized_path] + + +def test_files_beyond_the_discord_count_limit_are_refused(tmp_path): + paths = [ + write_workspace_file(tmp_path, f"media/gif-{index}.gif") + for index in range(outbound_reply.DISCORD_ATTACHMENT_COUNT_LIMIT + 2) + ] + + split = outbound_reply.split_reply_into_text_and_attachments( + "\n".join(paths), str(tmp_path) + ) + + assert len(split.attachment_paths) == outbound_reply.DISCORD_ATTACHMENT_COUNT_LIMIT + assert split.refused_paths == paths[outbound_reply.DISCORD_ATTACHMENT_COUNT_LIMIT :] + + +def test_the_files_ride_along_with_the_first_sent_message(tmp_path): + gif_path = write_workspace_file(tmp_path, "media/sneer.gif") + channel = RecordingChannel() + + asyncio.run( + outbound_reply.send_reply( + channel, + outbound_reply.ReplyAttachmentSplit("toma", [gif_path], []), + [].append, + ) + ) + + assert len(channel.sent) == 1 + content, files = channel.sent[0] + assert content == "toma" + assert [attached.path for attached in files] == [gif_path] + + +def test_a_long_reply_attaches_to_the_first_chunk_only(tmp_path): + gif_path = write_workspace_file(tmp_path, "media/sneer.gif") + channel = RecordingChannel() + long_text = "\n".join(["a" * 500] * 8) + + asyncio.run( + outbound_reply.send_reply( + channel, + outbound_reply.ReplyAttachmentSplit(long_text, [gif_path], []), + [].append, + ) + ) + + assert len(channel.sent) > 1 + assert channel.sent[0][1] is not None + assert all(files is None for _, files in channel.sent[1:]) + + +def test_a_file_with_no_text_is_sent_without_content(tmp_path): + gif_path = write_workspace_file(tmp_path, "media/sneer.gif") + channel = RecordingChannel() + + asyncio.run( + outbound_reply.send_reply( + channel, outbound_reply.ReplyAttachmentSplit("", [gif_path], []), [].append + ) + ) + + assert channel.sent[0][0] is None + assert [attached.path for attached in channel.sent[0][1]] == [gif_path] + + +def test_nothing_is_sent_when_the_reply_has_neither_text_nor_files(): + channel = RecordingChannel() + + asyncio.run( + outbound_reply.send_reply( + channel, outbound_reply.ReplyAttachmentSplit("", [], []), [].append + ) + ) + + assert channel.sent == [] + + +def test_a_missing_workspace_path_is_left_in_the_text(tmp_path): + missing_path = str(tmp_path / "media" / "gone.gif") + + split = outbound_reply.split_reply_into_text_and_attachments( + missing_path, str(tmp_path) + ) + + assert split.attachment_paths == [] + assert split.text == missing_path + assert not os.path.exists(missing_path) + + +def test_a_reply_survives_when_discord_refuses_its_attachment(tmp_path): + gif_path = write_workspace_file(tmp_path, "media/sneer.gif") + channel = RecordingChannel(refuse_attachments=True) + reported = [] + + asyncio.run( + outbound_reply.send_reply( + channel, + outbound_reply.ReplyAttachmentSplit("toma", [gif_path], []), + reported.append, + ) + ) + + assert channel.sent == [("toma", None)] + assert any("payload too large" in note for note in reported) + + +def test_a_refused_lone_attachment_reports_rather_than_sending_an_empty_message( + tmp_path, +): + gif_path = write_workspace_file(tmp_path, "media/sneer.gif") + channel = RecordingChannel(refuse_attachments=True) + reported = [] + + asyncio.run( + outbound_reply.send_reply( + channel, + outbound_reply.ReplyAttachmentSplit("", [gif_path], []), + reported.append, + ) + ) + + assert channel.sent == [] + assert reported