Skip to content

Commit

Permalink
irc: add modes submodule to handle MODE messages
Browse files Browse the repository at this point in the history
  • Loading branch information
Exirel committed Jul 7, 2021
1 parent 677cdbf commit 5636c6d
Show file tree
Hide file tree
Showing 4 changed files with 869 additions and 1 deletion.
7 changes: 7 additions & 0 deletions docs/source/irc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ Backends
:show-inheritance:


Mode Messages
=============

.. automodule:: sopel.irc.modes
:members:


ISUPPORT
========

Expand Down
2 changes: 1 addition & 1 deletion sopel/irc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from .utils import CapReq, safe


__all__ = ['abstract_backends', 'backends', 'utils']
__all__ = ['abstract_backends', 'backends', 'modes', 'utils']

LOGGER = logging.getLogger(__name__)

Expand Down
234 changes: 234 additions & 0 deletions sopel/irc/modes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
"""Mode management for IRC channels.
.. seealso::
https://modern.ircdocs.horse/#mode-message
.. versionadded:: 8.0
"""
from __future__ import generator_stop

from collections import namedtuple
import logging
from typing import Dict, Generator, List, Optional, Set, Tuple


LOGGER = logging.getLogger(__name__)

PARAM_ALWAYS = 'always'
PARAM_ADDED = 'added'
PARAM_REMOVED = 'removed'
PARAM_NEVER = 'never'
DEFAULT_MODETYPE_PARAM_CONFIG = {
'A': PARAM_ALWAYS,
'B': PARAM_ALWAYS,
'C': PARAM_ADDED,
'D': PARAM_NEVER,
}
"""Default parameter requirements for mode types."""


class ModeException(Exception):
"""Base exception class for mode management."""
pass


class ModeTypeUnknown(ModeException):
"""Exception when a mode's type is unknown or cannot be determined."""
def __init__(self, mode) -> None:
super().__init__('Unknown type for mode %s' % mode)


class ModeTypeImproperlyConfigured(ModeException):
"""Exception when the mode's type management is not configured."""
def __init__(self, mode: str, letter: str) -> None:
message = 'Type {mode} for mode {letter} is not properly configured.'
super().__init__(message.format(mode=mode, letter=letter))


def _modes_is_added(modestring: str
) -> Generator[Tuple[str, bool], None, None]:
is_added = True
for char in modestring:
if char in '+-':
is_added = char == '+'
continue
yield (char, is_added)


ModeString = namedtuple('ModeString', [
'modes',
'privileges',
'ignored_modes',
'leftover_params',
])


class ModeMessage:
"""Modestring parser for IRC's ``MODE`` messages for channel modes."""
PRIVILEGES: Set[str] = {
"v", # VOICE
"h", # HALFOP
"o", # OP
"a", # ADMIN
"q", # OWNER
"y", # OPER
"Y", # OPER
}
"""Set of user privileges used by default."""

def __init__(
self,
chanmodes: Dict[str, Tuple[str, ...]],
type_params: Dict[str, str] = DEFAULT_MODETYPE_PARAM_CONFIG,
privileges: Set[str] = PRIVILEGES,
) -> None:
self.chanmodes: Dict[str, Tuple[str, ...]] = chanmodes
"""Map of mode types (``str``) with their list of modes (``tuple``).
This map should come from ``ISUPPORT``, usually through
:attr:`bot.isupport.CHANMODES <sopel.irc.isupport.ISupport.CHANMODES>`.
"""
self.type_params = type_params
"""Map of mode types (``str``) with their param requirements.
This map default to :data:`DEFAULT_MODETYPE_PARAM_CONFIG`.
"""
self.privileges = privileges
"""Set of valid user privileges.
This set should come from ``ISUPPORT``, usually through
:attr:`bot.isupport.PREFIX <sopel.irc.isupport.ISupport.PREFIX>`.
If a server doesn't advertise its prefixes for user privileges,
:attr:`PRIVILEGES` can be used as a default value.
"""

def get_mode_type(self, mode: str) -> str:
"""Retrieve the type of ``mode``.
:raise ModeTypeUnknown: if the mode's type cannont be determined
:return: the mode's type as defined by :attr:`chanmodes`
::
>>> mm = ModeMessage({'A': tuple('beI'), 'B': tuple('k')}, {})
>>> mm.get_mode_type('b')
'A'
>>> mm.get_mode_type('k')
'B'
This method will raise a :exc:`ModeTypeUnknown` if the mode is unknown,
including the case where ``mode`` is actually a user privilege.
(such as ``v``)
"""
for letter, modes in self.chanmodes.items():
if mode in modes:
return letter
raise ModeTypeUnknown(mode)

def get_mode_info(
self,
mode: str,
is_added: bool
) -> Tuple[Optional[str], bool, bool]:
"""Retrieve ``mode``'s information when added or removed.
:raise ModeTypeUnknown: when the mode's type is unknown and isn't a
user privilege
:raise ModeTypeImproperlyConfigured: when the mode's type is known but
there is no information for
parameters (if and when they are
required by the mode)
:return: a tuple with three values: the mode type, if it requires a
parameter, and if it's a channel mode or a user privilege
::
>>> chanmodes = {'A': tuple('beI'), 'B': tuple('k')}
>>> t_params = {'A': PARAM_ALWAYS, 'B': PARAM_ADDED}
>>> mm = ModeMessage(chanmodes, t_params)
>>> mm.get_mode_info('e', False)
('A', True, False)
>>> mm.get_mode_info('k', False)
('B', False, False)
>>> mm.get_mode_info('v', True)
(None, True, True)
.. note::
A user privilege ``mode`` doesn't have a type so the first value
returned will be ``None`` in that case.
"""
try:
letter = self.get_mode_type(mode)
except ModeTypeUnknown:
if mode in self.privileges:
# a user privilege doesn't have a type
return None, True, True
# not a user privilege: re-raise error
raise

if letter not in self.type_params:
# we don't know how to handle this type of mode
raise ModeTypeImproperlyConfigured(mode, letter)

type_param = self.type_params[letter]
return letter, not type_param == PARAM_NEVER and (
type_param == PARAM_ALWAYS
or type_param == PARAM_ADDED and is_added
or type_param == PARAM_REMOVED and not is_added
), False

def parse_modestring(
self,
modestring: str,
params: Tuple[str, ...]
) -> ModeString:
"""Parse a ``modestring`` for a channel with its ``params``.
:return: the parsed and validated information for that ``modestring``
"""
imodes = iter(_modes_is_added(modestring))
iparams = iter(params)
modes: List = []
privileges: List = []
for mode, is_added in imodes:
param = None
try:
letter, required, is_priv = self.get_mode_info(mode, is_added)
if required:
try:
param = next(iparams)
except StopIteration:
# Not enough parameters: we have to stop here
return ModeString(
tuple(modes),
tuple(privileges),
((mode, is_added),) + tuple(imodes),
tuple(),
)
except ModeException as modeerror:
LOGGER.debug(
'Invalid modestring: %r; error: %s',
modestring,
modeerror,
)
return ModeString(
tuple(modes),
tuple(privileges),
((mode, is_added),) + tuple(imodes),
tuple(iparams),
)

if is_priv:
privileges.append((mode, is_added, param))
else:
modes.append((letter, mode, is_added, param))

return ModeString(
tuple(modes),
tuple(privileges),
tuple(imodes),
tuple(iparams),
)
Loading

0 comments on commit 5636c6d

Please sign in to comment.