# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """IRC protocol parsing helpers. This module contains *pure* helpers used by the IRC client/state machine. Design goals: - Be conservative: parse only what we need for reliability. - Avoid RFC rabbit holes: implement RFC 1459-ish behaviour for numerics, PING/PONG, JOIN detection, and channel normalisation. - Keep parsing side-effect free: no state mutations happen here. Terminology refresher (IRC line shape, simplified): [":" ] [":" ] Examples: :server.example 001 nick :Welcome to the network PING :123456 :nick!user@host JOIN :#channel :server 366 nick #channel :End of /NAMES list. """ from __future__ import annotations from typing import Optional from ...compat import dataclass_compat as dataclass class IRCAuthMode: """IRC authentication mode. The IRC plugin uses a small set of authentication strategies, selected by URL parsing logic. These values are treated as constants and are used by the client (connection setup) rather than by the parsing/state code. NONE No authentication. SERVER Use PASS during registration. NICKSERV Authenticate after registration via NickServ IDENTIFY. ZNC Connect to a ZNC bouncer and presume registration is already handled. In this mode, the client generally avoids emitting registration flows. """ # No authentication NONE = "none" # PASS during registration SERVER = "server" # NickServ IDENTIFY after registration NICKSERV = "nickserv" # ZNC bouncer mode - connects to bouncer only - presumes registration ZNC = "znc" IRC_AUTH_MODES = ( IRCAuthMode.ZNC, IRCAuthMode.SERVER, IRCAuthMode.NICKSERV, IRCAuthMode.NONE, ) @dataclass(frozen=True, slots=True) class IRCMessage: """A parsed IRC line. raw The line as received (minus CRLF). prefix Optional prefix (nick/server). Examples: server.example nick!user@host command The IRC command, for example: PRIVMSG, JOIN, PING, or a numeric string. params A tuple of middle parameters (space separated). trailing The trailing parameter (after ' :'), which may contain spaces. Notes on numerics: Numeric replies are three digits as a string. This helper provides a .numeric property that returns an int, or None when not numeric. """ raw: str prefix: Optional[str] command: str params: tuple[str, ...] trailing: Optional[str] @property def numeric(self) -> Optional[int]: """Return numeric reply code as int when command is a 3-digit string.""" if self.command.isdigit() and len(self.command) == 3: return int(self.command) return None def parse_irc_line(line: str) -> IRCMessage: """Parse an IRC line into its components. This is intentionally tolerant and small, but sufficient for: - detecting PINGs (command == 'PING') - reading common numeric replies (001, 376/422, 366, error codes) - identifying JOIN completion - extracting the welcome nick from 001 The parser follows the usual IRC split rules: - prefix is optional and begins with ':' at the start of the line - trailing is optional and begins with ' :' and consumes the remainder - params are any remaining space-delimited tokens after command """ raw = line.rstrip("\r\n") prefix: Optional[str] = None trailing: Optional[str] = None s = raw # Prefix is only present at the beginning of the message. if s.startswith(":"): parts = s[1:].split(" ", 1) prefix = parts[0] if parts else None s = parts[1] if len(parts) > 1 else "" # Trailing is indicated by " :"; it may contain spaces and consumes the # rest. if " :" in s: before, after = s.split(" :", 1) trailing = after s = before s = s.strip() if not s: return IRCMessage( raw=raw, prefix=prefix, command="", params=(), trailing=trailing) bits = s.split() command = bits[0] params = tuple(bits[1:]) if len(bits) > 1 else () return IRCMessage( raw=raw, prefix=prefix, command=command, params=params, trailing=trailing) def is_ping(msg: IRCMessage) -> bool: """True when message is a PING request.""" return msg.command.upper() == "PING" def ping_payload(msg: IRCMessage) -> str: """Extract the payload to use when responding to a PING.""" if msg.trailing is not None: return msg.trailing return msg.params[0] if msg.params else "" def extract_welcome_nick(msg: IRCMessage) -> Optional[str]: """Extract the nickname from the numeric 001 (welcome) message.""" if msg.numeric != 1: return None if msg.params: return msg.params[0] return None def normalise_channel(name: str) -> str: """Normalise a channel name to include '#'.""" name = name.strip() if not name: return name return name if name.startswith("#") else f"#{name}"