├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── VERSION ├── ircstates ├── __init__.py ├── casemap.py ├── channel.py ├── channel_user.py ├── decorators.py ├── emit.py ├── isupport │ ├── __init__.py │ └── tokens.py ├── names.py ├── numerics.py ├── py.typed ├── server.py └── user.py ├── requirements-dev.txt ├── requirements.txt ├── setup.py └── test ├── __init__.py ├── cap.py ├── casemap.py ├── channel.py ├── emit.py ├── isupport.py ├── mode.py ├── motd.py ├── sasl.py ├── user.py └── who.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | python: 4 | - "3.7" 5 | - "3.8" 6 | - "3.9" 7 | install: 8 | - pip3 install mypy types-cachetools -r requirements-dev.txt 9 | before_script: 10 | - pip3 freeze 11 | script: 12 | - python3 -m unittest test 13 | 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 jesopo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ircstates 2 | 3 | [![Build Status](https://travis-ci.org/jesopo/ircstates.svg?branch=master)](https://travis-ci.org/jesopo/ircstates) 4 | 5 | ## rationale 6 | 7 | I wanted a bare-bones reference implementation of taking byte input, parsing it 8 | into tokens and then managing an IRC client session state from it. 9 | 10 | with this library, you can have client session state managed for you and put 11 | additional arbitrary functionality on top of it. 12 | 13 | 14 | ## usage 15 | 16 | ### simple 17 | 18 | ```python 19 | import ircstates 20 | 21 | server = ircstates.Server("freenode") 22 | lines = server.recv(b":server 001 nick :hello world!\r\n") 23 | lines += server.recv(b":nick JOIN #chan\r\n") 24 | for line in lines: 25 | server.parse_tokens(line) 26 | 27 | chan = server.channels["#chan"] 28 | ``` 29 | 30 | ### socket to state 31 | 32 | ```python 33 | import ircstates, irctokens, socket 34 | 35 | NICK = "nickname" 36 | CHAN = "#chan" 37 | HOST = "127.0.0.1" 38 | PORT = 6667 39 | 40 | server = ircstates.Server("freenode") 41 | sock = socket.socket() 42 | 43 | sock.connect((HOST, PORT)) 44 | def _send(raw: str): 45 | sock.sendall(f"{raw}\r\n".encode("utf8")) 46 | 47 | _send("USER test 0 * test") 48 | _send(f"NICK {NICK}") 49 | 50 | while True: 51 | recv_data = sock.recv(1024) 52 | recv_lines = server.recv(recv_data) 53 | for line in recv_lines: 54 | server.parse_tokens(line) 55 | print(f"< {line.format()}") 56 | 57 | # user defined behaviors... 58 | if line.command == "PING": 59 | _send(f"PONG :{line.params[0]}") 60 | ``` 61 | 62 | ### get a user's channels 63 | ```python 64 | >>> server.users 65 | {'nickname': User(nickname='nickname')} 66 | >>> user = server.users["nickname"] 67 | >>> user 68 | User(nickname='nickname') 69 | >>> user.channels 70 | {'#chan'} 71 | ``` 72 | 73 | ### get a channel's users 74 | ```python 75 | >>> server.channels 76 | {'#chan': Channel(name='#chan')} 77 | >>> channel = server.channels["#chan"] 78 | >>> channel 79 | Channel(name='#chan') 80 | >>> channel.users 81 | {'jess': ChannelUser(#chan jess)} 82 | ``` 83 | 84 | ### get a user's modes in channel 85 | ```python 86 | >>> channel = server.channels["#chan"] 87 | >>> channel_user = channel.users["nickname"] 88 | >>> channel_user 89 | ChannelUser(#chan jess +ov) 90 | >>> channel_user.modes 91 | {'o', 'v'} 92 | ``` 93 | 94 | ## contact 95 | 96 | Come say hi at `#irctokens` on irc.libera.chat 97 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.13.0 2 | -------------------------------------------------------------------------------- /ircstates/__init__.py: -------------------------------------------------------------------------------- 1 | from .server import Server, ServerDisconnectedException 2 | from .user import User 3 | from .channel import Channel 4 | from .channel_user import ChannelUser 5 | from .casemap import casefold, CaseMap 6 | from .emit import * 7 | -------------------------------------------------------------------------------- /ircstates/casemap.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from string import ascii_lowercase, ascii_uppercase 3 | from typing import Dict, List, Optional 4 | 5 | class CaseMap(Enum): 6 | ASCII = "ascii" 7 | RFC1459 = "rfc1459" 8 | 9 | CASEMAPS: Dict[CaseMap, Dict[int, Optional[int]]] = { 10 | CaseMap.ASCII: str.maketrans( 11 | r"ABCDEFGHIJKLMNOPQRSTUVWXYZ", 12 | r"abcdefghijklmnopqrstuvwxyz" 13 | ), 14 | CaseMap.RFC1459: str.maketrans( 15 | r"ABCDEFGHIJKLMNOPQRSTUVWXYZ\[]^", 16 | r"abcdefghijklmnopqrstuvwxyz|{}~" 17 | ) 18 | } 19 | def casefold(casemap_name: CaseMap, s: str): 20 | casemap = CASEMAPS[casemap_name] 21 | return s.translate(casemap) 22 | -------------------------------------------------------------------------------- /ircstates/channel.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional, Set 2 | from pendulum import DateTime 3 | 4 | from .channel_user import ChannelUser 5 | from .names import Name 6 | 7 | class Channel(object): 8 | def __init__(self, name: Name): 9 | self._name = name 10 | 11 | self.users: Dict[str, ChannelUser] = {} 12 | 13 | self.topic: Optional[str] = None 14 | self.topic_setter: Optional[str] = None 15 | self.topic_time: Optional[DateTime] = None 16 | 17 | self.created: Optional[DateTime] = None 18 | 19 | self._list_modes_temp: Dict[str, List[str]] = {} 20 | self.list_modes: Dict[str, List[str]] = {} 21 | self.modes: Dict[str, Optional[str]] = {} 22 | 23 | def __repr__(self) -> str: 24 | return f"Channel(name={self.name!r})" 25 | 26 | def get_name(self) -> Name: 27 | return self._name 28 | @property 29 | def name(self) -> str: 30 | return self._name.normal 31 | @property 32 | def name_lower(self) -> str: 33 | return self._name.folded 34 | 35 | def change_name(self, 36 | normal: str, 37 | folded: str): 38 | self._name.normal = normal 39 | self._name.folded = folded 40 | 41 | def add_mode(self, 42 | char: str, 43 | param: Optional[str], 44 | list_mode: bool): 45 | if list_mode: 46 | if param is not None: 47 | if not char in self.list_modes: 48 | self.list_modes[char] = [] 49 | if not param in self.list_modes[char]: 50 | self.list_modes[char].append(param) 51 | else: 52 | self.modes[char] = param 53 | 54 | def remove_mode(self, 55 | char: str, 56 | param: Optional[str]): 57 | if char in self.list_modes: 58 | if (param is not None and 59 | param in self.list_modes[char]): 60 | self.list_modes[char].remove(param) 61 | elif char in self.modes: 62 | del self.modes[char] 63 | -------------------------------------------------------------------------------- /ircstates/channel_user.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Set 2 | from .names import Name 3 | from pendulum import DateTime, now 4 | 5 | class ChannelUser(object): 6 | def __init__(self, 7 | nickname: Name, 8 | channel_name: Name): 9 | self._nickname = nickname 10 | self._channel_name = channel_name 11 | 12 | self.modes: Set[str] = set() 13 | self.since = now("utc") 14 | self.joined: Optional[DateTime] = None 15 | 16 | def __repr__(self) -> str: 17 | outs: List[str] = [self.channel, self.nickname] 18 | if self.modes: 19 | outs.append(f"+{''.join(self.modes)}") 20 | return f"ChannelUser({' '.join(outs)})" 21 | 22 | @property 23 | def nickname(self) -> str: 24 | return self._nickname.normal 25 | @property 26 | def nickname_lower(self) -> str: 27 | return self._nickname.folded 28 | 29 | @property 30 | def channel(self) -> str: 31 | return self._channel_name.normal 32 | -------------------------------------------------------------------------------- /ircstates/decorators.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | def handler_decorator(d: Dict[str, List[Any]]): 4 | def _handler(command: str): 5 | def _(func: Any): 6 | if not command in d: 7 | d[command] = [] 8 | d[command].append(func) 9 | return func 10 | return _ 11 | return _handler 12 | -------------------------------------------------------------------------------- /ircstates/emit.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from .user import User 3 | from .channel import Channel 4 | 5 | class Emit(object): 6 | command: Optional[str] = None 7 | subcommand: Optional[str] = None 8 | 9 | text: Optional[str] = None 10 | tokens: Optional[List[str]] = None 11 | 12 | finished: Optional[bool] = None 13 | 14 | self: Optional[bool] = None 15 | self_source: Optional[bool] = None 16 | self_target: Optional[bool] = None 17 | 18 | user: Optional[User] = None 19 | user_source: Optional[User] = None 20 | user_target: Optional[User] = None 21 | 22 | users: Optional[List[User]] = None 23 | 24 | channel: Optional[Channel] = None 25 | channel_source: Optional[Channel] = None 26 | channel_target: Optional[Channel] = None 27 | 28 | target: Optional[str] = None 29 | -------------------------------------------------------------------------------- /ircstates/isupport/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional 2 | from .tokens import ChanModes, Prefix 3 | from ..casemap import CaseMap 4 | 5 | CASEMAPPINGS = ["rfc1459", "ascii"] 6 | 7 | def _parse_escapes(s: str): 8 | idx = 0 9 | out = "" 10 | 11 | while idx < (len(s)): 12 | if s[idx] == "\\": 13 | if s[idx+1:]: 14 | if (s[idx+1] == "x" and 15 | len(s[idx+2:]) >= 2): 16 | out += chr(int(s[idx+2:idx+4], 16)) 17 | idx += 4 18 | else: 19 | out += s[idx+1] 20 | idx += 2 21 | else: 22 | out += s[idx] 23 | idx += 1 24 | return out 25 | 26 | class ISupport(object): 27 | raw: Dict[str, Optional[str]] 28 | 29 | network: Optional[str] = None 30 | 31 | chanmodes = ChanModes(["b"], ["k"], ["l"], ["i", "m", "n", "p", "s", "t"]) 32 | prefix = Prefix(["o", "v"], ["@", "+"]) 33 | 34 | modes: int = 3 # -1 if "no limit" 35 | casemapping: CaseMap = CaseMap.RFC1459 36 | chantypes: List[str] = ["#"] 37 | statusmsg: List[str] = [] 38 | 39 | callerid: Optional[str] = None 40 | excepts: Optional[str] = None 41 | invex: Optional[str] = None 42 | 43 | monitor: Optional[int] = None # -1 if "no limit" 44 | watch: Optional[int] = None # -1 if "no limit" 45 | whox: bool = False 46 | nicklen: int = 9 # from RFC1459 47 | 48 | def __init__(self): 49 | self.raw = {} 50 | 51 | def from_tokens(self, tokens: List[str]): 52 | for token in tokens: 53 | key, sep, value = token.partition("=") 54 | value = _parse_escapes(value) 55 | self.raw[key] = value if sep else None 56 | 57 | if key == "NETWORK": 58 | self.network = value 59 | 60 | elif key == "CHANMODES": 61 | a, b, c, d = value.split(",") 62 | self.chanmodes = ChanModes(list(a), list(b), list(c), list(d)) 63 | 64 | elif key == "PREFIX": 65 | modes, prefixes = value[1:].split(")") 66 | self.prefix = Prefix(list(modes), list(prefixes)) 67 | 68 | elif key == "STATUSMSG": 69 | self.statusmsg = list(value) 70 | 71 | elif key == "MODES": 72 | self.modes = int(value) if value else -1 73 | elif key == "MONITOR": 74 | self.monitor = int(value) if value else -1 75 | elif key == "WATCH": 76 | self.watch = int(value) if value else -1 77 | 78 | elif key == "CASEMAPPING": 79 | self.casemapping = CaseMap(value) 80 | 81 | elif key == "CHANTYPES": 82 | self.chantypes = list(value) 83 | 84 | elif key == "CALLERID": 85 | self.callerid = value or "g" 86 | elif key == "EXCEPTS": 87 | self.excepts = value or "e" 88 | elif key == "INVEX": 89 | self.invex = value or "I" 90 | 91 | elif key == "WHOX": 92 | self.whox = True 93 | 94 | elif key == "NICKLEN": 95 | self.nicklen = int(value) 96 | -------------------------------------------------------------------------------- /ircstates/isupport/tokens.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | class ChanModes(object): 4 | def __init__(self, 5 | a_modes: List[str], 6 | b_modes: List[str], 7 | c_modes: List[str], 8 | d_modes: List[str]): 9 | self.a_modes = a_modes 10 | self.b_modes = b_modes 11 | self.c_modes = c_modes 12 | self.d_modes = d_modes 13 | 14 | class Prefix(object): 15 | def __init__(self, 16 | modes: List[str], 17 | prefixes: List[str]): 18 | self.modes = modes 19 | self.prefixes = prefixes 20 | 21 | def from_mode(self, mode: str) -> Optional[str]: 22 | if mode in self.modes: 23 | return self.prefixes[self.modes.index(mode)] 24 | return None 25 | def from_prefix(self, prefix: str) -> Optional[str]: 26 | if prefix in self.prefixes: 27 | return self.modes[self.prefixes.index(prefix)] 28 | return None 29 | -------------------------------------------------------------------------------- /ircstates/names.py: -------------------------------------------------------------------------------- 1 | 2 | class Name(object): 3 | def __init__(self, 4 | normal: str, 5 | folded: str): 6 | self.normal = normal 7 | self.folded = folded 8 | -------------------------------------------------------------------------------- /ircstates/numerics.py: -------------------------------------------------------------------------------- 1 | 2 | RPL_WELCOME = "001" 3 | RPL_ISUPPORT = "005" 4 | RPL_MOTD = "372" 5 | RPL_MOTDSTART = "375" 6 | RPL_ENDOFMOTD = "376" 7 | ERR_NOMOTD = "422" 8 | RPL_UMODEIS = "221" 9 | RPL_VISIBLEHOST = "396" 10 | RPL_TRYAGAIN = "263" 11 | RPL_YOUREOPER = "381" 12 | 13 | ERR_NOSUCHNICK = "401" 14 | ERR_NOSUCHSERVER = "402" 15 | 16 | RPL_CHANNELMODEIS = "324" 17 | RPL_CREATIONTIME = "329" 18 | RPL_TOPIC = "332" 19 | RPL_TOPICWHOTIME = "333" 20 | 21 | RPL_WHOREPLY = "352" 22 | RPL_WHOSPCRPL = "354" 23 | RPL_ENDOFWHO = "315" 24 | RPL_NAMREPLY = "353" 25 | RPL_ENDOFNAMES = "366" 26 | 27 | RPL_WHOWASUSER = "314" 28 | RPL_ENDOFWHOWAS = "369" 29 | 30 | RPL_BANLIST = "367" 31 | RPL_ENDOFBANLIST = "368" 32 | RPL_QUIETLIST = "728" 33 | RPL_ENDOFQUIETLIST = "729" 34 | 35 | RPL_LOGGEDIN = "900" 36 | RPL_LOGGEDOUT = "901" 37 | RPL_SASLSUCCESS = "903" 38 | ERR_SASLFAIL = "904" 39 | ERR_SASLTOOLONG = "905" 40 | ERR_SASLABORTED = "906" 41 | ERR_SASLALREADY = "907" 42 | RPL_SASLMECHS = "908" 43 | 44 | RPL_WHOISUSER = "311" 45 | RPL_WHOISSERVER = "312" 46 | RPL_WHOISOPERATOR = "313" 47 | RPL_WHOISIDLE = "317" 48 | RPL_WHOISCHANNELS = "319" 49 | RPL_WHOISACCOUNT = "330" 50 | RPL_WHOISHOST = "378" 51 | RPL_WHOISMODES = "379" 52 | RPL_WHOISSECURE = "671" 53 | RPL_AWAY = "301" 54 | RPL_ENDOFWHOIS = "318" 55 | 56 | ERR_ERRONEUSNICKNAME = "432" 57 | ERR_NICKNAMEINUSE = "433" 58 | ERR_BANNICKCHANGE = "435" 59 | ERR_UNAVAILRESOURCE = "437" 60 | ERR_NICKTOOFAST = "438" 61 | ERR_CANTCHANGENICK = "447" 62 | 63 | ERR_NOSUCHCHANNEL = "403" 64 | ERR_TOOMANYCHANNELS = "405" 65 | ERR_USERONCHANNEL = "443" 66 | ERR_LINKCHANNEL = "470" 67 | ERR_BADCHANNAME = "479" 68 | ERR_BADCHANNEL = "926" 69 | 70 | 71 | ERR_BANNEDFROMCHAN = "474" 72 | ERR_INVITEONLYCHAN = "473" 73 | ERR_BADCHANNELKEY = "475" 74 | ERR_CHANNELISFULL = "471" 75 | ERR_NEEDREGGEDNICK = "477" 76 | ERR_THROTTLE = "480" 77 | 78 | RPL_LOGOFF = "601" 79 | RPL_MONONLINE = "730" 80 | RPL_MONOFFLINE = "731" 81 | 82 | RPL_RSACHALLENGE2 = "740" 83 | RPL_ENDOFRSACHALLENGE2 = "741" 84 | -------------------------------------------------------------------------------- /ircstates/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jesopo/ircstates/7a651b5840783fd8334f4989632c2bda2160de77/ircstates/py.typed -------------------------------------------------------------------------------- /ircstates/server.py: -------------------------------------------------------------------------------- 1 | from ipaddress import ip_address 2 | from typing import Callable, Dict, List, Optional, Set, Tuple 3 | from irctokens import Line, build, Hostmask, StatefulDecoder, StatefulEncoder 4 | from irctokens import hostmask as hostmask_ 5 | from pendulum import from_timestamp, now 6 | 7 | from .user import User 8 | from .channel import Channel 9 | from .channel_user import ChannelUser 10 | from .isupport import ISupport 11 | from .decorators import handler_decorator 12 | from .casemap import casefold 13 | from .names import Name 14 | from .emit import * 15 | from .numerics import * 16 | 17 | LINE_HANDLERS: Dict[str, List[Callable[["Server", Line], Emit]]] = {} 18 | line_handler = handler_decorator(LINE_HANDLERS) 19 | 20 | class ServerException(Exception): 21 | pass 22 | class ServerDisconnectedException(ServerException): 23 | pass 24 | 25 | WHO_TYPE = "735" # randomly generated 26 | TYPE_EMIT = Optional[Emit] 27 | 28 | class Server(object): 29 | def __init__(self, name: str): 30 | self.name = name 31 | 32 | self.nickname = "" 33 | self.nickname_lower = "" 34 | self.username: Optional[str] = None 35 | self.hostname: Optional[str] = None 36 | self.realname: Optional[str] = None 37 | self.account: Optional[str] = None 38 | self.server: Optional[str] = None 39 | self.away: Optional[str] = None 40 | self.ip: Optional[str] = None 41 | 42 | self.registered = False 43 | self.modes: Set[str] = set() 44 | self.motd: List[str] = [] 45 | 46 | self._decoder = StatefulDecoder() 47 | 48 | self.users: Dict[str, User] = {} 49 | self.channels: Dict[str, Channel] = {} 50 | 51 | self.isupport = ISupport() 52 | 53 | self.has_cap: bool = False 54 | self._temp_caps: Dict[str, str] = {} 55 | self.available_caps: Dict[str, str] = {} 56 | self.agreed_caps: List[str] = [] 57 | 58 | def __repr__(self) -> str: 59 | return f"Server(name={self.name!r})" 60 | 61 | def recv(self, data: bytes) -> List[Line]: 62 | lines = self._decoder.push(data) 63 | if lines is None: 64 | raise ServerDisconnectedException() 65 | return lines 66 | 67 | def parse_tokens(self, line: Line) -> TYPE_EMIT: 68 | ret_emit: TYPE_EMIT = None 69 | if line.command in LINE_HANDLERS: 70 | for callback in LINE_HANDLERS[line.command]: 71 | emit = callback(self, line) 72 | if emit is not None and ret_emit is None: 73 | emit.command = line.command 74 | ret_emit = emit 75 | return ret_emit 76 | 77 | def casefold(self, s1: str): 78 | return casefold(self.isupport.casemapping, s1) 79 | def casefold_equals(self, s1: str, s2: str): 80 | return self.casefold(s1) == self.casefold(s2) 81 | def is_me(self, nickname: str): 82 | return self.casefold(nickname) == self.nickname_lower 83 | 84 | def has_user(self, nickname: str) -> bool: 85 | return self.casefold(nickname) in self.users 86 | def _add_user(self, nickname: str, nickname_lower: str): 87 | user = self.create_user(Name(nickname, nickname_lower)) 88 | self.users[nickname_lower] = user 89 | 90 | def is_channel(self, target: str) -> bool: 91 | return target[:1] in self.isupport.chantypes 92 | def has_channel(self, name: str) -> bool: 93 | return self.casefold(name) in self.channels 94 | def get_channel(self, name: str) -> Optional[Channel]: 95 | return self.channels.get(self.casefold(name), None) 96 | 97 | def create_user(self, nickname: Name) -> User: 98 | return User(nickname) 99 | 100 | def create_channel(self, name: Name) -> Channel: 101 | return Channel(name) 102 | 103 | def _user_join(self, channel: Channel, user: User) -> ChannelUser: 104 | channel_user = ChannelUser( 105 | user.get_name(), 106 | channel.get_name()) 107 | 108 | user.channels.add(self.casefold(channel.name)) 109 | channel.users[user.nickname_lower] = channel_user 110 | return channel_user 111 | 112 | def prepare_whox(self, target: str) -> Line: 113 | return build("WHO", [target, f"n%afhinrstu,{WHO_TYPE}"]) 114 | 115 | def _self_hostmask(self, hostmask: Hostmask): 116 | self.nickname = hostmask.nickname 117 | if hostmask.username: 118 | self.username = hostmask.username 119 | if hostmask.hostname: 120 | self.hostname = hostmask.hostname 121 | 122 | def _emit(self) -> Emit: 123 | return Emit() 124 | 125 | @line_handler(RPL_WELCOME) 126 | # first message reliably sent to us after registration is complete 127 | def _handle_welcome(self, line: Line) -> Emit: 128 | self.nickname = line.params[0] 129 | self.nickname_lower = self.casefold(line.params[0]) 130 | self.registered = True 131 | return self._emit() 132 | 133 | @line_handler(RPL_ISUPPORT) 134 | # https://defs.ircdocs.horse/defs/isupport.html 135 | def _handle_ISUPPORT(self, line: Line) -> Emit: 136 | self.isupport.from_tokens(line.params[1:-1]) 137 | return self._emit() 138 | 139 | @line_handler(RPL_MOTDSTART) 140 | # start of MOTD 141 | def _handle_motd_start(self, line: Line) -> Emit: 142 | self.motd.clear() 143 | return self._emit() 144 | @line_handler(RPL_MOTDSTART) 145 | # start of MOTD 146 | @line_handler(RPL_MOTD) 147 | # line of MOTD 148 | def _handle_motd_line(self, line: Line) -> Emit: 149 | emit = self._emit() 150 | text = line.params[1] 151 | emit.text = text 152 | self.motd.append(text) 153 | return emit 154 | 155 | @line_handler("NICK") 156 | def _handle_NICK(self, line: Line) -> Emit: 157 | new_nickname = line.params[0] 158 | new_nickname_lower = self.casefold(new_nickname) 159 | nickname_lower = self.casefold(line.hostmask.nickname) 160 | 161 | emit = self._emit() 162 | 163 | if nickname_lower in self.users: 164 | user = self.users.pop(nickname_lower) 165 | emit.user = user 166 | user.change_nickname(new_nickname, new_nickname_lower) 167 | self.users[new_nickname_lower] = user 168 | 169 | for channel_lower in user.channels: 170 | channel = self.channels[channel_lower] 171 | channel_user = channel.users.pop(nickname_lower) 172 | channel.users[user.nickname_lower] = channel_user 173 | 174 | if nickname_lower == self.nickname_lower: 175 | emit.self = True 176 | 177 | self.nickname = new_nickname 178 | self.nickname_lower = new_nickname_lower 179 | return emit 180 | 181 | @line_handler("JOIN") 182 | def _handle_JOIN(self, line: Line) -> Emit: 183 | extended = len(line.params) == 3 184 | 185 | account = line.params[1].strip("*") if extended else None 186 | realname = line.params[2] if extended else None 187 | 188 | emit = self._emit() 189 | 190 | channel_lower = self.casefold(line.params[0]) 191 | nickname_lower = self.casefold(line.hostmask.nickname) 192 | if nickname_lower == self.nickname_lower: 193 | emit.self = True 194 | if not channel_lower in self.channels: 195 | channel = self.create_channel( 196 | Name(line.params[0], channel_lower) 197 | ) 198 | #TODO: put this somewhere better 199 | for mode in self.isupport.chanmodes.a_modes: 200 | channel.list_modes[mode] = [] 201 | 202 | self.channels[channel_lower] = channel 203 | 204 | self._self_hostmask(line.hostmask) 205 | if extended: 206 | self.account = account 207 | self.realname = realname 208 | 209 | if channel_lower in self.channels: 210 | channel = self.channels[channel_lower] 211 | emit.channel = channel 212 | if not nickname_lower in self.users: 213 | self._add_user(line.hostmask.nickname, nickname_lower) 214 | 215 | user = self.users[nickname_lower] 216 | emit.user = user 217 | if line.hostmask.username: 218 | user.username = line.hostmask.username 219 | if line.hostmask.hostname: 220 | user.hostname = line.hostmask.hostname 221 | if extended: 222 | user.account = account 223 | user.realname = realname 224 | 225 | channel_user = self._user_join(channel, user) 226 | channel_user.joined = now("utc") 227 | return emit 228 | 229 | def _user_part(self, line: Line, 230 | nickname: str, 231 | channel_name: str, 232 | reason_i: int) -> Tuple[Emit, Optional[User]]: 233 | emit = self._emit() 234 | channel_lower = self.casefold(channel_name) 235 | reason = line.params[reason_i] if line.params[reason_i:] else None 236 | if not reason is None: 237 | emit.text = reason 238 | 239 | user: Optional[User] = None 240 | if channel_lower in self.channels: 241 | channel = self.channels[channel_lower] 242 | emit.channel = channel 243 | 244 | nickname_lower = self.casefold(nickname) 245 | if nickname_lower in self.users: 246 | user = self.users[nickname_lower] 247 | 248 | user.channels.remove(channel.name_lower) 249 | del channel.users[user.nickname_lower] 250 | if not user.channels: 251 | del self.users[nickname_lower] 252 | 253 | if nickname_lower == self.nickname_lower: 254 | del self.channels[channel_lower] 255 | 256 | for key, cuser in channel.users.items(): 257 | ruser = self.users[key] 258 | ruser.channels.remove(channel.name_lower) 259 | if not ruser.channels: 260 | del self.users[ruser.nickname_lower] 261 | 262 | return emit, user 263 | 264 | @line_handler("PART") 265 | def _handle_PART(self, line: Line) -> Emit: 266 | emit, user = self._user_part(line, line.hostmask.nickname, 267 | line.params[0], 1) 268 | if not user is None: 269 | emit.user = user 270 | if user.nickname_lower == self.nickname_lower: 271 | emit.self = True 272 | return emit 273 | @line_handler("KICK") 274 | def _handle_KICK(self, line: Line) -> Emit: 275 | emit, kicked = self._user_part(line, line.params[1], line.params[0], 276 | 2) 277 | if not kicked is None: 278 | emit.user_target = kicked 279 | 280 | if kicked.nickname_lower == self.nickname_lower: 281 | emit.self = True 282 | 283 | kicker_lower = self.casefold(line.hostmask.nickname) 284 | if kicker_lower == self.nickname_lower: 285 | emit.self_source = True 286 | 287 | if kicker_lower in self.users: 288 | emit.user_source = self.users[kicker_lower] 289 | else: 290 | emit.user_source = self.create_user( 291 | Name(line.hostmask.nickname, kicker_lower) 292 | ) 293 | 294 | return emit 295 | 296 | def _self_quit(self): 297 | self.users.clear() 298 | self.channels.clear() 299 | 300 | @line_handler("QUIT") 301 | def _handle_quit(self, line: Line) -> Emit: 302 | emit = self._emit() 303 | nickname_lower = self.casefold(line.hostmask.nickname) 304 | reason = line.params[0] if line.params else None 305 | if not reason is None: 306 | emit.text = reason 307 | 308 | if nickname_lower == self.nickname_lower: 309 | emit.self = True 310 | self._self_quit() 311 | else: 312 | if nickname_lower in self.users: 313 | user = self.users.pop(nickname_lower) 314 | emit.user = user 315 | for channel_lower in user.channels: 316 | channel = self.channels[channel_lower] 317 | del channel.users[user.nickname_lower] 318 | return emit 319 | 320 | @line_handler("ERROR") 321 | def _handle_ERROR(self, line: Line) -> Emit: 322 | self._self_quit() 323 | return self._emit() 324 | 325 | @line_handler(RPL_NAMREPLY) 326 | # channel's user list, "NAMES #channel" response (and on-join) 327 | def _handle_names(self, line: Line) -> Emit: 328 | emit = self._emit() 329 | channel_lower = self.casefold(line.params[2]) 330 | if channel_lower in self.channels: 331 | channel = self.channels[channel_lower] 332 | emit.channel = channel 333 | nicknames = list(filter(bool, line.params[3].split(" "))) 334 | users: List[User] = [] 335 | emit.users = users 336 | 337 | for nickname in nicknames: 338 | modes = "" 339 | for char in nickname: 340 | mode = self.isupport.prefix.from_prefix(char) 341 | if mode: 342 | modes += mode 343 | else: 344 | break 345 | 346 | hostmask = hostmask_(nickname[len(modes):]) 347 | nickname_lower = self.casefold(hostmask.nickname) 348 | if not nickname_lower in self.users: 349 | self._add_user(hostmask.nickname, nickname_lower) 350 | user = self.users[nickname_lower] 351 | users.append(user) 352 | 353 | if not nickname_lower in channel.users: 354 | channel_user = self._user_join(channel, user) 355 | else: 356 | channel_user = channel.users[nickname_lower] 357 | 358 | if hostmask.username: 359 | user.username = hostmask.username 360 | if hostmask.hostname: 361 | user.hostname = hostmask.hostname 362 | 363 | if nickname_lower == self.nickname_lower: 364 | self._self_hostmask(hostmask) 365 | 366 | for mode in modes: 367 | channel_user.modes.add(mode) 368 | return emit 369 | 370 | @line_handler(RPL_CREATIONTIME) 371 | # channel creation time, "MODE #channel" response (and on-join) 372 | def _handle_creation_time(self, line: Line) -> Emit: 373 | emit = self._emit() 374 | channel_lower = self.casefold(line.params[1]) 375 | if channel_lower in self.channels: 376 | channel = self.channels[channel_lower] 377 | emit.channel = channel 378 | channel.created = from_timestamp(int(line.params[2])) 379 | return emit 380 | 381 | @line_handler("TOPIC") 382 | def _handle_TOPIC(self, line: Line) -> Emit: 383 | emit = self._emit() 384 | channel_lower = self.casefold(line.params[0]) 385 | if channel_lower in self.channels: 386 | channel = self.channels[channel_lower] 387 | emit.channel = channel 388 | channel.topic = line.params[1] 389 | channel.topic_setter = line.source 390 | channel.topic_time = now("utc") 391 | return emit 392 | 393 | @line_handler(RPL_TOPIC) 394 | # topic text, "TOPIC #channel" response (and on-join) 395 | def _handle_topic_num(self, line: Line) -> Emit: 396 | emit = self._emit() 397 | channel_lower = self.casefold(line.params[1]) 398 | if channel_lower in self.channels: 399 | channel = self.channels[channel_lower] 400 | emit.channel = channel 401 | self.channels[channel_lower].topic = line.params[2] 402 | return emit 403 | @line_handler(RPL_TOPICWHOTIME) 404 | # topic setby, "TOPIC #channel" response (and on-join) 405 | def _handle_topic_time(self, line: Line) -> Emit: 406 | emit = self._emit() 407 | channel_lower = self.casefold(line.params[1]) 408 | if channel_lower in self.channels: 409 | channel = self.channels[channel_lower] 410 | emit.channel = channel 411 | channel.topic_setter = line.params[2] 412 | channel.topic_time = from_timestamp(int(line.params[3])) 413 | return emit 414 | 415 | def _channel_modes(self, 416 | channel: Channel, 417 | modes: List[str], 418 | params: List[str] 419 | ) -> List[Tuple[str, Optional[str]]]: 420 | tokens: List[Tuple[str, Optional[str]]] = [] 421 | 422 | for mode in modes: 423 | add = mode[0] == "+" 424 | char = mode[1] 425 | arg: Optional[str] = None 426 | 427 | if char in self.isupport.prefix.modes: # a user's status 428 | arg = params.pop(0) 429 | nickname_lower = self.casefold(arg) 430 | 431 | if nickname_lower in self.users: 432 | user = self.users[nickname_lower] 433 | channel_user = channel.users[user.nickname_lower] 434 | if add: 435 | channel_user.modes.add(char) 436 | else: 437 | channel_user.modes.discard(char) 438 | else: 439 | has_arg = False 440 | is_list = False 441 | if char in self.isupport.chanmodes.a_modes: 442 | has_arg = True 443 | is_list = True 444 | elif add: 445 | has_arg = char in (self.isupport.chanmodes.b_modes+ 446 | self.isupport.chanmodes.c_modes) 447 | else: # remove 448 | has_arg = char in self.isupport.chanmodes.b_modes 449 | 450 | if has_arg: 451 | arg = params.pop(0) 452 | 453 | if add: 454 | channel.add_mode(char, arg, is_list) 455 | else: 456 | channel.remove_mode(char, arg) 457 | 458 | tokens.append((mode, arg)) 459 | 460 | return tokens 461 | 462 | @line_handler("MODE") 463 | def _handle_MODE(self, line: Line) -> Emit: 464 | emit = self._emit() 465 | target = line.params[0] 466 | modes_str = line.params[1] 467 | params = line.params[2:].copy() 468 | 469 | modifier = "+" 470 | modes: List[str] = [] 471 | 472 | for c in list(modes_str): 473 | if c in ["+", "-"]: 474 | modifier = c 475 | else: 476 | modes.append(f"{modifier}{c}") 477 | 478 | target_lower = self.casefold(target) 479 | if target_lower == self.nickname_lower: 480 | emit.self_target = True 481 | emit.tokens = modes 482 | 483 | for mode in modes: 484 | add = mode[0] == "+" 485 | char = mode[1] 486 | if add: 487 | self.modes.add(char) 488 | else: 489 | self.modes.discard(char) 490 | elif target_lower in self.channels: 491 | channel = self.channels[self.casefold(target)] 492 | emit.channel = channel 493 | ctokens = self._channel_modes(channel, modes, params) 494 | 495 | ctokens_str: List[str] = [] 496 | for mode, arg in ctokens: 497 | if arg is not None: 498 | ctokens_str.append(f"{mode} {arg}") 499 | else: 500 | ctokens_str.append(mode) 501 | emit.tokens = ctokens_str 502 | return emit 503 | 504 | @line_handler(RPL_CHANNELMODEIS) 505 | # channel modes, "MODE #channel" response (sometimes on-join?) 506 | def _handle_channelmodeis(self, line: Line) -> Emit: 507 | emit = self._emit() 508 | channel_lower = self.casefold(line.params[1]) 509 | if channel_lower in self.channels: 510 | channel = self.channels[channel_lower] 511 | emit.channel = channel 512 | modes = [f"+{char}" for char in line.params[2].lstrip("+")] 513 | params = line.params[3:] 514 | self._channel_modes(channel, modes, params) 515 | return emit 516 | 517 | @line_handler(RPL_UMODEIS) 518 | # our own user modes, "MODE nickname" response (sometimes on-connect?) 519 | def _handle_umodeis(self, line: Line) -> Emit: 520 | for char in line.params[1].lstrip("+"): 521 | self.modes.add(char) 522 | return self._emit() 523 | 524 | def _mode_list(self, 525 | channel_name: str, 526 | mode: str, 527 | mask: str): 528 | channel_lower = self.casefold(channel_name) 529 | if channel_lower in self.channels: 530 | channel = self.channels[channel_lower] 531 | if not mode in channel._list_modes_temp: 532 | channel._list_modes_temp[mode] = [] 533 | channel._list_modes_temp[mode].append(mask) 534 | def _mode_list_end(self, 535 | channel_name: str, 536 | mode: str): 537 | channel_lower = self.casefold(channel_name) 538 | if channel_lower in self.channels: 539 | channel = self.channels[channel_lower] 540 | if mode in channel._list_modes_temp: 541 | mlist = channel._list_modes_temp.pop(mode) 542 | channel.list_modes[mode] = mlist 543 | 544 | @line_handler(RPL_BANLIST) 545 | def _handle_banlist(self, line: Line) -> Emit: 546 | channel = line.params[1] 547 | mask = line.params[2] 548 | 549 | if len(line.params) > 3: 550 | # parse these out but we're not storing them yet 551 | set_by = line.params[3] 552 | set_at = int(line.params[4]) 553 | 554 | self._mode_list(channel, "b", mask) 555 | return self._emit() 556 | 557 | @line_handler(RPL_ENDOFBANLIST) 558 | def _handle_banlist_end(self, line: Line) -> Emit: 559 | channel = line.params[1] 560 | self._mode_list_end(channel, "b") 561 | return self._emit() 562 | 563 | @line_handler(RPL_QUIETLIST) 564 | def _handle_quietlist(self, line: Line) -> Emit: 565 | channel = line.params[1] 566 | mode = line.params[2] 567 | mask = line.params[3] 568 | set_by = line.params[4] 569 | set_at = int(line.params[5]) 570 | 571 | self._mode_list(channel, mode, mask) 572 | return self._emit() 573 | 574 | @line_handler(RPL_ENDOFQUIETLIST) 575 | def _handle_quietlist_end(self, line: Line) -> Emit: 576 | channel = line.params[1] 577 | mode = line.params[2] 578 | self._mode_list_end(channel, mode) 579 | return self._emit() 580 | 581 | @line_handler("PRIVMSG") 582 | @line_handler("NOTICE") 583 | @line_handler("TAGMSG") 584 | def _handle_message(self, line: Line) -> Emit: 585 | emit = self._emit() 586 | # this does not visually spark joy 587 | if not line.source: 588 | return emit 589 | 590 | message = line.params[1] if line.params[1:] else None 591 | if not message is None: 592 | emit.text = message 593 | 594 | nickname_lower = self.casefold(line.hostmask.nickname) 595 | if nickname_lower == self.nickname_lower: 596 | emit.self_source = True 597 | self._self_hostmask(line.hostmask) 598 | 599 | if nickname_lower in self.users: 600 | user = self.users[nickname_lower] 601 | else: 602 | user = self.create_user( 603 | Name(line.hostmask.nickname, nickname_lower) 604 | ) 605 | emit.user = user 606 | 607 | if line.hostmask.username: 608 | user.username = line.hostmask.username 609 | if line.hostmask.hostname: 610 | user.hostname = line.hostmask.hostname 611 | 612 | target_raw = target = line.params[0] 613 | statusmsg = [] 614 | while target: 615 | if target[0] in self.isupport.statusmsg: 616 | statusmsg.append(target[0]) 617 | target = target[1:] 618 | else: 619 | break 620 | emit.target = target_raw 621 | 622 | target_lower = self.casefold(target) 623 | if self.is_channel(target): 624 | if target_lower in self.channels: 625 | channel = self.channels[target_lower] 626 | emit.channel = channel 627 | elif target_lower == self.nickname_lower: 628 | emit.self_target = True 629 | return emit 630 | 631 | @line_handler(RPL_VISIBLEHOST) 632 | # our own hostname, sometimes username@hostname, when it changes 633 | def _handle_visiblehost(self, line: Line) -> Emit: 634 | username, _, hostname = line.params[1].rpartition("@") 635 | self.hostname = hostname 636 | if username: 637 | self.username = username 638 | return self._emit() 639 | 640 | @line_handler(RPL_WHOREPLY) 641 | # WHO line, "WHO #channel|nickname" response 642 | def _handle_who(self, line: Line) -> Emit: 643 | emit = self._emit() 644 | emit.target = line.params[1] 645 | nickname = line.params[5] 646 | username = line.params[2] 647 | hostname = line.params[3] 648 | status = line.params[6] 649 | away = "" if "G" in status else None 650 | realname = line.params[7].split(" ", 1)[1] 651 | 652 | server: Optional[str] = None 653 | if not line.params[4] == "*": 654 | server = line.params[4] 655 | 656 | nickname_lower = self.casefold(line.params[5]) 657 | if nickname_lower == self.nickname_lower: 658 | emit.self = True 659 | self.username = username 660 | self.hostname = hostname 661 | self.realname = realname 662 | self.server = server 663 | self.away = away 664 | 665 | if nickname_lower in self.users: 666 | user = self.users[nickname_lower] 667 | emit.user = user 668 | user.username = username 669 | user.hostname = hostname 670 | user.realname = realname 671 | user.server = server 672 | user.away = away 673 | return emit 674 | 675 | @line_handler(RPL_WHOSPCRPL) 676 | # WHOX line, "WHO #channel|nickname" response; only listen for our "type" 677 | def _handle_whox(self, line: Line) -> Emit: 678 | emit = self._emit() 679 | if line.params[1] == WHO_TYPE and len(line.params) == 10: 680 | nickname_lower = self.casefold(line.params[6]) 681 | username = line.params[2] 682 | hostname = line.params[4] 683 | status = line.params[7] 684 | away = "" if "G" in status else None 685 | realname = line.params[9] 686 | 687 | account = "" 688 | if not line.params[8] == "0": 689 | account = line.params[8] 690 | 691 | server: Optional[str] = None 692 | if not line.params[5] == "*": 693 | server = line.params[5] 694 | ip: Optional[str] = None 695 | if not line.params[3] == "255.255.255.255": 696 | try: 697 | ip = ip_address(line.params[3]).compressed 698 | except ValueError: 699 | pass 700 | 701 | if nickname_lower in self.users: 702 | user = self.users[nickname_lower] 703 | emit.user = user 704 | user.username = username 705 | user.hostname = hostname 706 | user.realname = realname 707 | user.account = account 708 | user.server = server 709 | user.away = away 710 | if ip is not None: 711 | user.ip = ip 712 | 713 | if nickname_lower == self.nickname_lower: 714 | emit.self = True 715 | self.username = username 716 | self.hostname = hostname 717 | self.realname = realname 718 | self.account = account 719 | self.server = server 720 | self.away = away 721 | if ip is not None: 722 | self.ip = ip 723 | 724 | return emit 725 | 726 | @line_handler(RPL_WHOISUSER) 727 | # WHOIS "user" line, one of "WHOIS nickname" response lines 728 | def _handle_whoisuser(self, line: Line) -> Emit: 729 | emit = self._emit() 730 | nickname = line.params[1] 731 | username = line.params[2] 732 | hostname = line.params[3] 733 | realname = line.params[5] 734 | 735 | nickname_lower = self.casefold(nickname) 736 | if nickname_lower == self.nickname_lower: 737 | emit.self = True 738 | self.username = username 739 | self.hostname = hostname 740 | self.realname = realname 741 | 742 | if nickname_lower in self.users: 743 | user = self.users[nickname_lower] 744 | emit.user = user 745 | user.username = username 746 | user.hostname = hostname 747 | user.realname = realname 748 | return emit 749 | 750 | @line_handler("CHGHOST") 751 | def _handle_CHGHOST(self, line: Line) -> Emit: 752 | emit = self._emit() 753 | username = line.params[0] 754 | hostname = line.params[1] 755 | nickname_lower = self.casefold(line.hostmask.nickname) 756 | if nickname_lower == self.nickname_lower: 757 | emit.self = True 758 | self.username = username 759 | self.hostname = hostname 760 | 761 | if nickname_lower in self.users: 762 | user = self.users[nickname_lower] 763 | emit.user = user 764 | user.username = username 765 | user.hostname = hostname 766 | return emit 767 | 768 | @line_handler("SETNAME") 769 | def _handle_SETNAME(self, line: Line) -> Emit: 770 | emit = self._emit() 771 | realname = line.params[0] 772 | nickname_lower = self.casefold(line.hostmask.nickname) 773 | if nickname_lower == self.nickname_lower: 774 | emit.self = True 775 | self.realname = realname 776 | 777 | if nickname_lower in self.users: 778 | user = self.users[nickname_lower] 779 | emit.user = user 780 | user.realname = realname 781 | return emit 782 | 783 | @line_handler("RENAME") 784 | def _handle_RENAME(self, line: Line) -> Emit: 785 | source_fold = self.casefold(line.params[0]) 786 | rename = line.params[1] 787 | rename_fold = self.casefold(rename) 788 | 789 | if source_fold in self.channels: 790 | channel = self.channels.pop(source_fold) 791 | 792 | channel.change_name(rename, rename_fold) 793 | for nickname in channel.users.keys(): 794 | user = self.users[nickname] 795 | user.channels.remove(source_fold) 796 | user.channels.add(rename_fold) 797 | 798 | self.channels[rename_fold] = channel 799 | return self._emit() 800 | 801 | @line_handler(RPL_AWAY) 802 | # sent in response to a command directed at a user who is marked as away 803 | def _handle_RPL_AWAY(self, line: Line) -> Emit: 804 | nickname = line.params[1] 805 | nickname_lower = self.casefold(nickname) 806 | reason = line.params[2] 807 | 808 | if nickname_lower == self.nickname_lower: 809 | self.away = reason 810 | if nickname_lower in self.users: 811 | user = self.users[nickname_lower] 812 | user.away = reason 813 | 814 | return self._emit() 815 | 816 | @line_handler("AWAY") 817 | def _handle_AWAY(self, line: Line) -> Emit: 818 | emit = self._emit() 819 | away = line.params[0] if line.params else None 820 | nickname_lower = self.casefold(line.hostmask.nickname) 821 | if nickname_lower == self.nickname_lower: 822 | emit.self = True 823 | self.away = away 824 | 825 | if nickname_lower in self.users: 826 | user = self.users[nickname_lower] 827 | emit.user = user 828 | user.away = away 829 | return emit 830 | 831 | @line_handler("ACCOUNT") 832 | def _handle_ACCOUNT(self, line: Line) -> Emit: 833 | emit = self._emit() 834 | account = line.params[0].strip("*") 835 | nickname_lower = self.casefold(line.hostmask.nickname) 836 | if nickname_lower == self.nickname_lower: 837 | emit.self = True 838 | self.account = account 839 | 840 | if nickname_lower in self.users: 841 | user = self.users[nickname_lower] 842 | emit.user = user 843 | user.account = account 844 | return emit 845 | 846 | @line_handler("CAP") 847 | def _handle_CAP(self, line: Line) -> Emit: 848 | self.has_cap = True 849 | subcommand = line.params[1].upper() 850 | multiline = line.params[2] == "*" 851 | caps = line.params[2 + (1 if multiline else 0)] 852 | 853 | 854 | tokens: Dict[str, str] = {} 855 | tokens_str: List[str] = [] 856 | for cap in filter(bool, caps.split(" ")): 857 | tokens_str.append(cap) 858 | key, _, value = cap.partition("=") 859 | tokens[key] = value 860 | 861 | emit = self._emit() 862 | emit.subcommand = subcommand 863 | emit.finished = not multiline 864 | emit.tokens = tokens_str 865 | 866 | if subcommand == "LS": 867 | self._temp_caps.update(tokens) 868 | if not multiline: 869 | self.available_caps = self._temp_caps.copy() 870 | self._temp_caps.clear() 871 | elif subcommand == "NEW": 872 | self.available_caps.update(tokens) 873 | elif subcommand == "DEL": 874 | for key in tokens.keys(): 875 | if key in self.available_caps.keys(): 876 | del self.available_caps[key] 877 | if key in self.agreed_caps: 878 | self.agreed_caps.remove(key) 879 | elif subcommand == "ACK": 880 | for key in tokens.keys(): 881 | if key.startswith("-"): 882 | key = key[1:] 883 | if key in self.agreed_caps: 884 | self.agreed_caps.remove(key) 885 | elif (not key in self.agreed_caps and 886 | key in self.available_caps): 887 | self.agreed_caps.append(key) 888 | return emit 889 | 890 | @line_handler(RPL_LOGGEDIN) 891 | def _handle_loggedin(self, line: Line) -> Emit: 892 | hostmask_str = line.params[1] 893 | hostmask = hostmask_(hostmask_str) 894 | account = line.params[2] 895 | 896 | self.account = account 897 | self._self_hostmask(hostmask) 898 | return self._emit() 899 | 900 | @line_handler(RPL_LOGGEDOUT) 901 | def _handle_loggedout(self, line: Line) -> Emit: 902 | hostmask_str = line.params[1] 903 | hostmask = hostmask_(hostmask_str) 904 | 905 | self.account = None 906 | self._self_hostmask(hostmask) 907 | return self._emit() 908 | -------------------------------------------------------------------------------- /ircstates/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Set 2 | from .names import Name 3 | 4 | class User(object): 5 | def __init__(self, nickname: Name): 6 | self._nickname = nickname 7 | 8 | self.username: Optional[str] = None 9 | self.hostname: Optional[str] = None 10 | self.realname: Optional[str] = None 11 | self.account: Optional[str] = None 12 | self.server: Optional[str] = None 13 | self.away: Optional[str] = None 14 | self.ip: Optional[str] = None 15 | self.channels: Set[str] = set([]) 16 | 17 | def __repr__(self) -> str: 18 | return f"User(nickname={self.nickname!r})" 19 | 20 | def get_name(self) -> Name: 21 | return self._nickname 22 | @property 23 | def nickname(self) -> str: 24 | return self._nickname.normal 25 | @property 26 | def nickname_lower(self) -> str: 27 | return self._nickname.folded 28 | 29 | def change_nickname(self, 30 | normal: str, 31 | folded: str): 32 | self._nickname.normal = normal 33 | self._nickname.folded = folded 34 | 35 | def hostmask(self) -> str: 36 | hostmask = self.nickname 37 | if self.username is not None: 38 | hostmask += f"!{self.username}" 39 | if self.hostname is not None: 40 | hostmask += f"@{self.hostname}" 41 | return hostmask 42 | 43 | def userhost(self) -> Optional[str]: 44 | if (self.username is not None and 45 | self.hostname is not None): 46 | return f"{self.username}@{self.hostname}" 47 | else: 48 | return None 49 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | freezegun ~=1.1.0 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | irctokens ~=2.0.2 2 | pendulum ~=3.0.0 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | with open("VERSION", "r") as version_file: 6 | version = version_file.read().strip() 7 | with open("requirements.txt", "r") as requirements_file: 8 | install_requires = requirements_file.read().splitlines() 9 | 10 | setuptools.setup( 11 | name="ircstates", 12 | version=version, 13 | author="jesopo", 14 | author_email="pip@jesopo.uk", 15 | description="IRC client session state parsing library", 16 | long_description=long_description, 17 | long_description_content_type="text/markdown", 18 | url="https://github.com/jesopo/ircstates", 19 | packages=setuptools.find_packages(), 20 | package_data={"ircstates": ["py.typed"]}, 21 | classifiers=[ 22 | "Programming Language :: Python :: 3", 23 | "License :: OSI Approved :: MIT License", 24 | "Operating System :: OS Independent", 25 | "Operating System :: POSIX", 26 | "Operating System :: Microsoft :: Windows", 27 | "Topic :: Communications :: Chat :: Internet Relay Chat" 28 | ], 29 | python_requires='>=3.8', 30 | install_requires=install_requires 31 | ) 32 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | from .channel import * 2 | from .user import * 3 | from .mode import * 4 | from .motd import * 5 | from .cap import * 6 | from .isupport import * 7 | from .casemap import * 8 | from .emit import * 9 | from .who import * 10 | from .sasl import * 11 | -------------------------------------------------------------------------------- /test/cap.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import ircstates, irctokens 3 | 4 | class CapTestLS(unittest.TestCase): 5 | def test_one_line(self): 6 | server = ircstates.Server("test") 7 | self.assertFalse(server.has_cap) 8 | self.assertEqual(server.available_caps, {}) 9 | server.parse_tokens(irctokens.tokenise("CAP * LS :a b")) 10 | self.assertEqual(server.available_caps, {"a": "", "b": ""}) 11 | 12 | def test_two_lines(self): 13 | server = ircstates.Server("test") 14 | server.parse_tokens(irctokens.tokenise("CAP * LS * :a b")) 15 | self.assertEqual(server.available_caps, {}) 16 | server.parse_tokens(irctokens.tokenise("CAP * LS :c")) 17 | self.assertEqual(server.available_caps, {"a": "", "b": "", "c": ""}) 18 | 19 | def test_values(self): 20 | server = ircstates.Server("test") 21 | server.parse_tokens(irctokens.tokenise("CAP * LS :a b= c=1")) 22 | self.assertEqual(server.available_caps, {"a": "", "b": "", "c": "1"}) 23 | 24 | class CapTestACK(unittest.TestCase): 25 | def test_one_line(self): 26 | server = ircstates.Server("test") 27 | server.parse_tokens(irctokens.tokenise("CAP * LS :a b")) 28 | server.parse_tokens(irctokens.tokenise("CAP * ACK :a b")) 29 | self.assertEqual(server.agreed_caps, ["a", "b"]) 30 | 31 | def test_two_lines(self): 32 | server = ircstates.Server("test") 33 | server.parse_tokens(irctokens.tokenise("CAP * LS :a b c")) 34 | server.parse_tokens(irctokens.tokenise("CAP * ACK * :a b")) 35 | server.parse_tokens(irctokens.tokenise("CAP * ACK :c")) 36 | self.assertEqual(server.agreed_caps, ["a", "b", "c"]) 37 | 38 | def test_not_ls(self): 39 | server = ircstates.Server("test") 40 | server.parse_tokens(irctokens.tokenise("CAP * LS a")) 41 | server.parse_tokens(irctokens.tokenise("CAP * ACK b")) 42 | self.assertEqual(server.agreed_caps, []) 43 | 44 | class CapTestNEW(unittest.TestCase): 45 | def test_no_ls(self): 46 | server = ircstates.Server("test") 47 | server.parse_tokens(irctokens.tokenise("CAP * NEW :a")) 48 | self.assertEqual(server.available_caps, {"a": ""}) 49 | 50 | def test_one(self): 51 | server = ircstates.Server("test") 52 | server.parse_tokens(irctokens.tokenise("CAP * LS :a")) 53 | server.parse_tokens(irctokens.tokenise("CAP * NEW :b")) 54 | self.assertEqual(server.available_caps, {"a": "", "b": ""}) 55 | 56 | def test_two(self): 57 | server = ircstates.Server("test") 58 | server.parse_tokens(irctokens.tokenise("CAP * LS :a")) 59 | server.parse_tokens(irctokens.tokenise("CAP * NEW :b c")) 60 | self.assertEqual(server.available_caps, {"a": "", "b": "", "c": ""}) 61 | 62 | class CapTestDEL(unittest.TestCase): 63 | def test_not_acked(self): 64 | server = ircstates.Server("test") 65 | server.parse_tokens(irctokens.tokenise("CAP * DEL a")) 66 | 67 | def test_one_ls(self): 68 | server = ircstates.Server("test") 69 | server.parse_tokens(irctokens.tokenise("CAP * LS :a")) 70 | server.parse_tokens(irctokens.tokenise("CAP * ACK :a")) 71 | server.parse_tokens(irctokens.tokenise("CAP * DEL :a")) 72 | self.assertEqual(server.available_caps, {}) 73 | self.assertEqual(server.agreed_caps, []) 74 | 75 | def test_two_ls(self): 76 | server = ircstates.Server("test") 77 | server.parse_tokens(irctokens.tokenise("CAP * LS :a b")) 78 | server.parse_tokens(irctokens.tokenise("CAP * ACK :a b")) 79 | server.parse_tokens(irctokens.tokenise("CAP * DEL :a")) 80 | self.assertEqual(server.available_caps, {"b": ""}) 81 | self.assertEqual(server.agreed_caps, ["b"]) 82 | 83 | def test_two_del(self): 84 | server = ircstates.Server("test") 85 | server.parse_tokens(irctokens.tokenise("CAP * LS :a b")) 86 | server.parse_tokens(irctokens.tokenise("CAP * ACK :a b")) 87 | server.parse_tokens(irctokens.tokenise("CAP * DEL :a b")) 88 | self.assertEqual(server.available_caps, {}) 89 | self.assertEqual(server.agreed_caps, []) 90 | 91 | -------------------------------------------------------------------------------- /test/casemap.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import ircstates, irctokens 3 | 4 | class CaseMapTestMethod(unittest.TestCase): 5 | def test_rfc1459(self): 6 | lower = ircstates.casefold(ircstates.CaseMap.RFC1459, "ÀTEST[]^\\") 7 | self.assertEqual(lower, "Àtest{}~|") 8 | 9 | def test_ascii(self): 10 | lower = ircstates.casefold(ircstates.CaseMap.ASCII, "ÀTEST[]~\\") 11 | self.assertEqual(lower, "Àtest[]~\\") 12 | 13 | class CaseMapTestCommands(unittest.TestCase): 14 | def test_join(self): 15 | server = ircstates.Server("test") 16 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 17 | server.parse_tokens(irctokens.tokenise(":Nickname JOIN #Chan")) 18 | server.parse_tokens(irctokens.tokenise(":Other JOIN #Chan")) 19 | self.assertIn("nickname", server.users) 20 | self.assertNotIn("Nickname", server.users) 21 | self.assertIn("other", server.users) 22 | self.assertNotIn("Other", server.users) 23 | self.assertIn("#chan", server.channels) 24 | self.assertNotIn("#Chan", server.channels) 25 | 26 | channel = server.channels["#chan"] 27 | self.assertEqual(channel.name, "#Chan") 28 | 29 | def test_nick(self): 30 | server = ircstates.Server("test") 31 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 32 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 33 | user = server.users["nickname"] 34 | server.parse_tokens(irctokens.tokenise(":nickname NICK NewNickname")) 35 | self.assertEqual(len(server.users), 1) 36 | self.assertIn("newnickname", server.users) 37 | self.assertEqual(user.nickname, "NewNickname") 38 | self.assertEqual(user.nickname_lower, "newnickname") 39 | self.assertEqual(server.nickname, "NewNickname") 40 | self.assertEqual(server.nickname_lower, "newnickname") 41 | -------------------------------------------------------------------------------- /test/channel.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import pendulum 3 | import ircstates, irctokens 4 | from freezegun import freeze_time 5 | 6 | class ChannelTestJoin(unittest.TestCase): 7 | def test_self_join(self): 8 | dt = pendulum.datetime(2021, 9, 6, 2, 55, 22) 9 | server = ircstates.Server("test") 10 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 11 | with freeze_time("2021-09-06 02:55:22"): 12 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 13 | 14 | self.assertIn("#chan", server.channels) 15 | self.assertIn("nickname", server.users) 16 | self.assertEqual(len(server.users), 1) 17 | self.assertEqual(len(server.channels), 1) 18 | 19 | user = server.users["nickname"] 20 | channel = server.channels["#chan"] 21 | self.assertIn(user.nickname_lower, channel.users) 22 | 23 | channel_user = channel.users[user.nickname_lower] 24 | self.assertEqual(user.channels, set([channel.name_lower])) 25 | self.assertEqual(channel_user.since, dt) 26 | self.assertEqual(channel_user.joined, dt) 27 | 28 | def test_other_join(self): 29 | server = ircstates.Server("test") 30 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 31 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 32 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 33 | self.assertEqual(len(server.users), 2) 34 | self.assertIn("other", server.users) 35 | channel = server.channels["#chan"] 36 | self.assertEqual(len(channel.users), 2) 37 | 38 | user = server.users["other"] 39 | self.assertEqual(user.channels, set([channel.name_lower])) 40 | 41 | class ChannelTestPart(unittest.TestCase): 42 | def test_self_part(self): 43 | server = ircstates.Server("test") 44 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 45 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 46 | server.parse_tokens(irctokens.tokenise(":nickname PART #chan")) 47 | self.assertEqual(len(server.users), 0) 48 | self.assertEqual(len(server.channels), 0) 49 | 50 | def test_other_part(self): 51 | server = ircstates.Server("test") 52 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 53 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 54 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 55 | server.parse_tokens(irctokens.tokenise(":other PART #chan")) 56 | 57 | user = server.users["nickname"] 58 | channel = server.channels["#chan"] 59 | channel_user = channel.users[user.nickname_lower] 60 | 61 | self.assertEqual(server.users, {"nickname": user}) 62 | self.assertEqual(server.channels, {"#chan": channel}) 63 | self.assertEqual(user.channels, set([channel.name_lower])) 64 | self.assertEqual(channel.users, {"nickname": channel_user}) 65 | 66 | class ChannelTestKick(unittest.TestCase): 67 | def test_self_kick(self): 68 | server = ircstates.Server("test") 69 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 70 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 71 | server.parse_tokens( 72 | irctokens.tokenise(":nickname KICK #chan nickname")) 73 | self.assertEqual(len(server.users), 0) 74 | self.assertEqual(len(server.channels), 0) 75 | 76 | def test_other_kick(self): 77 | server = ircstates.Server("test") 78 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 79 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 80 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 81 | server.parse_tokens(irctokens.tokenise(":nickname KICK #chan other")) 82 | 83 | user = server.users["nickname"] 84 | channel = server.channels["#chan"] 85 | channel_user = channel.users[user.nickname_lower] 86 | 87 | self.assertEqual(len(server.users), 1) 88 | self.assertEqual(len(server.channels), 1) 89 | self.assertEqual(user.channels, set([channel.name_lower])) 90 | self.assertEqual(channel.users, {user.nickname_lower: channel_user}) 91 | 92 | class ChannelTestTopic(unittest.TestCase): 93 | def test_text(self): 94 | dt = pendulum.datetime(2020, 3, 12, 14, 27, 57) 95 | 96 | server = ircstates.Server("test") 97 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 98 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 99 | server.parse_tokens(irctokens.tokenise("332 * #chan :test")) 100 | server.parse_tokens(irctokens.tokenise("333 * #chan other 1584023277")) 101 | 102 | channel = server.channels["#chan"] 103 | self.assertEqual(channel.topic, "test") 104 | self.assertEqual(channel.topic_setter, "other") 105 | self.assertEqual(channel.topic_time, dt) 106 | 107 | def test_topic_command(self): 108 | server = ircstates.Server("test") 109 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 110 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 111 | dt = pendulum.datetime(2021, 9, 6, 2, 43, 22) 112 | with freeze_time("2021-09-06 02:43:22"): 113 | server.parse_tokens(irctokens.tokenise(":other TOPIC #chan :hello there")) 114 | 115 | channel = server.channels["#chan"] 116 | self.assertEqual(channel.topic, "hello there") 117 | self.assertEqual(channel.topic_setter, "other") 118 | self.assertEqual(channel.topic_time, dt) 119 | 120 | class ChannelTestCreation(unittest.TestCase): 121 | def test(self): 122 | dt = pendulum.datetime(2020, 3, 12, 19, 38, 9) 123 | server = ircstates.Server("test") 124 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 125 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 126 | server.parse_tokens(irctokens.tokenise("329 * #chan 1584041889")) 127 | self.assertEqual(server.channels["#chan"].created, dt) 128 | 129 | class ChannelTestNAMES(unittest.TestCase): 130 | def test(self): 131 | dt_1 = pendulum.datetime(2021, 9, 6, 2, 57, 22) 132 | dt_2 = pendulum.datetime(2021, 9, 6, 2, 58, 22) 133 | 134 | server = ircstates.Server("test") 135 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 136 | with freeze_time("2021-09-06 02:57:22"): 137 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 138 | with freeze_time("2021-09-06 02:58:22"): 139 | server.parse_tokens(irctokens.tokenise("353 * * #chan :nickname @+other")) 140 | 141 | self.assertIn("nickname", server.users) 142 | self.assertIn("other", server.users) 143 | 144 | user = server.users["other"] 145 | channel = server.channels["#chan"] 146 | channel_user_1 = channel.users[server.nickname_lower] 147 | channel_user_2 = channel.users[user.nickname_lower] 148 | 149 | self.assertEqual(channel.users, { 150 | server.nickname_lower: channel_user_1, 151 | user.nickname_lower: channel_user_2 152 | }) 153 | self.assertEqual(user.channels, set([channel.name_lower])) 154 | self.assertEqual(channel_user_2.modes, {"o", "v"}) 155 | 156 | self.assertEqual(channel_user_1.since, dt_1) 157 | self.assertEqual(channel_user_2.since, dt_2) 158 | 159 | def test_userhost_in_names(self): 160 | server = ircstates.Server("test") 161 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 162 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 163 | server.parse_tokens(irctokens.tokenise( 164 | "353 * * #chan :nickname!user@host other!user2@host2")) 165 | self.assertEqual(server.username, "user") 166 | self.assertEqual(server.hostname, "host") 167 | user = server.users["other"] 168 | self.assertEqual(user.username, "user2") 169 | self.assertEqual(user.hostname, "host2") 170 | 171 | class ChannelNICKAfterJoin(unittest.TestCase): 172 | def test(self): 173 | server = ircstates.Server("test") 174 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 175 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 176 | 177 | user = server.users["nickname"] 178 | channel = server.channels["#chan"] 179 | channel_user = channel.users[user.nickname_lower] 180 | server.parse_tokens(irctokens.tokenise(":nickname NICK Nickname2")) 181 | 182 | self.assertEqual(channel.users, {user.nickname_lower: channel_user}) 183 | self.assertEqual(channel_user.nickname, "Nickname2") 184 | self.assertEqual(channel_user.nickname_lower, "nickname2") 185 | 186 | class ChannelRENAME(unittest.TestCase): 187 | def test(self): 188 | server = ircstates.Server("test") 189 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 190 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 191 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 192 | 193 | user = server.users["nickname"] 194 | channel = server.channels["#chan"] 195 | 196 | server.parse_tokens(irctokens.tokenise(":nickname RENAME #chan #chan2 *")) 197 | 198 | self.assertEqual(channel.name, "#chan2") 199 | self.assertEqual(set(channel.users.keys()), {"nickname", "other"}) 200 | self.assertEqual(user.channels, {"#chan2"}) 201 | self.assertNotIn("#chan", server.channels) 202 | self.assertIn("#chan2", server.channels) 203 | self.assertEqual(len(server.channels), 1) 204 | -------------------------------------------------------------------------------- /test/emit.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import ircstates, irctokens 3 | 4 | class EmitTest(unittest.TestCase): 5 | def test_join(self): 6 | server = ircstates.Server("test") 7 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 8 | emit = server.parse_tokens( 9 | irctokens.tokenise(":nickname JOIN #chan")) 10 | 11 | self.assertEqual(emit.command, "JOIN") 12 | self.assertEqual(emit.self, True) 13 | self.assertEqual(emit.user, server.users["nickname"]) 14 | self.assertEqual(emit.channel, server.channels["#chan"]) 15 | 16 | emit = server.parse_tokens( 17 | irctokens.tokenise(":other JOIN #chan")) 18 | self.assertIsNotNone(emit) 19 | self.assertEqual(emit.command, "JOIN") 20 | self.assertEqual(emit.self, None) 21 | self.assertEqual(emit.user, server.users["other"]) 22 | self.assertEqual(emit.channel, server.channels["#chan"]) 23 | 24 | def test_privmsg(self): 25 | server = ircstates.Server("test") 26 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 27 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 28 | emit = server.parse_tokens( 29 | irctokens.tokenise(":nickname PRIVMSG #chan :hello")) 30 | self.assertIsNotNone(emit) 31 | self.assertEqual(emit.command, "PRIVMSG") 32 | self.assertEqual(emit.text, "hello") 33 | self.assertEqual(emit.self_source, True) 34 | self.assertEqual(emit.user, server.users["nickname"]) 35 | self.assertEqual(emit.channel, server.channels["#chan"]) 36 | 37 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 38 | emit = server.parse_tokens( 39 | irctokens.tokenise(":other PRIVMSG #chan :hello2")) 40 | self.assertIsNotNone(emit) 41 | self.assertEqual(emit.command, "PRIVMSG") 42 | self.assertEqual(emit.text, "hello2") 43 | self.assertEqual(emit.self_source, None) 44 | self.assertEqual(emit.user, server.users["other"]) 45 | self.assertEqual(emit.channel, server.channels["#chan"]) 46 | 47 | def test_privmsg_nojoin(self): 48 | server = ircstates.Server("test") 49 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 50 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 51 | 52 | emit = server.parse_tokens( 53 | irctokens.tokenise(":other PRIVMSG #chan :hello")) 54 | 55 | self.assertIsNotNone(emit) 56 | self.assertEqual(emit.command, "PRIVMSG") 57 | self.assertEqual(emit.text, "hello") 58 | self.assertEqual(emit.self_source, None) 59 | self.assertIsNotNone(emit.user) 60 | channel = server.channels["#chan"] 61 | self.assertEqual(emit.channel, channel) 62 | 63 | def test_kick(self): 64 | server = ircstates.Server("test") 65 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 66 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 67 | user = server.users["nickname"] 68 | channel = server.channels["#chan"] 69 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 70 | user_other = server.users["other"] 71 | emit = server.parse_tokens( 72 | irctokens.tokenise(":nickname KICK #chan other :reason")) 73 | 74 | self.assertIsNotNone(emit) 75 | self.assertEqual(emit.command, "KICK") 76 | self.assertEqual(emit.text, "reason") 77 | self.assertEqual(emit.self_source, True) 78 | self.assertEqual(emit.user_source, user) 79 | self.assertEqual(emit.user_target, user_other) 80 | self.assertEqual(emit.channel, channel) 81 | 82 | def test_mode_self(self): 83 | server = ircstates.Server("test") 84 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 85 | emit = server.parse_tokens( 86 | irctokens.tokenise("MODE nickname x+i-i+wi-wi")) 87 | 88 | self.assertIsNotNone(emit) 89 | self.assertEqual(emit.command, "MODE") 90 | self.assertTrue(emit.self_target) 91 | self.assertEqual(emit.tokens, 92 | ["+x", "+i", "-i", "+w", "+i", "-w", "-i"]) 93 | 94 | def test_mode_channel(self): 95 | server = ircstates.Server("test") 96 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 97 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 98 | channel = server.channels["#chan"] 99 | emit = server.parse_tokens( 100 | irctokens.tokenise(":server MODE #chan +im-m+b-k asd!*@* key")) 101 | 102 | self.assertIsNotNone(emit) 103 | self.assertEqual(emit.command, "MODE") 104 | self.assertEqual(emit.channel, channel) 105 | self.assertEqual(emit.tokens, 106 | ["+i", "+m", "-m", "+b asd!*@*", "-k key"]) 107 | -------------------------------------------------------------------------------- /test/isupport.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import ircstates, irctokens 3 | 4 | class ISUPPORTTest(unittest.TestCase): 5 | def test_escape(self): 6 | server = ircstates.Server("test") 7 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 8 | server.parse_tokens(irctokens.tokenise(r"005 * TEST=a\x20b\\x20\x2 *")) 9 | self.assertEqual(server.isupport.raw["TEST"], r"a b\x20x2") 10 | 11 | def test_chanmodes(self): 12 | server = ircstates.Server("test") 13 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 14 | self.assertEqual(server.isupport.chanmodes.a_modes, ["b"]) 15 | self.assertEqual(server.isupport.chanmodes.b_modes, ["k"]) 16 | self.assertEqual(server.isupport.chanmodes.c_modes, ["l"]) 17 | self.assertEqual(server.isupport.chanmodes.d_modes, 18 | ["i", "m", "n", "p", "s", "t"]) 19 | server.parse_tokens(irctokens.tokenise("005 * CHANMODES=a,b,c,d *")) 20 | self.assertEqual(server.isupport.chanmodes.a_modes, ["a"]) 21 | self.assertEqual(server.isupport.chanmodes.b_modes, ["b"]) 22 | self.assertEqual(server.isupport.chanmodes.c_modes, ["c"]) 23 | self.assertEqual(server.isupport.chanmodes.d_modes, ["d"]) 24 | 25 | def test_prefix(self): 26 | server = ircstates.Server("test") 27 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 28 | self.assertEqual(server.isupport.prefix.modes, ["o", "v"]) 29 | self.assertEqual(server.isupport.prefix.prefixes, ["@", "+"]) 30 | 31 | self.assertEqual(server.isupport.prefix.from_mode("o"), "@") 32 | self.assertIsNone(server.isupport.prefix.from_mode("a")) 33 | self.assertEqual(server.isupport.prefix.from_prefix("@"), "o") 34 | self.assertIsNone(server.isupport.prefix.from_prefix("&")) 35 | 36 | server.parse_tokens(irctokens.tokenise("005 * PREFIX=(qaohv)~&@%+ *")) 37 | self.assertEqual(server.isupport.prefix.modes, 38 | ["q", "a", "o", "h", "v"]) 39 | self.assertEqual(server.isupport.prefix.prefixes, 40 | ["~", "&", "@", "%", "+"]) 41 | self.assertEqual(server.isupport.prefix.from_mode("a"), "&") 42 | self.assertEqual(server.isupport.prefix.from_prefix("&"), "a") 43 | 44 | 45 | def test_chantypes(self): 46 | server = ircstates.Server("test") 47 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 48 | self.assertEqual(server.isupport.chantypes, ["#"]) 49 | server.parse_tokens(irctokens.tokenise("005 * CHANTYPES=#& *")) 50 | self.assertEqual(server.isupport.chantypes, ["#", "&"]) 51 | 52 | def test_modes(self): 53 | server = ircstates.Server("test") 54 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 55 | self.assertEqual(server.isupport.modes, 3) 56 | server.parse_tokens(irctokens.tokenise("005 * MODES *")) 57 | self.assertEqual(server.isupport.modes, -1) 58 | server.parse_tokens(irctokens.tokenise("005 * MODES=5 *")) 59 | self.assertEqual(server.isupport.modes, 5) 60 | 61 | def test_network(self): 62 | server = ircstates.Server("test") 63 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 64 | self.assertIsNone(server.isupport.network) 65 | server.parse_tokens(irctokens.tokenise("005 * NETWORK=testnet *")) 66 | self.assertEqual(server.isupport.network, "testnet") 67 | 68 | def test_statusmsg(self): 69 | server = ircstates.Server("test") 70 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 71 | self.assertEqual(server.isupport.statusmsg, []) 72 | server.parse_tokens(irctokens.tokenise("005 * STATUSMSG=&@ *")) 73 | self.assertEqual(server.isupport.statusmsg, ["&", "@"]) 74 | 75 | def test_callerid(self): 76 | server = ircstates.Server("test") 77 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 78 | self.assertIsNone(server.isupport.callerid) 79 | server.parse_tokens(irctokens.tokenise("005 * CALLERID=U *")) 80 | self.assertEqual(server.isupport.callerid, "U") 81 | server.parse_tokens(irctokens.tokenise("005 * CALLERID *")) 82 | self.assertEqual(server.isupport.callerid, "g") 83 | 84 | def test_excepts(self): 85 | server = ircstates.Server("test") 86 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 87 | self.assertIsNone(server.isupport.excepts) 88 | server.parse_tokens(irctokens.tokenise("005 * EXCEPTS=U *")) 89 | self.assertEqual(server.isupport.excepts, "U") 90 | server.parse_tokens(irctokens.tokenise("005 * EXCEPTS *")) 91 | self.assertEqual(server.isupport.excepts, "e") 92 | 93 | def test_invex(self): 94 | server = ircstates.Server("test") 95 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 96 | self.assertIsNone(server.isupport.invex) 97 | server.parse_tokens(irctokens.tokenise("005 * INVEX=U *")) 98 | self.assertEqual(server.isupport.invex, "U") 99 | server.parse_tokens(irctokens.tokenise("005 * INVEX *")) 100 | self.assertEqual(server.isupport.invex, "I") 101 | 102 | def test_whox(self): 103 | server = ircstates.Server("test") 104 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 105 | self.assertFalse(server.isupport.whox) 106 | server.parse_tokens(irctokens.tokenise("005 * WHOX *")) 107 | self.assertTrue(server.isupport.whox) 108 | 109 | def test_monitor(self): 110 | server = ircstates.Server("test") 111 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 112 | self.assertIsNone(server.isupport.monitor) 113 | server.parse_tokens(irctokens.tokenise("005 * MONITOR=123 *")) 114 | self.assertEqual(server.isupport.monitor, 123) 115 | server.parse_tokens(irctokens.tokenise("005 * MONITOR *")) 116 | self.assertEqual(server.isupport.monitor, -1) 117 | 118 | def test_watch(self): 119 | server = ircstates.Server("test") 120 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 121 | self.assertIsNone(server.isupport.watch) 122 | server.parse_tokens(irctokens.tokenise("005 * WATCH=123 *")) 123 | self.assertEqual(server.isupport.watch, 123) 124 | server.parse_tokens(irctokens.tokenise("005 * WATCH *")) 125 | self.assertEqual(server.isupport.watch, -1) 126 | 127 | def test_nicklen(self): 128 | server = ircstates.Server("test") 129 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 130 | self.assertEqual(server.isupport.nicklen, 9) 131 | server.parse_tokens(irctokens.tokenise("005 * NICKLEN=16 *")) 132 | self.assertEqual(server.isupport.nicklen, 16) 133 | 134 | class ISupportTestCasemapping(unittest.TestCase): 135 | def test_rfc1459(self): 136 | server = ircstates.Server("test") 137 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 138 | self.assertEqual(server.isupport.casemapping, ircstates.CaseMap.RFC1459) 139 | server.parse_tokens(irctokens.tokenise("005 * CASEMAPPING=rfc1459 *")) 140 | self.assertEqual(server.isupport.casemapping, ircstates.CaseMap.RFC1459) 141 | 142 | def test_ascii(self): 143 | server = ircstates.Server("test") 144 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 145 | server.parse_tokens(irctokens.tokenise("005 * CASEMAPPING=ascii *")) 146 | self.assertEqual(server.isupport.casemapping, ircstates.CaseMap.ASCII) 147 | 148 | def test_unknown(self): 149 | server = ircstates.Server("test") 150 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 151 | with self.assertRaises(ValueError): 152 | server.parse_tokens(irctokens.tokenise("005 * CASEMAPPING=asd *")) 153 | -------------------------------------------------------------------------------- /test/mode.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import ircstates, irctokens 3 | 4 | 5 | class ModeTestUMode(unittest.TestCase): 6 | def test_add(self): 7 | server = ircstates.Server("test") 8 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 9 | server.parse_tokens(irctokens.tokenise("MODE nickname +i")) 10 | self.assertEqual(server.modes, {"i"}) 11 | 12 | def test_remove(self): 13 | server = ircstates.Server("test") 14 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 15 | server.parse_tokens(irctokens.tokenise("MODE nickname +i")) 16 | server.parse_tokens(irctokens.tokenise("MODE nickname -i")) 17 | self.assertEqual(server.modes, set()) 18 | 19 | class ModeTestChannelPrefix(unittest.TestCase): 20 | def test_add(self): 21 | server = ircstates.Server("test") 22 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 23 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 24 | server.parse_tokens( 25 | irctokens.tokenise("MODE #chan +ov nickname nickname")) 26 | user = server.users["nickname"] 27 | channel = server.channels["#chan"] 28 | channel_user = channel.users[user.nickname_lower] 29 | self.assertEqual(channel_user.modes, {"o", "v"}) 30 | 31 | def test_remove(self): 32 | server = ircstates.Server("test") 33 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 34 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 35 | server.parse_tokens( 36 | irctokens.tokenise("MODE #chan +ov nickname nickname")) 37 | server.parse_tokens( 38 | irctokens.tokenise("MODE #chan -ov nickname nickname")) 39 | user = server.users["nickname"] 40 | channel = server.channels["#chan"] 41 | channel_user = channel.users[user.nickname_lower] 42 | self.assertEqual(channel_user.modes, set()) 43 | 44 | class ModeTestChannelList(unittest.TestCase): 45 | def test_add(self): 46 | server = ircstates.Server("test") 47 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 48 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 49 | 50 | channel = server.channels["#chan"] 51 | self.assertEqual(channel.list_modes, {"b": []}) 52 | 53 | server.parse_tokens(irctokens.tokenise("MODE #chan +b asd!*@*")) 54 | self.assertEqual(channel.list_modes, {"b": ["asd!*@*"]}) 55 | 56 | server.parse_tokens(irctokens.tokenise("MODE #chan -b asd!*@*")) 57 | self.assertEqual(channel.list_modes, {"b": []}) 58 | 59 | def test_remove(self): 60 | server = ircstates.Server("test") 61 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 62 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 63 | server.parse_tokens(irctokens.tokenise("MODE #chan +b asd!*@*")) 64 | server.parse_tokens(irctokens.tokenise("MODE #chan +b dsa!*@*")) 65 | server.parse_tokens(irctokens.tokenise("MODE #chan -b asd!*@*")) 66 | channel = server.channels["#chan"] 67 | self.assertEqual(channel.list_modes, {"b": ["dsa!*@*"]}) 68 | 69 | def test_banlist(self): 70 | server = ircstates.Server("test") 71 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 72 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 73 | server.parse_tokens( 74 | irctokens.tokenise("367 * #chan *!*@host setby 1594477713")) 75 | server.parse_tokens( 76 | irctokens.tokenise("367 * #chan $a:account setby 1594477713")) 77 | server.parse_tokens( 78 | irctokens.tokenise("367 * #chan r:my*gecos")) 79 | server.parse_tokens(irctokens.tokenise("368 * #chan *")) 80 | 81 | channel = server.channels["#chan"] 82 | self.assertEqual( 83 | channel.list_modes["b"], 84 | ["*!*@host", "$a:account", "r:my*gecos"] 85 | ) 86 | 87 | def test_quietlist(self): 88 | server = ircstates.Server("test") 89 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 90 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 91 | server.parse_tokens( 92 | irctokens.tokenise("728 * #chan q q!*@host setby 1594477713")) 93 | server.parse_tokens( 94 | irctokens.tokenise("728 * #chan q $a:qaccount setby 1594477713")) 95 | server.parse_tokens( 96 | irctokens.tokenise("728 * #chan q r:q*my*gecos setby 1594477713")) 97 | server.parse_tokens(irctokens.tokenise("729 * #chan q *")) 98 | 99 | channel = server.channels["#chan"] 100 | self.assertEqual( 101 | channel.list_modes["q"], 102 | ["q!*@host", "$a:qaccount", "r:q*my*gecos"] 103 | ) 104 | 105 | 106 | class ModeTestChannelTypeB(unittest.TestCase): 107 | def test_add(self): 108 | server = ircstates.Server("test") 109 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 110 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 111 | server.parse_tokens(irctokens.tokenise("MODE #chan +k password")) 112 | channel = server.channels["#chan"] 113 | self.assertEqual(channel.modes, {"k": "password"}) 114 | 115 | def test_remove(self): 116 | server = ircstates.Server("test") 117 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 118 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 119 | server.parse_tokens(irctokens.tokenise("MODE #chan +k password")) 120 | server.parse_tokens(irctokens.tokenise("MODE #chan -k *")) 121 | channel = server.channels["#chan"] 122 | self.assertEqual(channel.modes, {}) 123 | 124 | class ModeTestChannelTypeC(unittest.TestCase): 125 | def test_add(self): 126 | server = ircstates.Server("test") 127 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 128 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 129 | server.parse_tokens(irctokens.tokenise("MODE #chan +l 100")) 130 | channel = server.channels["#chan"] 131 | self.assertEqual(channel.modes, {"l": "100"}) 132 | 133 | def test_remove(self): 134 | server = ircstates.Server("test") 135 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 136 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 137 | server.parse_tokens(irctokens.tokenise("MODE #chan +l 100")) 138 | server.parse_tokens(irctokens.tokenise("MODE #chan -l")) 139 | channel = server.channels["#chan"] 140 | self.assertEqual(channel.modes, {}) 141 | 142 | class ModeTestChannelTypeD(unittest.TestCase): 143 | def test_add(self): 144 | server = ircstates.Server("test") 145 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 146 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 147 | server.parse_tokens(irctokens.tokenise("MODE #chan +i")) 148 | channel = server.channels["#chan"] 149 | self.assertEqual(channel.modes, {"i": None}) 150 | 151 | def test_remove(self): 152 | server = ircstates.Server("test") 153 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 154 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 155 | server.parse_tokens(irctokens.tokenise("MODE #chan +i")) 156 | server.parse_tokens(irctokens.tokenise("MODE #chan -i")) 157 | channel = server.channels["#chan"] 158 | self.assertEqual(channel.modes, {}) 159 | 160 | class ModeTestChannelNumeric(unittest.TestCase): 161 | def test(self): 162 | server = ircstates.Server("test") 163 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 164 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 165 | server.parse_tokens( 166 | irctokens.tokenise("324 * #chan +bkli *!*@* pass 10")) 167 | channel = server.channels["#chan"] 168 | self.assertEqual(channel.modes, {"k": "pass", "l": "10", "i": None}) 169 | self.assertEqual(channel.list_modes, {"b": ["*!*@*"]}) 170 | 171 | def test_without_plus(self): 172 | server = ircstates.Server("test") 173 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 174 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 175 | server.parse_tokens(irctokens.tokenise("324 * #chan il 10")) 176 | channel = server.channels["#chan"] 177 | self.assertEqual(channel.modes, {"i": None, "l": "10"}) 178 | 179 | class ModeTestUserNumeric(unittest.TestCase): 180 | def test(self): 181 | server = ircstates.Server("test") 182 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 183 | server.parse_tokens(irctokens.tokenise("221 * +iw")) 184 | self.assertEqual(server.modes, {"i", "w"}) 185 | 186 | def test_without_plus(self): 187 | server = ircstates.Server("test") 188 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 189 | server.parse_tokens(irctokens.tokenise("221 * iw")) 190 | self.assertEqual(server.modes, {"i", "w"}) 191 | -------------------------------------------------------------------------------- /test/motd.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import ircstates, irctokens 3 | 4 | class MOTDTest(unittest.TestCase): 5 | def test(self): 6 | server = ircstates.Server("test") 7 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 8 | server.parse_tokens(irctokens.tokenise("375 * :start of motd")) 9 | server.parse_tokens(irctokens.tokenise("372 * :first line of motd")) 10 | server.parse_tokens(irctokens.tokenise("372 * :second line of motd")) 11 | self.assertEqual(server.motd, [ 12 | "start of motd", 13 | "first line of motd", 14 | "second line of motd"]) 15 | -------------------------------------------------------------------------------- /test/sasl.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import ircstates, irctokens 3 | 4 | class SASLTestAccount(unittest.TestCase): 5 | def test_loggedin(self): 6 | server = ircstates.Server("test") 7 | server.parse_tokens(irctokens.tokenise("900 * nick!user@host account *")) 8 | 9 | self.assertEqual(server.nickname, "nick") 10 | self.assertEqual(server.username, "user") 11 | self.assertEqual(server.hostname, "host") 12 | self.assertEqual(server.account, "account") 13 | 14 | def test_loggedout(self): 15 | server = ircstates.Server("test") 16 | server.parse_tokens(irctokens.tokenise("900 * nick!user@host account *")) 17 | server.parse_tokens(irctokens.tokenise("901 * nick1!user1@host1 *")) 18 | 19 | self.assertEqual(server.nickname, "nick1") 20 | self.assertEqual(server.username, "user1") 21 | self.assertEqual(server.hostname, "host1") 22 | self.assertEqual(server.account, None) 23 | -------------------------------------------------------------------------------- /test/user.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import ircstates, irctokens 3 | 4 | class UserTestNicknameChange(unittest.TestCase): 5 | def test(self): 6 | server = ircstates.Server("test") 7 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 8 | server.parse_tokens(irctokens.tokenise(":nickname NICK Nickname2")) 9 | self.assertEqual(server.nickname, "Nickname2") 10 | self.assertEqual(server.nickname_lower, "nickname2") 11 | 12 | server.parse_tokens(irctokens.tokenise(":nickname2 JOIN #chan")) 13 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 14 | self.assertIn("other", server.users) 15 | user = server.users["other"] 16 | 17 | server.parse_tokens(irctokens.tokenise(":other NICK Other2")) 18 | self.assertNotIn("other", server.users) 19 | self.assertIn("other2", server.users) 20 | 21 | self.assertEqual(user.nickname, "Other2") 22 | self.assertEqual(user.nickname_lower, "other2") 23 | 24 | class UserTestHostmaskJoin(unittest.TestCase): 25 | def test_both(self): 26 | server = ircstates.Server("test") 27 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 28 | server.parse_tokens( 29 | irctokens.tokenise(":nickname!user@host JOIN #chan")) 30 | self.assertEqual(server.username, "user") 31 | self.assertEqual(server.hostname, "host") 32 | 33 | server.parse_tokens(irctokens.tokenise(":other!user@host JOIN #chan")) 34 | user = server.users["other"] 35 | self.assertEqual(user.username, "user") 36 | self.assertEqual(user.hostname, "host") 37 | self.assertEqual(user.userhost(), "user@host") 38 | self.assertEqual(user.hostmask(), "other!user@host") 39 | 40 | def test_user(self): 41 | server = ircstates.Server("test") 42 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 43 | server.parse_tokens(irctokens.tokenise(":nickname!user JOIN #chan")) 44 | self.assertEqual(server.username, "user") 45 | self.assertIsNone(server.hostname) 46 | 47 | server.parse_tokens(irctokens.tokenise(":other!user JOIN #chan")) 48 | user = server.users["other"] 49 | self.assertEqual(user.username, "user") 50 | self.assertIsNone(user.hostname) 51 | self.assertIsNone(user.userhost()) 52 | self.assertEqual(user.hostmask(), "other!user") 53 | 54 | def test_host(self): 55 | server = ircstates.Server("test") 56 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 57 | server.parse_tokens(irctokens.tokenise(":nickname@host JOIN #chan")) 58 | self.assertIsNone(server.username) 59 | self.assertEqual(server.hostname, "host") 60 | 61 | server.parse_tokens(irctokens.tokenise(":other@host JOIN #chan")) 62 | user = server.users["other"] 63 | self.assertIsNone(user.username) 64 | self.assertEqual(user.hostname, "host") 65 | self.assertIsNone(user.userhost()) 66 | self.assertEqual(user.hostmask(), "other@host") 67 | 68 | class UserTestExtendedJoin(unittest.TestCase): 69 | def test_without_extended_join(self): 70 | server = ircstates.Server("test") 71 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 72 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 73 | self.assertIsNone(server.account) 74 | self.assertIsNone(server.realname) 75 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 76 | user = server.users["other"] 77 | self.assertIsNone(user.account) 78 | self.assertIsNone(user.realname) 79 | 80 | def test_with_account(self): 81 | server = ircstates.Server("test") 82 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 83 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan acc :realname")) 84 | self.assertEqual(server.account, "acc") 85 | self.assertEqual(server.realname, "realname") 86 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan acc2 :realname2")) 87 | user = server.users["other"] 88 | self.assertEqual(user.account, "acc2") 89 | self.assertEqual(user.realname, "realname2") 90 | 91 | def test_without_account(self): 92 | server = ircstates.Server("test") 93 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 94 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan * :realname")) 95 | self.assertEqual(server.account, "") 96 | self.assertEqual(server.realname, "realname") 97 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan * :realname2")) 98 | user = server.users["other"] 99 | self.assertEqual(user.account, "") 100 | self.assertEqual(user.realname, "realname2") 101 | 102 | class UserTestAccountNotify(unittest.TestCase): 103 | def test_with_account(self): 104 | server = ircstates.Server("test") 105 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 106 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 107 | server.parse_tokens(irctokens.tokenise(":nickname ACCOUNT acc")) 108 | self.assertEqual(server.account, "acc") 109 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 110 | server.parse_tokens(irctokens.tokenise(":other ACCOUNT acc2")) 111 | user = server.users["other"] 112 | self.assertEqual(user.account, "acc2") 113 | 114 | def test_without_account(self): 115 | server = ircstates.Server("test") 116 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 117 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 118 | server.parse_tokens(irctokens.tokenise(":nickname ACCOUNT *")) 119 | self.assertEqual(server.account, "") 120 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 121 | server.parse_tokens(irctokens.tokenise(":other ACCOUNT *")) 122 | user = server.users["other"] 123 | self.assertEqual(user.account, "") 124 | 125 | class UserTestHostmaskPRIVMSG(unittest.TestCase): 126 | def test_both(self): 127 | server = ircstates.Server("test") 128 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 129 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 130 | server.parse_tokens( 131 | irctokens.tokenise(":nickname!user@host PRIVMSG #chan :hi")) 132 | self.assertEqual(server.username, "user") 133 | self.assertEqual(server.hostname, "host") 134 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 135 | server.parse_tokens( 136 | irctokens.tokenise(":other!user@host PRIVMSG #chan :hi")) 137 | user = server.users["other"] 138 | self.assertEqual(user.username, "user") 139 | self.assertEqual(user.hostname, "host") 140 | 141 | def test_user(self): 142 | server = ircstates.Server("test") 143 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 144 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 145 | server.parse_tokens( 146 | irctokens.tokenise(":nickname!user PRIVMSG #chan :hi")) 147 | self.assertEqual(server.username, "user") 148 | self.assertIsNone(server.hostname) 149 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 150 | server.parse_tokens( 151 | irctokens.tokenise(":other!user PRIVMSG #chan :hi")) 152 | user = server.users["other"] 153 | self.assertEqual(user.username, "user") 154 | self.assertIsNone(user.hostname) 155 | 156 | def test_host(self): 157 | server = ircstates.Server("test") 158 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 159 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 160 | server.parse_tokens( 161 | irctokens.tokenise(":nickname@host PRIVMSG #chan :hi")) 162 | self.assertIsNone(server.username) 163 | self.assertEqual(server.hostname, "host") 164 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 165 | server.parse_tokens( 166 | irctokens.tokenise(":other@host PRIVMSG #chan :hi")) 167 | user = server.users["other"] 168 | self.assertIsNone(user.username) 169 | self.assertEqual(user.hostname, "host") 170 | 171 | class UserTestVisibleHost(unittest.TestCase): 172 | def test_without_username(self): 173 | server = ircstates.Server("test") 174 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 175 | server.parse_tokens(irctokens.tokenise("396 * hostname *")) 176 | self.assertIsNone(server.username) 177 | self.assertEqual(server.hostname, "hostname") 178 | 179 | def test_with_username(self): 180 | server = ircstates.Server("test") 181 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 182 | server.parse_tokens(irctokens.tokenise("396 * username@hostname *")) 183 | self.assertEqual(server.username, "username") 184 | self.assertEqual(server.hostname, "hostname") 185 | 186 | class UserTestWHO(unittest.TestCase): 187 | def test(self): 188 | server = ircstates.Server("test") 189 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 190 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 191 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 192 | server.parse_tokens( 193 | irctokens.tokenise("352 * #chan user host * nickname * :0 real")) 194 | server.parse_tokens( 195 | irctokens.tokenise("352 * #chan user2 host2 * other * :0 real2")) 196 | 197 | self.assertEqual(server.username, "user") 198 | self.assertEqual(server.hostname, "host") 199 | self.assertEqual(server.realname, "real") 200 | user = server.users["other"] 201 | self.assertEqual(user.username, "user2") 202 | self.assertEqual(user.hostname, "host2") 203 | self.assertEqual(user.realname, "real2") 204 | 205 | class UserTestCHGHOST(unittest.TestCase): 206 | def test(self): 207 | server = ircstates.Server("test") 208 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 209 | server.parse_tokens( 210 | irctokens.tokenise(":nickname!user@host JOIN #chan")) 211 | server.parse_tokens(irctokens.tokenise(":nickname CHGHOST u h")) 212 | self.assertEqual(server.username, "u") 213 | self.assertEqual(server.hostname, "h") 214 | server.parse_tokens( 215 | irctokens.tokenise(":other!user2@host2 JOIN #chan")) 216 | server.parse_tokens(irctokens.tokenise(":other CHGHOST u2 h2")) 217 | user = server.users["other"] 218 | self.assertEqual(user.username, "u2") 219 | self.assertEqual(user.hostname, "h2") 220 | 221 | class UserTestWHOIS(unittest.TestCase): 222 | def test_user_line(self): 223 | server = ircstates.Server("test") 224 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 225 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 226 | server.parse_tokens(irctokens.tokenise("311 * nickname u h * :r")) 227 | self.assertEqual(server.username, "u") 228 | self.assertEqual(server.hostname, "h") 229 | self.assertEqual(server.realname, "r") 230 | 231 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 232 | server.parse_tokens(irctokens.tokenise("311 * other u2 h2 * :r2")) 233 | user = server.users["other"] 234 | self.assertEqual(user.username, "u2") 235 | self.assertEqual(user.hostname, "h2") 236 | self.assertEqual(user.realname, "r2") 237 | 238 | class UserTestAway(unittest.TestCase): 239 | def test_verb_set(self): 240 | server = ircstates.Server("test") 241 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 242 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 243 | 244 | user = server.users["nickname"] 245 | self.assertIsNone(server.away) 246 | self.assertIsNone(user.away) 247 | 248 | server.parse_tokens(irctokens.tokenise(":nickname AWAY :ik ga weg")) 249 | self.assertEqual(server.away, "ik ga weg") 250 | self.assertEqual(user.away, "ik ga weg") 251 | 252 | def test_verb_unset(self): 253 | server = ircstates.Server("test") 254 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 255 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 256 | user = server.users["nickname"] 257 | 258 | server.parse_tokens(irctokens.tokenise( 259 | ":nickname AWAY :let's blow this popsicle stand")) 260 | server.parse_tokens(irctokens.tokenise(":nickname AWAY")) 261 | self.assertIsNone(server.away) 262 | self.assertIsNone(user.away) 263 | 264 | def test_numeric(self): 265 | server = ircstates.Server("test") 266 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 267 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 268 | user = server.users["nickname"] 269 | 270 | self.assertIsNone(server.away) 271 | self.assertIsNone(user.away) 272 | 273 | server.parse_tokens(irctokens.tokenise( 274 | "301 * nickname :i saw something shiny")) 275 | self.assertEqual(server.away, "i saw something shiny") 276 | self.assertEqual(user.away, "i saw something shiny") 277 | 278 | class UserTestSETNAME(unittest.TestCase): 279 | def test(self): 280 | server = ircstates.Server("test") 281 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 282 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 283 | server.parse_tokens(irctokens.tokenise(":other JOIN #chan")) 284 | user = server.users["other"] 285 | self.assertIsNone(user.realname) 286 | self.assertIsNone(server.realname) 287 | server.parse_tokens( 288 | irctokens.tokenise(":nickname SETNAME :new now know how")) 289 | server.parse_tokens( 290 | irctokens.tokenise(":other SETNAME :tyrannosaurus hex")) 291 | self.assertEqual(server.realname, "new now know how") 292 | self.assertEqual(user.realname, "tyrannosaurus hex") 293 | -------------------------------------------------------------------------------- /test/who.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import ircstates, irctokens 3 | from ircstates.server import WHO_TYPE 4 | 5 | class WHOTest(unittest.TestCase): 6 | def test_who(self): 7 | server = ircstates.Server("test") 8 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 9 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 10 | user = server.users["nickname"] 11 | server.parse_tokens(irctokens.tokenise( 12 | "352 * #chan user host server nickname * :0 real")) 13 | 14 | self.assertEqual(user.username, "user") 15 | self.assertEqual(user.hostname, "host") 16 | self.assertEqual(user.realname, "real") 17 | self.assertEqual(user.account, None) 18 | self.assertEqual(user.server, "server") 19 | self.assertIsNone(user.away) 20 | 21 | self.assertEqual(server.username, user.username) 22 | self.assertEqual(server.hostname, user.hostname) 23 | self.assertEqual(server.realname, user.realname) 24 | self.assertEqual(server.server, user.server) 25 | self.assertIsNone(server.away) 26 | 27 | server.parse_tokens(irctokens.tokenise( 28 | "352 * #chan user host server nickname G* :0 real")) 29 | self.assertEqual(user.away, "") 30 | self.assertEqual(server.away, "") 31 | 32 | def test_whox(self): 33 | server = ircstates.Server("test") 34 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 35 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 36 | user = server.users["nickname"] 37 | server.parse_tokens(irctokens.tokenise( 38 | f"354 * {WHO_TYPE} user 1.2.3.4 host server nickname * account :real")) 39 | 40 | self.assertEqual(user.username, "user") 41 | self.assertEqual(user.hostname, "host") 42 | self.assertEqual(user.realname, "real") 43 | self.assertEqual(user.account, "account") 44 | self.assertEqual(user.server, "server") 45 | self.assertIsNone(user.away) 46 | self.assertEqual(user.ip, "1.2.3.4") 47 | 48 | self.assertEqual(server.username, user.username) 49 | self.assertEqual(server.hostname, user.hostname) 50 | self.assertEqual(server.realname, user.realname) 51 | self.assertEqual(server.account, user.account) 52 | self.assertEqual(server.server, user.server) 53 | self.assertIsNone(server.away) 54 | self.assertEqual(server.ip, user.ip) 55 | 56 | server.parse_tokens(irctokens.tokenise( 57 | f"354 * {WHO_TYPE} user realip host server nickname G account :real")) 58 | self.assertEqual(user.away, "") 59 | self.assertEqual(server.away, "") 60 | 61 | def test_whox_no_account(self): 62 | server = ircstates.Server("test") 63 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 64 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 65 | 66 | user = server.users["nickname"] 67 | user.account = "account" 68 | server.account = "account" 69 | 70 | server.parse_tokens(irctokens.tokenise( 71 | f"354 * {WHO_TYPE} user realip host server nickname * 0 :real")) 72 | 73 | self.assertEqual(user.account, "") 74 | self.assertEqual(server.account, user.account) 75 | 76 | def test_whox_ipv6(self): 77 | server = ircstates.Server("test") 78 | server.parse_tokens(irctokens.tokenise("001 nickname *")) 79 | server.parse_tokens(irctokens.tokenise(":nickname JOIN #chan")) 80 | 81 | user = server.users["nickname"] 82 | 83 | server.parse_tokens(irctokens.tokenise( 84 | f"354 * {WHO_TYPE} user 0::1 host server nickname * 0 :real")) 85 | self.assertEqual(user.ip, "::1") 86 | 87 | server.parse_tokens(irctokens.tokenise( 88 | f"354 * {WHO_TYPE} user 00::2 host server nickname * 0 :real")) 89 | self.assertEqual(user.ip, "::2") 90 | 91 | server.parse_tokens(irctokens.tokenise( 92 | f"354 * {WHO_TYPE} user fd00:0:0:0::1 host server nickname * 0 :real")) 93 | self.assertEqual(user.ip, "fd00::1") 94 | --------------------------------------------------------------------------------