diff --git a/.github/workflows/engine-ci.yml b/.github/workflows/engine-ci.yml index 6dd5087..0ba19fd 100644 --- a/.github/workflows/engine-ci.yml +++ b/.github/workflows/engine-ci.yml @@ -42,6 +42,8 @@ jobs: run engine/tron.py --selftest run engine/gate.py run engine/tg.py + run engine/test_discord.py + run engine/test_discord_routing.py run engine/events.py run engine/workflow.py run engine/bpmn.py diff --git a/engine/discord.py b/engine/discord.py new file mode 100644 index 0000000..99f1918 --- /dev/null +++ b/engine/discord.py @@ -0,0 +1,77 @@ +"""tron — the Discord line to the operator. + +Discord REST is used deliberately: outbound notes post to #tron and the +engine's normal wake poll reads recent human messages from that one channel. +The transport is optional; missing credentials or network failures never stop +the engine. +""" + +import json +import urllib.parse +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +ENV = ROOT / ".env" +# The operator-managed bot credential is shared by TRON worktrees. Keep the +# engine-local file first for standalone installs and tests. +MODES_ENV = ROOT.parents[2] / "tron-modes" / ".env" +CHANNEL_ID = "1547918251651112971" +MESSAGE_URL = f"https://discord.com/api/v10/channels/{CHANNEL_ID}/messages" +_state = {"loaded": False, "token": None, "after": None} + + +def _load(): + if not _state["loaded"]: + _state["loaded"] = True + for env in (ENV, MODES_ENV): + if not env.exists(): + continue + for line in env.read_text().splitlines(): + key, _, value = line.partition("=") + if key.strip() == "DISCORD_BOT_TOKEN": + _state["token"] = value.strip().strip('"') + break + if _state["token"]: + break + return bool(_state["token"]) + + +def _request(url, data=None, timeout=10): + request = urllib.request.Request( + url, data=data, + headers={"Authorization": f"Bot {_state['token']}", + "Content-Type": "application/json"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode()) + + +def note(text): + """Post one outbound note to #tron; return False on any delivery failure.""" + if not _load(): + return False + try: + _request(MESSAGE_URL, json.dumps({"content": text}).encode()) + return True + except Exception: + return False + + +def inbox(): + """Return unseen human messages in #tron, oldest first, once each.""" + if not _load(): + return [] + try: + query = {"limit": 100} + if _state["after"]: + query["after"] = _state["after"] + messages = _request(MESSAGE_URL + "?" + urllib.parse.urlencode(query)) + except Exception: + return [] + if not isinstance(messages, list): + return [] + if messages: + _state["after"] = max(message["id"] for message in messages) + return [message.get("content", "").strip() for message in reversed(messages) + if not message.get("author", {}).get("bot") + and message.get("content", "").strip()] diff --git a/engine/test_discord.py b/engine/test_discord.py new file mode 100644 index 0000000..0b81b5e --- /dev/null +++ b/engine/test_discord.py @@ -0,0 +1,83 @@ +"""Behavioral tests for the Discord operator transport.""" + +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import discord + + +class DiscordTransportTests(unittest.TestCase): + def setUp(self): + self.saved_state = dict(discord._state) + self.saved_env = discord.ENV + self.saved_modes_env = discord.MODES_ENV + self.env = Path(tempfile.mkdtemp(prefix="discord-test-")) / ".env" + self.env.write_text("DISCORD_BOT_TOKEN=token\n") + discord.ENV = self.env + discord._state.update(loaded=False, token=None, after=None) + + def tearDown(self): + discord.ENV = self.saved_env + discord.MODES_ENV = self.saved_modes_env + discord._state.update(self.saved_state) + + def test_load_reads_the_shared_tron_modes_environment(self): + modes_env = Path(tempfile.mkdtemp(prefix="discord-modes-")) / ".env" + modes_env.write_text("DISCORD_BOT_TOKEN=modes-token\n") + discord.ENV = self.env.with_name("missing.env") + discord.MODES_ENV = modes_env + discord._state.update(loaded=False, token=None, after=None) + + self.assertTrue(discord._load()) + self.assertEqual(discord._state["token"], "modes-token") + + def test_note_posts_to_the_tron_channel(self): + calls = [] + + class Response: + def __enter__(self): return self + def __exit__(self, *args): return False + def read(self): return b'{"id": "17"}' + + def open_(request, timeout): + calls.append((request.full_url, dict(request.header_items()), + request.data, timeout)) + return Response() + + with patch("urllib.request.urlopen", open_): + self.assertTrue(discord.note("fleet is green")) + + url, headers, body, timeout = calls[0] + self.assertEqual(url, discord.MESSAGE_URL) + self.assertEqual(headers["Authorization"], "Bot token") + self.assertEqual(json.loads(body), {"content": "fleet is green"}) + self.assertEqual(timeout, 10) + + def test_inbox_returns_only_new_human_messages_from_tron_channel(self): + class Response: + def __enter__(self): return self + def __exit__(self, *args): return False + def read(self): + return json.dumps([ + {"id": "23", "content": "status?", "author": {"bot": False}}, + {"id": "22", "content": "ignore bot", "author": {"bot": True}}, + {"id": "21", "content": "old", "author": {"bot": False}}, + ]).encode() + + def open_(request, timeout): + if "after=23" in request.full_url: + class EmptyResponse(Response): + def read(self): return b"[]" + return EmptyResponse() + return Response() + + with patch("urllib.request.urlopen", open_): + self.assertEqual(discord.inbox(), ["old", "status?"]) + self.assertEqual(discord.inbox(), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/engine/test_discord_routing.py b/engine/test_discord_routing.py new file mode 100644 index 0000000..47c2796 --- /dev/null +++ b/engine/test_discord_routing.py @@ -0,0 +1,48 @@ +"""Behavioral tests for Discord-to-TRON routing.""" + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import tron +import transcript + + +class DiscordRoutingTests(unittest.TestCase): + def test_operator_page_is_delivered_to_telegram_and_discord(self): + page = "[TRON] Hey boss — the fleet needs you.\n\ndo this\n\nReply here; your answer goes straight back in." + with patch.object(transcript.tg, "ask", return_value="continue"), \ + patch.object(transcript.discord, "note") as discord: + self.assertEqual(transcript.operator("do this"), "continue") + + discord.assert_called_once_with(page) + + def test_milestone_is_delivered_to_telegram_and_discord(self): + with patch.object(tron.tg, "note") as telegram, \ + patch.object(tron.discord, "note") as discord: + tron.milestone("trunk green") + + telegram.assert_called_once_with("trunk green") + discord.assert_called_once_with("trunk green") + + def test_each_normal_discord_message_is_routed_through_parley(self): + project = Path(tempfile.mkdtemp(prefix="discord-route-")) + received = [] + + def parley(path, architect): + message = path / "parley.md" + if message.exists(): + received.append(message.read_text()) + message.unlink() + + with patch.object(tron.discord, "inbox", return_value=["status?", "next?"]), \ + patch.object(tron, "parley", side_effect=parley), \ + patch.object(tron, "report_request"): + tron.channels(project, object()) + + self.assertEqual(received, ["status?", "next?"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/engine/transcript.py b/engine/transcript.py index 9f64e1d..2990ba9 100644 --- a/engine/transcript.py +++ b/engine/transcript.py @@ -10,6 +10,7 @@ import time import events +import discord import tg LOG_PATH = None @@ -53,9 +54,10 @@ def operator(context): print(f"[TRON -> OPERATOR] {context}") log_entry("TRON -> OPERATOR", context) events.emit("page", context=context[:200]) - ans = tg.ask("[TRON] Hey boss — the fleet needs you.\n\n" - + context + "\n\nReply here; your answer goes " - "straight back in.") + page = ("[TRON] Hey boss — the fleet needs you.\n\n" + context + + "\n\nReply here; your answer goes straight back in.") + discord.note(page) + ans = tg.ask(page) if ans is None: try: ans = input("OPERATOR> ").strip() diff --git a/engine/tron.py b/engine/tron.py index e20032e..2fefe65 100644 --- a/engine/tron.py +++ b/engine/tron.py @@ -26,6 +26,7 @@ import prompts import roster import bootup +import discord import tg import transcript import workflow @@ -83,11 +84,12 @@ def ablation(value): # ---------------------------------------------------------------- routing def milestone(text): - """Milestone narration rides Telegram unless TRON_QUIET says a - harness batch is driving. Only the narration is quietable — pages + """Milestone narration rides Telegram and Discord unless TRON_QUIET + says a harness batch is driving. Only narration is quietable — pages (transcript.operator) always reach the operator.""" if not os.environ.get("TRON_QUIET"): tg.note(text) + discord.note(text) def interpret(agent, architect, reply, context="", tag=""): @@ -271,6 +273,9 @@ def report_request(path, architect): def channels(path, architect): """Every operator-initiated channel, one poll.""" parley(path, architect) + for text in discord.inbox(): + (path / "parley.md").write_text(text) + parley(path, architect) report_request(path, architect)