diff --git a/src/bouncer/commands.py b/src/bouncer/commands.py index fe58417..5bbbbf5 100644 --- a/src/bouncer/commands.py +++ b/src/bouncer/commands.py @@ -533,6 +533,7 @@ async def _cmd_addnetwork(router: Router, arg: str) -> list[str]: nick=kvs.get("nick", ""), channels=channels, password=kvs.get("password"), + auth_service=kvs.get("auth_service", "nickserv"), ) await router.add_network(cfg) diff --git a/src/bouncer/config.py b/src/bouncer/config.py index a256e67..c2dca3c 100644 --- a/src/bouncer/config.py +++ b/src/bouncer/config.py @@ -47,6 +47,7 @@ class NetworkConfig: password: str | None = None proxy_host: str | None = None proxy_port: int | None = None + auth_service: str = "nickserv" # "nickserv", "qbot", or "none" @dataclass(slots=True) @@ -104,6 +105,7 @@ def load(path: Path) -> Config: password=net_raw.get("password"), proxy_host=net_raw.get("proxy_host"), proxy_port=net_raw.get("proxy_port"), + auth_service=net_raw.get("auth_service", "nickserv"), ) if not networks: diff --git a/src/bouncer/network.py b/src/bouncer/network.py index 6fde02b..af11805 100644 --- a/src/bouncer/network.py +++ b/src/bouncer/network.py @@ -486,6 +486,22 @@ class Network: await self._nickserv_complete() return + # Skip auth for networks with no services + if self.cfg.auth_service == "none": + self._status(f"ready as {self.nick} (no auth service)") + return + + # Q bot auth (QuakeNet) + if self.cfg.auth_service == "qbot": + self._nickserv_done = asyncio.Event() + await self._qbot_auth() + try: + await asyncio.wait_for(self._nickserv_done.wait(), timeout=30) + except asyncio.TimeoutError: + log.warning("[%s] Q bot did not respond in 30s", self.cfg.name) + await self._nickserv_complete() + return + # Check for a pending registration from a previous session if await self._resume_pending_verification(): # Pending verification resumed -- skip normal NickServ flow @@ -496,11 +512,11 @@ class Network: self._nickserv_done = asyncio.Event() await self._nickserv_identify() - # If NickServ doesn't respond within 15s, move on + # If NickServ doesn't respond within 30s, move on try: - await asyncio.wait_for(self._nickserv_done.wait(), timeout=15) + await asyncio.wait_for(self._nickserv_done.wait(), timeout=30) except asyncio.TimeoutError: - log.warning("[%s] NickServ did not respond in 15s", self.cfg.name) + log.warning("[%s] NickServ did not respond in 30s", self.cfg.name) self._nickserv_pending = "" await self._nickserv_complete() @@ -573,6 +589,63 @@ class Network: """Signal that NickServ interaction is finished.""" self._nickserv_done.set() + # -- Q bot auth (QuakeNet) ------------------------------------------- + + _QBOT = "Q@CServe.quakenet.org" + + async def _qbot_auth(self) -> None: + """Authenticate with QuakeNet's Q bot using stored credentials. + + Looks up creds by network name. If found, sends AUTH to Q. + No registration path -- QuakeNet accounts are web-only. + """ + if not self.backlog: + log.info("[%s] no backlog, skipping Q auth", self.cfg.name) + await self._nickserv_complete() + return + + creds = await self.backlog.get_nickserv_creds_by_network(self.cfg.name) + if not creds: + log.info("[%s] no stored Q creds, skipping auth", self.cfg.name) + self._status("no Q account (register at quakenet.org)") + await self._nickserv_complete() + return + + stored_nick, stored_pass = creds + self._nickserv_pending = "qbot_auth" + self._nickserv_password = stored_pass + log.info("[%s] authenticating with Q as %s", self.cfg.name, stored_nick) + self._status(f"authenticating with Q as {stored_nick}") + await self.send_raw("PRIVMSG", self._QBOT, f"AUTH {stored_nick} {stored_pass}") + + async def _handle_qbot(self, text: str) -> None: + """Process Q bot NOTICE responses.""" + lower = text.lower() + log.info("[%s] Q: %s", self.cfg.name, text) + + if self._nickserv_pending != "qbot_auth": + return + + if "you are now logged in" in lower: + self._status(f"Q auth successful") + log.info("[%s] Q AUTH succeeded", self.cfg.name) + self._nickserv_pending = "" + # Switch to configured nick if set + if self.cfg.nick and self.cfg.nick != self.nick: + self._nick_confirmed.clear() + await self.send_raw("NICK", self.cfg.nick) + try: + await asyncio.wait_for(self._nick_confirmed.wait(), timeout=10) + except asyncio.TimeoutError: + log.warning("[%s] nick change to %s not confirmed", + self.cfg.name, self.cfg.nick) + await self._nickserv_complete() + elif "incorrect password" in lower or "auth failed" in lower: + self._status("Q auth failed (wrong password)") + log.warning("[%s] Q AUTH failed: %s", self.cfg.name, text) + self._nickserv_pending = "" + await self._nickserv_complete() + async def _verify_email_code(self) -> None: """Poll temp email for NickServ verification code and confirm.""" if not self._nickserv_email: @@ -890,6 +963,10 @@ class Network: if sender == "nickserv": await self._handle_nickserv(text) + # Q bot response handling (QuakeNet) + elif sender == "q": + await self._handle_qbot(text) + # Extract hostname from server notices during connect elif "Found your hostname" in text: # "*** Found your hostname: some.host.example.com"