mirror of
https://github.com/morpheus65535/bazarr.git
synced 2026-04-18 05:08:50 -04:00
217 lines
7.1 KiB
Python
217 lines
7.1 KiB
Python
# BSD 2-Clause License
|
|
#
|
|
# Apprise - Push Notification Library.
|
|
# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>
|
|
#
|
|
# 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 State Machine.
|
|
|
|
The client reads IRC lines, parses them into :class:`IRCMessage`, and feeds
|
|
them into :meth:`IRCStateMachine.on_message`. The return value is a list of
|
|
:class:`IRCAction` objects that describe what the client should do next.
|
|
|
|
Responsibilities:
|
|
- Client: I/O (socket), PING/PONG, retries/backoff, timeouts.
|
|
- State machine: registration/join progress, mapping known numeric errors
|
|
to a terminal failure, tracking accepted nick, tracking joined channels.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import field
|
|
from enum import Enum, auto
|
|
from typing import Optional
|
|
|
|
from ...compat import dataclass_compat as dataclass
|
|
from .protocol import IRCMessage, extract_welcome_nick
|
|
|
|
|
|
class IRCState(Enum):
|
|
"""High-level connection state."""
|
|
|
|
DISCONNECTED = auto()
|
|
REGISTERING = auto()
|
|
READY = auto()
|
|
JOINING = auto()
|
|
QUITTING = auto()
|
|
ERROR = auto()
|
|
|
|
|
|
class IRCActionKind(Enum):
|
|
"""Action returned by the state machine."""
|
|
|
|
SEND = auto()
|
|
FAIL = auto()
|
|
NOOP = auto()
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class IRCAction:
|
|
"""Represents the next step for the client."""
|
|
|
|
kind: IRCActionKind
|
|
line: Optional[str] = None
|
|
reason: Optional[str] = None
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class IRCContext:
|
|
"""Mutable context shared between client and state machine."""
|
|
|
|
desired_nick: str
|
|
accepted_nick: str
|
|
fullname: str
|
|
password: Optional[str] = None
|
|
registered: bool = False
|
|
motd_done: bool = False
|
|
joined: set[str] = field(default_factory=set)
|
|
last_error: Optional[str] = None
|
|
|
|
|
|
def _err(msg: IRCMessage) -> str:
|
|
"""Build a human readable error message from an IRC message."""
|
|
if msg.trailing:
|
|
return msg.trailing
|
|
return " ".join(msg.params) if msg.params else "IRC error"
|
|
|
|
|
|
REGISTER_ERRORS = {
|
|
464: "Password incorrect",
|
|
465: "Banned from server",
|
|
468: "Only registered users allowed",
|
|
}
|
|
|
|
JOIN_ERRORS = {
|
|
403: "No such channel",
|
|
471: "Channel is full",
|
|
473: "Invite only channel",
|
|
474: "Banned from channel",
|
|
475: "Bad channel key",
|
|
476: "Bad channel mask",
|
|
477: "Need to be registered",
|
|
489: "Cannot join channel",
|
|
}
|
|
|
|
|
|
class IRCStateMachine:
|
|
"""State machine driven by inbound IRC messages."""
|
|
|
|
def __init__(self, ctx: IRCContext) -> None:
|
|
self.ctx = ctx
|
|
self.state: IRCState = IRCState.DISCONNECTED
|
|
|
|
def start_registration(self) -> list[IRCAction]:
|
|
"""Begin registration by emitting PASS/NICK/USER as required."""
|
|
self.state = IRCState.REGISTERING
|
|
out: list[IRCAction] = []
|
|
if self.ctx.password:
|
|
out.append(IRCAction(
|
|
IRCActionKind.SEND, line=f"PASS {self.ctx.password}"))
|
|
out.append(IRCAction(
|
|
IRCActionKind.SEND, line=f"NICK {self.ctx.desired_nick}"))
|
|
out.append(
|
|
IRCAction(
|
|
IRCActionKind.SEND,
|
|
line=f"USER {self.ctx.desired_nick} 0 * :{self.ctx.fullname}",
|
|
),
|
|
)
|
|
return out
|
|
|
|
def on_message(self, msg: IRCMessage) -> list[IRCAction]:
|
|
"""Process an inbound IRC message and emit next actions."""
|
|
if self.state in (IRCState.ERROR, IRCState.QUITTING):
|
|
return []
|
|
|
|
actions: list[IRCAction] = []
|
|
n = msg.numeric
|
|
|
|
if self.state == IRCState.REGISTERING:
|
|
if n in REGISTER_ERRORS:
|
|
self.ctx.last_error = f"{REGISTER_ERRORS[n]}: {_err(msg)}"
|
|
self.state = IRCState.ERROR
|
|
return [IRCAction(
|
|
IRCActionKind.FAIL, reason=self.ctx.last_error)]
|
|
|
|
if n in (432, 433):
|
|
actions.append(IRCAction(
|
|
IRCActionKind.SEND, line=f"NICK {self.ctx.desired_nick}"))
|
|
return actions
|
|
|
|
if n == 1:
|
|
nick = extract_welcome_nick(msg)
|
|
if nick:
|
|
self.ctx.accepted_nick = nick
|
|
self.ctx.registered = True
|
|
self.state = IRCState.READY
|
|
return actions
|
|
|
|
if n in (376, 422):
|
|
self.ctx.motd_done = True
|
|
if self.ctx.registered:
|
|
self.state = IRCState.READY
|
|
return actions
|
|
|
|
if self.state == IRCState.JOINING:
|
|
if n in JOIN_ERRORS:
|
|
self.ctx.last_error = f"{JOIN_ERRORS[n]}: {_err(msg)}"
|
|
self.state = IRCState.ERROR
|
|
return [IRCAction(
|
|
IRCActionKind.FAIL, reason=self.ctx.last_error)]
|
|
|
|
if n == 443 and len(msg.params) >= 2:
|
|
chan = msg.params[1]
|
|
self.ctx.joined.add(chan)
|
|
self.state = IRCState.READY
|
|
return actions
|
|
|
|
if n == 366 and len(msg.params) >= 2:
|
|
chan = msg.params[1]
|
|
self.ctx.joined.add(chan)
|
|
self.state = IRCState.READY
|
|
return actions
|
|
|
|
if msg.command.upper() == "JOIN":
|
|
chan = msg.trailing or (msg.params[0] if msg.params else "")
|
|
if chan:
|
|
self.ctx.joined.add(chan)
|
|
self.state = IRCState.READY
|
|
return actions
|
|
|
|
return actions
|
|
|
|
def request_join(
|
|
self, channel: str, key: Optional[str] = None) -> list[IRCAction]:
|
|
"""Request a channel join and enter JOINING state."""
|
|
self.state = IRCState.JOINING
|
|
|
|
if key:
|
|
return [IRCAction(
|
|
IRCActionKind.SEND, line=f"JOIN {channel} {key}")]
|
|
return [IRCAction(IRCActionKind.SEND, line=f"JOIN {channel}")]
|
|
|
|
def request_quit(self, message: str) -> list[IRCAction]:
|
|
"""Request a quit and enter QUITTING state."""
|
|
self.state = IRCState.QUITTING
|
|
return [IRCAction(IRCActionKind.SEND, line=f"QUIT :{message}")]
|