From fc973790fb0afb4ba2933fc248dadc7c11618d56 Mon Sep 17 00:00:00 2001 From: weishaupt Date: Sun, 11 Oct 2026 11:02:36 -0400 Subject: [PATCH] Switch nick to clod, identify to NickServ, allow #/g/tv - Nick is now clod with clod_ as fallback; while on a fallback nick the server retries NICK clod every nick_retry_seconds (default 180). - Identify to NickServ whenever holding the configured nick, using the password in nickserv_password.txt (gitignored). - Add #/g/tv to allowed_channels. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + README.md | 3 ++- config.json | 10 ++++++---- server.py | 46 +++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 77ac754..e78a461 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .venv/ __pycache__/ *.pyc +nickserv_password.txt diff --git a/README.md b/README.md index f6cd8d4..17606ea 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ A small MCP server that gives Claude Code an IRC connection. -- Connects to `irc.rizon.net:6697` over TLS as `tpuahsiew_claude`, and uses a fallback nick if that one is taken +- Connects to `irc.rizon.net:6697` over TLS as `clod`. If that nick is taken it uses `clod_` and tries to switch back every `nick_retry_seconds` (default 180) +- Identifies to NickServ whenever it holds `clod`, using the password in `nickserv_password.txt` (gitignored, set by `nickserv_password_file`) - Answers server PINGs, sends its own keepalive PING, and reconnects automatically - Can only join channels listed in `allowed_channels` in `config.json` (re-read on every call, so edits apply right away) - Keeps a rolling buffer of recent messages per joined channel (`buffer_size`, default 200) diff --git a/config.json b/config.json index 1322033..fb4a801 100644 --- a/config.json +++ b/config.json @@ -2,10 +2,12 @@ "server": "irc.rizon.net", "port": 6697, "tls": true, - "nick": "tpuahsiew_claude", - "fallback_nicks": ["tpuahsiew_claude_", "tpuahsiew_claude2"], + "nick": "clod", + "fallback_nicks": ["clod_"], "username": "claude", "realname": "Claude Code IRC MCP", - "allowed_channels": ["#blog"], - "buffer_size": 200 + "allowed_channels": ["#blog", "#/g/tv"], + "buffer_size": 200, + "nickserv_password_file": "nickserv_password.txt", + "nick_retry_seconds": 180 } diff --git a/server.py b/server.py index 1e2b613..51bb47f 100644 --- a/server.py +++ b/server.py @@ -5,7 +5,8 @@ messages, and exposes tools to join/leave/send/read/list users. It never sends chat text on its own: the only things it writes to the server unprompted are protocol traffic (NICK/USER registration, PONG replies, -keepalive PINGs, and re-joining channels you already joined after a reconnect). +keepalive PINGs, NickServ IDENTIFY, periodic NICK attempts to reclaim the +configured nick, and re-joining channels you already joined after a reconnect). """ import asyncio @@ -46,9 +47,24 @@ def load_config() -> dict: cfg.setdefault("realname", cfg["nick"]) cfg.setdefault("allowed_channels", []) cfg.setdefault("buffer_size", 200) + cfg.setdefault("nickserv_password_file", "") + cfg.setdefault("nick_retry_seconds", 180) return cfg +def nickserv_password(cfg: dict) -> str: + """Read the NickServ password from its (gitignored) file, relative to the config.""" + name = cfg["nickserv_password_file"] + if not name: + return "" + path = CONFIG_PATH.parent / name + try: + return path.read_text(encoding="utf-8").strip() + except FileNotFoundError: + log.warning("NickServ password file %s not found; not identifying", path) + return "" + + def irc_lower(s: str) -> str: """RFC 1459 case folding, used for channel and nick comparison.""" return s.lower().translate(str.maketrans("[]\\~", "{}|^")) @@ -164,6 +180,13 @@ class IRCClient: await self.send_raw(f"NICK {self.nick}") await self.send_raw(f"USER {cfg['username']} 0 * :{cfg['realname']}") + reclaim = asyncio.create_task(self._reclaim_nick()) + try: + await self._read_loop(reader) + finally: + reclaim.cancel() + + async def _read_loop(self, reader: asyncio.StreamReader): awaiting_pong = False while True: try: @@ -181,6 +204,24 @@ class IRCClient: if line: await self._handle(line) + async def _reclaim_nick(self): + """While on a fallback nick, periodically try to switch back to the configured one.""" + while True: + cfg = load_config() + await asyncio.sleep(max(30, int(cfg["nick_retry_seconds"]))) + cfg = load_config() + if self.registered.is_set() and not self._is_me(cfg["nick"]): + log.info("trying to reclaim nick %s", cfg["nick"]) + await self.send_raw(f"NICK {cfg['nick']}") + + async def _identify(self): + """Identify to NickServ, but only while holding the configured (registered) nick.""" + cfg = load_config() + password = nickserv_password(cfg) + if password and self._is_me(cfg["nick"]): + log.info("identifying to NickServ as %s", self.nick) + await self.send_raw(f"PRIVMSG NickServ :IDENTIFY {password}") + async def _quit(self): if self.writer: try: @@ -220,6 +261,7 @@ class IRCClient: self.registered.set() self.status = f"connected as {self.nick}" log.info(self.status) + await self._identify() for ch in list(self.wanted): await self.send_raw(f"JOIN {self.display.get(ch, ch)}") elif cmd in ("432", "433", "436") and not self.registered.is_set(): @@ -267,6 +309,8 @@ class IRCClient: elif cmd == "NICK" and p: if self._is_me(src): self.nick = p[0] + self.status = f"connected as {self.nick}" + await self._identify() for ch, nicks in self.joined.items(): if src in nicks: nicks.discard(src)