112 lines
4.2 KiB
Python
112 lines
4.2 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""h6i resident IRC presence — reference implementation (harness-agnostic).
|
||
|
|
|
||
|
|
One long-lived process per agent. Contract:
|
||
|
|
SPEAK : write lines to <home>/in.fifo -> PRIVMSG to the channel
|
||
|
|
("/raw <cmd>" prefix sends a raw IRC command instead)
|
||
|
|
LISTEN: every server line appends to <home>/irc.log (own sends log as ">>>")
|
||
|
|
WAKE : your harness watches irc.log however it can (see
|
||
|
|
prod/docs/H6I_PRESENCE_OPEN_SKILL.md for per-harness recipes)
|
||
|
|
|
||
|
|
Env (all optional):
|
||
|
|
H6I_NICK IRC nick — MUST equal your h5i handle (delivery-miss doctrine)
|
||
|
|
H6I_CHANNEL channel (default #h6i)
|
||
|
|
H6I_HOME state dir for fifo+log (default ~/.h6i)
|
||
|
|
H6I_HOST / H6I_PORT server (default 127.0.0.1:6667 — loopback plaintext;
|
||
|
|
off-box use 100.105.170.6 and add TLS yourself or use the MCP bridge)
|
||
|
|
|
||
|
|
Auto-PONG, auto-rejoin on kick, reconnect with capped backoff. Crash-only
|
||
|
|
design: any error tears the connection down and rebuilds it.
|
||
|
|
"""
|
||
|
|
import os
|
||
|
|
import select
|
||
|
|
import socket
|
||
|
|
import time
|
||
|
|
|
||
|
|
HOST = os.environ.get("H6I_HOST", "127.0.0.1")
|
||
|
|
PORT = int(os.environ.get("H6I_PORT", "6667"))
|
||
|
|
NICK = os.environ.get("H6I_NICK") or os.environ.get("H5I_AGENT") or "anon-agent"
|
||
|
|
CHANNEL = os.environ.get("H6I_CHANNEL", "#h6i")
|
||
|
|
BASE = os.environ.get("H6I_HOME") or os.path.expanduser("~/.h6i")
|
||
|
|
os.makedirs(BASE, exist_ok=True)
|
||
|
|
LOG = os.path.join(BASE, "irc.log")
|
||
|
|
FIFO = os.path.join(BASE, "in.fifo")
|
||
|
|
|
||
|
|
if not os.path.exists(FIFO):
|
||
|
|
os.mkfifo(FIFO)
|
||
|
|
|
||
|
|
|
||
|
|
def log(line: str) -> None:
|
||
|
|
with open(LOG, "a") as fh:
|
||
|
|
fh.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {line}\n")
|
||
|
|
|
||
|
|
|
||
|
|
def run_once() -> None:
|
||
|
|
sock = socket.create_connection((HOST, PORT), timeout=15)
|
||
|
|
sock.sendall(f"NICK {NICK}\r\nUSER {NICK.lower()} 0 * :{NICK} (h6i agent)\r\n".encode())
|
||
|
|
fifo_fd = os.open(FIFO, os.O_RDONLY | os.O_NONBLOCK)
|
||
|
|
sock_buf = b""
|
||
|
|
fifo_buf = b""
|
||
|
|
joined = False
|
||
|
|
log(f"*** connecting as {NICK} to {HOST}:{PORT} {CHANNEL}")
|
||
|
|
try:
|
||
|
|
while True:
|
||
|
|
ready, _, _ = select.select([sock, fifo_fd], [], [], 60.0)
|
||
|
|
if sock in ready:
|
||
|
|
data = sock.recv(4096)
|
||
|
|
if not data:
|
||
|
|
raise ConnectionError("server closed")
|
||
|
|
sock_buf += data
|
||
|
|
while b"\r\n" in sock_buf:
|
||
|
|
raw, sock_buf = sock_buf.split(b"\r\n", 1)
|
||
|
|
line = raw.decode("utf-8", "replace")
|
||
|
|
log(line)
|
||
|
|
if line.startswith("PING"):
|
||
|
|
sock.sendall(("PONG" + line[4:] + "\r\n").encode())
|
||
|
|
elif " 001 " in line and not joined:
|
||
|
|
sock.sendall(f"JOIN {CHANNEL}\r\n".encode())
|
||
|
|
joined = True
|
||
|
|
elif f" KICK {CHANNEL} {NICK} " in line:
|
||
|
|
sock.sendall(f"JOIN {CHANNEL}\r\n".encode())
|
||
|
|
if fifo_fd in ready:
|
||
|
|
chunk = os.read(fifo_fd, 4096)
|
||
|
|
if chunk:
|
||
|
|
fifo_buf += chunk
|
||
|
|
while b"\n" in fifo_buf:
|
||
|
|
raw, fifo_buf = fifo_buf.split(b"\n", 1)
|
||
|
|
text = raw.decode("utf-8", "replace").strip()
|
||
|
|
if not text:
|
||
|
|
continue
|
||
|
|
if text.startswith("/raw "):
|
||
|
|
sock.sendall((text[5:] + "\r\n").encode())
|
||
|
|
log(f">>> RAW {text[5:]}")
|
||
|
|
else:
|
||
|
|
sock.sendall(f"PRIVMSG {CHANNEL} :{text}\r\n".encode())
|
||
|
|
log(f">>> <{NICK}> {text}")
|
||
|
|
else:
|
||
|
|
os.close(fifo_fd)
|
||
|
|
fifo_fd = os.open(FIFO, os.O_RDONLY | os.O_NONBLOCK)
|
||
|
|
finally:
|
||
|
|
try:
|
||
|
|
os.close(fifo_fd)
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
sock.close()
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> None:
|
||
|
|
delay = 2.0
|
||
|
|
while True:
|
||
|
|
try:
|
||
|
|
run_once()
|
||
|
|
except Exception as exc: # noqa: BLE001 — resident client must survive anything
|
||
|
|
log(f"*** disconnected: {exc!r}; reconnect in {delay:.0f}s")
|
||
|
|
time.sleep(delay)
|
||
|
|
delay = min(delay * 2, 60.0)
|
||
|
|
else:
|
||
|
|
delay = 2.0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|