diff --git a/.env b/.env index 0473f57..15b125c 100644 --- a/.env +++ b/.env @@ -3,4 +3,5 @@ MISTLE_PORT=4711 MISTLE_USER=mistle MISTLE_PASSWORD=sl-mudbot MISTLE_LOGIN_PROMPT=Wie heisst Du denn ("neu" fuer neuen Spieler) ? -MISTLE_EXIT_COMMAND=schlaf ein \ No newline at end of file +MISTLE_EXIT_COMMAND=schlaf ein +MISTLE_AGENT_MODE=true \ No newline at end of file diff --git a/app.py b/app.py index d34fe3c..50c7420 100644 --- a/app.py +++ b/app.py @@ -3,10 +3,113 @@ import select import sys import time from pathlib import Path +from threading import Event, Lock, Thread +from typing import Callable, Optional from telnetclient import TelnetClient +AgentFunction = Callable[[str], Optional[str]] + + +class SessionState: + """Share Telnet session state safely across threads.""" + + def __init__(self) -> None: + self._send_lock = Lock() + self._output_lock = Lock() + self._output_event = Event() + self._last_output = "" + self._last_agent_send = 0.0 + + def send(self, client: TelnetClient, message: str) -> None: + with self._send_lock: + client.send(message) + + def agent_send( + self, + client: TelnetClient, + message: str, + *, + min_interval: float, + stop_event: Event, + ) -> bool: + """Send on behalf of the agent while respecting a minimum cadence.""" + while not stop_event.is_set(): + with self._send_lock: + now = time.time() + elapsed = now - self._last_agent_send + if elapsed >= min_interval: + client.send(message) + self._last_agent_send = now + return True + wait_time = min_interval - elapsed + if wait_time <= 0: + continue + if stop_event.wait(wait_time): + break + return False + + def update_output(self, text: str) -> None: + if not text: + return + with self._output_lock: + self._last_output = text + self._output_event.set() + + def snapshot_output(self) -> str: + with self._output_lock: + return self._last_output + + def wait_for_output(self, timeout: float) -> bool: + return self._output_event.wait(timeout) + + def clear_output_event(self) -> None: + self._output_event.clear() + + +def agent_decision(last_output: str) -> Optional[str]: + """Decide which command to send based on the most recent server output.""" + return "schau" + + +def run_agent_loop( + client: TelnetClient, + state: SessionState, + agent_fn: AgentFunction, + stop_event: Event, + *, + idle_delay: float = 0.5, + min_send_interval: float = 1.0, +) -> None: + """Invoke *agent_fn* whenever new output arrives and send its response.""" + while not stop_event.is_set(): + triggered = state.wait_for_output(timeout=idle_delay) + if stop_event.is_set(): + break + if not triggered: + continue + state.clear_output_event() + last_output = state.snapshot_output() + if not last_output: + continue + try: + command = agent_fn(last_output) + except Exception as exc: # pragma: no cover - defensive logging + print(f"Agent function failed: {exc}", file=sys.stderr) + continue + if not command: + continue + sent = state.agent_send( + client, + command, + min_interval=min_send_interval, + stop_event=stop_event, + ) + if not sent: + break + + def load_env_file(path: str = ".env") -> None: """Populate ``os.environ`` with key/value pairs from a dotenv file.""" env_path = Path(path) @@ -40,6 +143,7 @@ def login( login_prompt: str, banner_timeout: float = 10.0, response_timeout: float = 2.0, + state: Optional[SessionState] = None, ) -> None: """Handle the banner/prompt exchange and send credentials.""" if login_prompt: @@ -48,6 +152,8 @@ def login( banner = client.receive(timeout=response_timeout) if banner: print(banner, end="" if banner.endswith("\n") else "\n") + if state: + state.update_output(banner) if user: client.send(user) @@ -58,14 +164,18 @@ def login( response = client.receive(timeout=response_timeout) if response: print(response, end="" if response.endswith("\n") else "\n") + if state: + state.update_output(response) def interactive_session( client: TelnetClient, + state: SessionState, + stop_event: Event, *, - exit_command: str, poll_interval: float = 0.2, receive_timeout: float = 0.2, + exit_command: str, ) -> None: """Keep the Telnet session running, proxying input/output until interrupted.""" if exit_command: @@ -73,30 +183,42 @@ def interactive_session( else: print("Connected. Press Ctrl-C to exit.") - while True: + while not stop_event.is_set(): incoming = client.receive(timeout=receive_timeout) if incoming: print(incoming, end="" if incoming.endswith("\n") else "\n") + state.update_output(incoming) readable, _, _ = select.select([sys.stdin], [], [], poll_interval) if sys.stdin in readable: line = sys.stdin.readline() if line == "": + stop_event.set() break line = line.rstrip("\r\n") if not line: continue - client.send(line) + state.send(client, line) -def graceful_shutdown(client: TelnetClient, exit_command: str) -> None: +def graceful_shutdown( + client: TelnetClient, + exit_command: str, + *, + state: Optional[SessionState] = None, +) -> None: if not exit_command: return try: - client.send(exit_command) + if state: + state.send(client, exit_command) + else: + client.send(exit_command) farewell = client.receive(timeout=2.0) if farewell: print(farewell, end="" if farewell.endswith("\n") else "\n") + if state: + state.update_output(farewell) except Exception as exc: # pragma: no cover - best effort logging print(f"Failed to send exit command: {exc}", file=sys.stderr) @@ -116,6 +238,11 @@ def main() -> int: password = os.environ.get("MISTLE_PASSWORD", "") login_prompt = os.environ.get("MISTLE_LOGIN_PROMPT", "") exit_command = os.environ.get("MISTLE_EXIT_COMMAND", "") + agent_mode = os.environ.get("MISTLE_AGENT_MODE", "").lower() in {"1", "true", "yes", "on"} + + state = SessionState() + stop_event = Event() + agent_thread: Optional[Thread] = None with TelnetClient(host=host, port=port, timeout=10.0) as client: login( @@ -123,20 +250,36 @@ def main() -> int: user=user, password=password, login_prompt=login_prompt, + state=state, ) + if agent_mode: + agent_thread = Thread( + target=run_agent_loop, + args=(client, state, agent_decision, stop_event), + kwargs={"min_send_interval": 1.0}, + daemon=True, + ) + agent_thread.start() + interrupted = False try: interactive_session( client, + state=state, + stop_event=stop_event, exit_command=exit_command, ) except KeyboardInterrupt: print() interrupted = True + finally: + stop_event.set() + if agent_thread: + agent_thread.join(timeout=1.0) if interrupted: - graceful_shutdown(client, exit_command) + graceful_shutdown(client, exit_command, state=state) return 0