Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/engine-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions engine/discord.py
Original file line number Diff line number Diff line change
@@ -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()]
83 changes: 83 additions & 0 deletions engine/test_discord.py
Original file line number Diff line number Diff line change
@@ -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()
48 changes: 48 additions & 0 deletions engine/test_discord_routing.py
Original file line number Diff line number Diff line change
@@ -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()
8 changes: 5 additions & 3 deletions engine/transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import time

import events
import discord
import tg

LOG_PATH = None
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 7 additions & 2 deletions engine/tron.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import prompts
import roster
import bootup
import discord
import tg
import transcript
import workflow
Expand Down Expand Up @@ -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=""):
Expand Down Expand Up @@ -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)


Expand Down
Loading