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 <noreply@anthropic.com>
This commit is contained in:
weishaupt
2026-10-11 11:02:36 -04:00
co-authored by Claude Opus 5.5
parent c50f770f77
commit fc973790fb
4 changed files with 54 additions and 6 deletions
+1
View File
@@ -1,3 +1,4 @@
.venv/ .venv/
__pycache__/ __pycache__/
*.pyc *.pyc
nickserv_password.txt
+2 -1
View File
@@ -2,7 +2,8 @@
A small MCP server that gives Claude Code an IRC connection. 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 - 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) - 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) - Keeps a rolling buffer of recent messages per joined channel (`buffer_size`, default 200)
+6 -4
View File
@@ -2,10 +2,12 @@
"server": "irc.rizon.net", "server": "irc.rizon.net",
"port": 6697, "port": 6697,
"tls": true, "tls": true,
"nick": "tpuahsiew_claude", "nick": "clod",
"fallback_nicks": ["tpuahsiew_claude_", "tpuahsiew_claude2"], "fallback_nicks": ["clod_"],
"username": "claude", "username": "claude",
"realname": "Claude Code IRC MCP", "realname": "Claude Code IRC MCP",
"allowed_channels": ["#blog"], "allowed_channels": ["#blog", "#/g/tv"],
"buffer_size": 200 "buffer_size": 200,
"nickserv_password_file": "nickserv_password.txt",
"nick_retry_seconds": 180
} }
+45 -1
View File
@@ -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 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, 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 import asyncio
@@ -46,9 +47,24 @@ def load_config() -> dict:
cfg.setdefault("realname", cfg["nick"]) cfg.setdefault("realname", cfg["nick"])
cfg.setdefault("allowed_channels", []) cfg.setdefault("allowed_channels", [])
cfg.setdefault("buffer_size", 200) cfg.setdefault("buffer_size", 200)
cfg.setdefault("nickserv_password_file", "")
cfg.setdefault("nick_retry_seconds", 180)
return cfg 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: def irc_lower(s: str) -> str:
"""RFC 1459 case folding, used for channel and nick comparison.""" """RFC 1459 case folding, used for channel and nick comparison."""
return s.lower().translate(str.maketrans("[]\\~", "{}|^")) 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"NICK {self.nick}")
await self.send_raw(f"USER {cfg['username']} 0 * :{cfg['realname']}") 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 awaiting_pong = False
while True: while True:
try: try:
@@ -181,6 +204,24 @@ class IRCClient:
if line: if line:
await self._handle(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): async def _quit(self):
if self.writer: if self.writer:
try: try:
@@ -220,6 +261,7 @@ class IRCClient:
self.registered.set() self.registered.set()
self.status = f"connected as {self.nick}" self.status = f"connected as {self.nick}"
log.info(self.status) log.info(self.status)
await self._identify()
for ch in list(self.wanted): for ch in list(self.wanted):
await self.send_raw(f"JOIN {self.display.get(ch, ch)}") await self.send_raw(f"JOIN {self.display.get(ch, ch)}")
elif cmd in ("432", "433", "436") and not self.registered.is_set(): elif cmd in ("432", "433", "436") and not self.registered.is_set():
@@ -267,6 +309,8 @@ class IRCClient:
elif cmd == "NICK" and p: elif cmd == "NICK" and p:
if self._is_me(src): if self._is_me(src):
self.nick = p[0] self.nick = p[0]
self.status = f"connected as {self.nick}"
await self._identify()
for ch, nicks in self.joined.items(): for ch, nicks in self.joined.items():
if src in nicks: if src in nicks:
nicks.discard(src) nicks.discard(src)