├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.rst ├── docs ├── api.rst ├── clientguide.rst ├── conf.py ├── index.rst ├── serverguide.rst └── versionhistory.rst ├── examples ├── asyncio_client.py ├── curio_client.py └── twisted_client.py ├── ircproto ├── __init__.py ├── connection.py ├── constants.py ├── events.py ├── exceptions.py ├── replies.py ├── states.py ├── styles.py └── utils.py ├── setup.cfg ├── setup.py ├── tests ├── test_connection.py ├── test_events.py ├── test_styles.py └── test_utils.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = ircproto 3 | branch = 1 4 | 5 | [report] 6 | show_missing = true 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .project 2 | .pydevproject 3 | .idea/ 4 | .coverage 5 | .cache/ 6 | .tox/ 7 | .eggs/ 8 | *.egg-info/ 9 | *.pyc 10 | dist/ 11 | docs/_build/ 12 | build/ 13 | virtualenv/ 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: python 4 | 5 | python: 6 | - "2.7" 7 | - "3.3" 8 | - "3.4" 9 | - "3.5" 10 | 11 | install: pip install tox-travis coveralls 12 | 13 | script: tox 14 | 15 | after_success: coveralls 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is the MIT license: http://www.opensource.org/licenses/mit-license.php 2 | 3 | Copyright (c) Alex Grönholm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the "Software"), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons 9 | to whom the Software is furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or 12 | substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 16 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 17 | FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/agronholm/ircproto.svg?branch=master 2 | :target: https://travis-ci.org/agronholm/ircproto 3 | :alt: Build Status 4 | .. image:: https://coveralls.io/repos/github/agronholm/ircproto/badge.svg?branch=master 5 | :target: https://coveralls.io/github/agronholm/ircproto?branch=master 6 | :alt: Code Coverage 7 | 8 | The IRC_ (Internet Relay Chat) protocol is the oldest distributed chat protocol still in widespread 9 | use. This library implements both client and server sides of the IRC protocol as a pure 10 | state-machine which only takes in bytes and returns a list of parsed events. This leaves users free 11 | to use any I/O approach they see fit (asyncio_, curio_, Twisted_, etc.). 12 | 13 | Sample code is provided for implementing both clients and servers using a variety of I/O 14 | frameworks. 15 | 16 | .. _IRC: https://tools.ietf.org/html/rfc2812 17 | .. _asyncio: https://docs.python.org/3/library/asyncio.html 18 | .. _curio: https://github.com/dabeaz/curio 19 | .. _Twisted: https://twistedmatrix.com/ 20 | 21 | Project links 22 | ------------- 23 | 24 | * `Documentation `_ 25 | * `Source code `_ 26 | * `Issue tracker `_ 27 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | TODO 5 | -------------------------------------------------------------------------------- /docs/clientguide.rst: -------------------------------------------------------------------------------- 1 | Client implementor's guide 2 | ========================== 3 | 4 | Creating a real-world IRC client using ircproto is quite straightforward. 5 | As with other sans-io protocols, you feed incoming data to ircproto and it vends events in return. 6 | To invoke actions on the connection, call 7 | :meth:`~ircproto.connection.IRCClientConnection.send_command` with the appropriate arguments for 8 | each command. The server's replies will then be available as :class:`~ircproto.events.Reply` 9 | events. 10 | 11 | To get pending outgoing data, use the :meth:`~ircproto.IRCClientConnection.data_to_send` method. 12 | For a reference on the available commands and their arguments, see :rfc:`2812`. 13 | 14 | Implementing DCC protocols 15 | -------------------------- 16 | 17 | TODO 18 | 19 | Running the examples 20 | -------------------- 21 | 22 | The ``examples`` directory in the project source tree contains example code for several popular 23 | I/O frameworks to get you started. Just run any of the client scripts and it will connect to the 24 | specified server, join the specified channel, send a message there and then disconnect. 25 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | import pkg_resources 4 | 5 | extensions = [ 6 | 'sphinx.ext.autodoc', 7 | ] 8 | 9 | templates_path = ['_templates'] 10 | source_suffix = '.rst' 11 | master_doc = 'index' 12 | project = 'ircproto' 13 | author = u'Alex Grönholm' 14 | copyright = '2016, ' + author 15 | 16 | v = pkg_resources.get_distribution('ircproto').parsed_version 17 | version = v.base_version 18 | release = v.public 19 | 20 | language = None 21 | 22 | exclude_patterns = ['_build'] 23 | pygments_style = 'sphinx' 24 | todo_include_todos = False 25 | 26 | html_theme = 'classic' 27 | html_static_path = ['_static'] 28 | htmlhelp_basename = 'ircprotodoc' 29 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | IRC state-machine protocol (ircproto) 2 | ===================================== 3 | 4 | .. include:: ../README.rst 5 | :start-line: 7 6 | :end-before: Project links 7 | 8 | 9 | Table of Contents 10 | ================= 11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | 15 | clientguide 16 | serverguide 17 | api 18 | versionhistory 19 | -------------------------------------------------------------------------------- /docs/serverguide.rst: -------------------------------------------------------------------------------- 1 | Server implementor's guide 2 | ========================== 3 | 4 | Creating a real-world IRC server using ircproto is somewhat more complicated than writing a client. 5 | 6 | As with other sans-io protocols, you feed incoming data to ircproto and it vends events in return. 7 | For a server, all your :class:`~ircproto.connection.IRCServerConnection` instances will need a 8 | shared :class:`~ircproto.states.IRCServerState` instance. This server state will track the states 9 | of connected clients, connected servers and open channels. Feeding incoming data to an IRC 10 | connection on a server will usually generate outgoing data for multiple connections. 11 | The I/O implementation is responsible for keeping a lookup table that allows it to target the 12 | outgoing data to the proper network sockets. 13 | 14 | Implementor's responsibilities 15 | ------------------------------ 16 | 17 | The logic in the connection class will handle most complications of the protocol. 18 | That leaves just a handful of things for I/O implementors to keep in mind: 19 | 20 | * Reply to every command event coming from connected peers (except ``PONG``). 21 | Some commands may require multiple replies. 22 | Refer to :rfc:`2812` regarding which replies are appropriate for each command. 23 | * Regularly send PING messages to clients and drop their connections when they fail to respond in 24 | time 25 | * Connect/disconnect other servers when an IRC operator requests it 26 | * Disconnect clients when they are killed 27 | 28 | Running the examples 29 | -------------------- 30 | 31 | The ``examples`` directory in the project source tree contains example code for several popular 32 | I/O frameworks to get you started. Just run any of the server scripts and it will start a server 33 | listening on the default IRC port (6667). 34 | -------------------------------------------------------------------------------- /docs/versionhistory.rst: -------------------------------------------------------------------------------- 1 | Version history 2 | =============== 3 | 4 | This library adheres to `Semantic Versioning `_. 5 | 6 | **1.0.0** 7 | 8 | - Initial release 9 | -------------------------------------------------------------------------------- /examples/asyncio_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | 3 | from argparse import ArgumentParser 4 | 5 | from ircproto.connection import IRCClientConnection 6 | from ircproto.constants import RPL_MYINFO 7 | from ircproto.events import Reply, Error, Join 8 | 9 | try: 10 | from asyncio import get_event_loop, Protocol 11 | except ImportError: 12 | from trollius import get_event_loop, Protocol 13 | 14 | 15 | class MessageSendProtocol(Protocol): 16 | def __init__(self, nickname, channel, message): 17 | self.nickname = nickname 18 | self.channel = channel 19 | self.message = message 20 | self.conn = IRCClientConnection() 21 | self.transport = None 22 | 23 | def connection_made(self, transport): 24 | self.transport = transport 25 | self.conn.send_command('NICK', self.nickname) 26 | self.conn.send_command('USER', 'ircproto', '0', 'ircproto example client') 27 | self.send_outgoing_data() 28 | 29 | def connection_lost(self, exc): 30 | get_event_loop().stop() 31 | 32 | def data_received(self, data): 33 | close_connection = False 34 | for event in self.conn.feed_data(data): 35 | print('<<< ' + event.encode().rstrip()) 36 | if isinstance(event, Reply): 37 | if event.is_error: 38 | self.transport.abort() 39 | return 40 | elif event.code == RPL_MYINFO: 41 | self.conn.send_command('JOIN', self.channel) 42 | elif isinstance(event, Join): 43 | self.conn.send_command('PRIVMSG', self.channel, self.message) 44 | self.conn.send_command('QUIT') 45 | close_connection = True 46 | elif isinstance(event, Error): 47 | self.transport.abort() 48 | return 49 | 50 | self.send_outgoing_data() 51 | if close_connection: 52 | self.transport.close() 53 | 54 | def send_outgoing_data(self): 55 | # This is more complicated than it should because we want to print all outgoing data here. 56 | # Normally, self.transport.write(self.conn.data_to_send()) would suffice. 57 | output = self.conn.data_to_send() 58 | if output: 59 | print('>>> ' + output.decode('utf-8').replace('\r\n', '\r\n>>> ').rstrip('> \r\n')) 60 | self.transport.write(output) 61 | 62 | parser = ArgumentParser(description='A sample IRC client') 63 | parser.add_argument('host', help='address of irc server (foo.bar.baz or foo.bar.baz:port)') 64 | parser.add_argument('nickname', help='nickname to register as') 65 | parser.add_argument('channel', help='channel to join once registered') 66 | parser.add_argument('message', help='message to send once joined') 67 | args = parser.parse_args() 68 | host, _, port = args.host.partition(':') 69 | 70 | loop = get_event_loop() 71 | protocol = MessageSendProtocol(args.nickname, args.channel, args.message) 72 | loop.run_until_complete(loop.create_connection(lambda: protocol, host, int(port or 6667))) 73 | loop.run_forever() 74 | -------------------------------------------------------------------------------- /examples/curio_client.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | 3 | import curio 4 | 5 | from ircproto.connection import IRCClientConnection 6 | from ircproto.constants import RPL_MYINFO 7 | from ircproto.events import Reply, Error, Join 8 | 9 | 10 | async def send_message_to_channel(host, port, nickname, channel, message): 11 | async def send_outgoing_data(): 12 | # This is more complicated than it should because we want to print all outgoing data here. 13 | # Normally, await sock.sendall(conn.data_to_send()) would suffice. 14 | output = conn.data_to_send() 15 | if output: 16 | print('>>> ' + output.decode('utf-8').replace('\r\n', '\r\n>>> ').rstrip('> \r\n')) 17 | await sock.sendall(output) 18 | 19 | sock = await curio.open_connection(host, port) 20 | async with sock: 21 | conn = IRCClientConnection() 22 | conn.send_command('NICK', nickname) 23 | conn.send_command('USER', 'ircproto', '0', 'ircproto example client') 24 | await send_outgoing_data() 25 | while True: 26 | data = await sock.recv(10000) 27 | for event in conn.feed_data(data): 28 | print('<<< ' + event.encode().rstrip()) 29 | if isinstance(event, Reply): 30 | if event.is_error: 31 | return 32 | elif event.code == RPL_MYINFO: 33 | conn.send_command('JOIN', channel) 34 | elif isinstance(event, Join): 35 | conn.send_command('PRIVMSG', channel, message) 36 | conn.send_command('QUIT') 37 | await send_outgoing_data() 38 | return 39 | elif isinstance(event, Error): 40 | return 41 | 42 | await send_outgoing_data() 43 | 44 | 45 | parser = ArgumentParser(description='A sample IRC client') 46 | parser.add_argument('host', help='address of irc server (foo.bar.baz or foo.bar.baz:port)') 47 | parser.add_argument('nickname', help='nickname to register as') 48 | parser.add_argument('channel', help='channel to join once registered') 49 | parser.add_argument('message', help='message to send once joined') 50 | args = parser.parse_args() 51 | host, _, port = args.host.partition(':') 52 | 53 | curio.run(send_message_to_channel(host, int(port or 6667), args.nickname, args.channel, 54 | args.message)) 55 | -------------------------------------------------------------------------------- /examples/twisted_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | 3 | from argparse import ArgumentParser 4 | 5 | from twisted.internet import reactor 6 | from twisted.internet.protocol import Protocol, connectionDone, ClientFactory 7 | 8 | from ircproto.connection import IRCClientConnection 9 | from ircproto.constants import RPL_MYINFO 10 | from ircproto.events import Reply, Error, Join 11 | 12 | 13 | class IRCProtocol(Protocol): 14 | def __init__(self, nickname, channel, message): 15 | self.nickname = nickname 16 | self.channel = channel 17 | self.message = message 18 | self.conn = IRCClientConnection() 19 | 20 | def connectionMade(self): 21 | self.conn.send_command('NICK', self.nickname) 22 | self.conn.send_command('USER', 'ircproto', '0', 'ircproto example client') 23 | self.send_outgoing_data() 24 | 25 | def connectionLost(self, reason=connectionDone): 26 | reactor.stop() 27 | 28 | def dataReceived(self, data): 29 | close_connection = False 30 | for event in self.conn.feed_data(data): 31 | print('<<< ' + event.encode().rstrip()) 32 | if isinstance(event, Reply): 33 | if event.is_error: 34 | self.transport.abortConnection() 35 | return 36 | elif event.code == RPL_MYINFO: 37 | self.conn.send_command('JOIN', self.channel) 38 | elif isinstance(event, Join): 39 | self.conn.send_command('PRIVMSG', self.channel, self.message) 40 | self.conn.send_command('QUIT') 41 | close_connection = True 42 | elif isinstance(event, Error): 43 | self.transport.abortConnection() 44 | return 45 | 46 | self.send_outgoing_data() 47 | if close_connection: 48 | self.transport.loseConnection() 49 | 50 | def send_outgoing_data(self): 51 | # This is more complicated than it should because we want to print all outgoing data here. 52 | # Normally, self.transport.write(self.conn.data_to_send()) would suffice. 53 | output = self.conn.data_to_send() 54 | if output: 55 | print('>>> ' + output.decode('utf-8').replace('\r\n', '\r\n>>> ').rstrip('> \r\n')) 56 | self.transport.write(output) 57 | 58 | 59 | class IRCClientFactory(ClientFactory): 60 | def buildProtocol(self, addr): 61 | return IRCProtocol(args.nickname, args.channel, args.message) 62 | 63 | parser = ArgumentParser(description='A sample IRC client') 64 | parser.add_argument('host', help='address of irc server (foo.bar.baz or foo.bar.baz:port)') 65 | parser.add_argument('nickname', help='nickname to register as') 66 | parser.add_argument('channel', help='channel to join once registered') 67 | parser.add_argument('message', help='message to send once joined') 68 | args = parser.parse_args() 69 | host, _, port = args.host.partition(':') 70 | 71 | reactor.connectTCP(host, int(port or 6667), IRCClientFactory()) 72 | reactor.run() 73 | -------------------------------------------------------------------------------- /ircproto/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agronholm/ircproto/7993857e0f475f9fe8484bc669906cf1d6add0dd/ircproto/__init__.py -------------------------------------------------------------------------------- /ircproto/connection.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import codecs 4 | 5 | from ircproto.events import decode_event, commands, Reply, Ping 6 | from ircproto.exceptions import ProtocolError 7 | from ircproto.replies import reply_templates 8 | 9 | 10 | class BaseIRCConnection(object): 11 | """Base class for IRC connection state machines.""" 12 | 13 | __slots__ = ('output_codec', 'input_decoder', 'fallback_decoder', '_input_buffer', 14 | '_output_buffer', '_closed') 15 | 16 | sender = None # type: str 17 | 18 | def __init__(self, output_encoding='utf-8', input_encoding='utf-8', 19 | fallback_encoding='iso-8859-1'): 20 | self.output_codec = codecs.getencoder(output_encoding) 21 | self.input_decoder = codecs.getdecoder(input_encoding) 22 | self.fallback_decoder = codecs.getdecoder(fallback_encoding) 23 | self._input_buffer = bytearray() 24 | self._output_buffer = bytearray() 25 | self._closed = False 26 | 27 | def feed_data(self, data): 28 | """ 29 | Feed data to the internal buffer of the connection. 30 | 31 | If there is enough data to generate one or more events, they will be added to the list 32 | returned from this call. 33 | 34 | Sometimes this call generates outgoing data so it is important to call 35 | :meth:`.data_to_send` afterwards and write those bytes to the output. 36 | 37 | :param bytes data: incoming data 38 | :raise ircproto.ProtocolError: if the protocol is violated 39 | :return: the list of generated events 40 | :rtype: list 41 | 42 | """ 43 | self._input_buffer.extend(data) 44 | events = [] 45 | while True: 46 | event = decode_event(self._input_buffer, self.input_decoder, self.fallback_decoder) 47 | if event is None: 48 | return events 49 | else: 50 | self.handle_event(event) 51 | events.append(event) 52 | 53 | def data_to_send(self): 54 | """ 55 | Return any data that is due to be sent to the other end. 56 | 57 | :rtype: bytes 58 | 59 | """ 60 | data = bytes(self._output_buffer) 61 | del self._output_buffer[:] 62 | return data 63 | 64 | def handle_event(self, event): 65 | # Automatically respond to pings 66 | if isinstance(event, Ping): 67 | self.send_command('PONG', event.server1, event.server2) 68 | 69 | def send_command(self, command, *params): 70 | """ 71 | Send a command to the peer. 72 | 73 | This method looks up the appropriate command event class and instantiates it using 74 | ``params``. Then the event is encoded and added to the output buffer. 75 | 76 | :param str command: name of the command (``NICK``, ``PRIVMSG`` etc.) 77 | :param str params: arguments for the constructor of the command class 78 | 79 | """ 80 | if isinstance(command, bytes): 81 | command = command.decode('ascii') 82 | 83 | try: 84 | command_cls = commands[command] 85 | except KeyError: 86 | raise ProtocolError('no such command: %s' % command) 87 | 88 | event = command_cls(None, *params) 89 | self._send_event(event) 90 | 91 | def _send_event(self, event): 92 | """ 93 | Send an event to the peer. 94 | 95 | :param ircproto.events.Event event: the event to send 96 | 97 | """ 98 | if self._closed: 99 | raise ProtocolError('the connection has been closed') 100 | 101 | encoded_event = event.encode() 102 | self._output_buffer.extend(self.output_codec(encoded_event)[0]) 103 | 104 | 105 | class IRCClientConnection(BaseIRCConnection): 106 | """An IRC client's connection to a server.""" 107 | 108 | __slots__ = ('nickname', 'realname') 109 | 110 | def __init__(self): 111 | super(IRCClientConnection, self).__init__() 112 | self.nickname = self.realname = None 113 | 114 | 115 | class IRCServerConnection(BaseIRCConnection): 116 | """A server side connection to either an IRC client or another IRC server.""" 117 | 118 | __slots__ = ('host', '_server_state') 119 | 120 | def __init__(self, host, server_state): 121 | super(IRCServerConnection, self).__init__() 122 | self.host = host 123 | self._server_state = server_state 124 | 125 | def send_reply(self, code, **templatevars): 126 | """ 127 | Send a reply for a command. 128 | 129 | This method creates a :class:`~ircproto.events.Reply`, encodes it and adds the result to 130 | the ouput buffer. 131 | 132 | :param int code: reply code 133 | :param templatevars: variables required for the reply message template 134 | 135 | """ 136 | # Format the reply message 137 | message = reply_templates[code].format(**templatevars) 138 | event = Reply(self.sender, code, message) 139 | self._send_event(event) 140 | 141 | @property 142 | def sender(self): 143 | return self._server_state.host 144 | -------------------------------------------------------------------------------- /ircproto/constants.py: -------------------------------------------------------------------------------- 1 | RPL_WELCOME = 1 2 | RPL_YOURHOST = 2 3 | RPL_CREATED = 3 4 | RPL_MYINFO = 4 5 | RPL_BOUNCE = 5 6 | RPL_USERHOST = 302 7 | RPL_ISON = 303 8 | RPL_AWAY = 301 9 | RPL_UNAWAY = 305 10 | RPL_NOWAWAY = 306 11 | RPL_WHOISUSER = 311 12 | RPL_WHOISSERVER = 312 13 | RPL_WHOISOPERATOR = 313 14 | RPL_WHOISSIDLE = 317 15 | RPL_ENDOFWHOIS = 318 16 | RPL_WHOISCHANNELS = 319 17 | RPL_WHOWASUSER = 314 18 | RPL_ENDOFWHOWAS = 369 19 | RPL_LIST = 322 20 | RPL_LISTEND = 323 21 | RPL_UNIQOPIS = 325 22 | RPL_CHANNELMODEIS = 324 23 | RPL_NOTOPIC = 331 24 | RPL_TOPIC = 332 25 | RPL_INVITING = 341 26 | RPL_SUMMONING = 342 27 | RPL_INVITELIST = 346 28 | RPL_ENDOFINVITELIST = 347 29 | RPL_EXCEPTLIST = 348 30 | RPL_ENDOFEXCEPTLIST = 349 31 | RPL_VERSION = 351 32 | RPL_WHOREPLY = 352 33 | RPL_ENDOFWHO = 315 34 | RPL_NAMREPLY = 353 35 | RPL_ENDOFNAMES = 366 36 | RPL_LINKS = 364 37 | RPL_ENDOFLINKS = 365 38 | RPL_BANLIST = 367 39 | RPL_ENDOFBANLIST = 368 40 | RPL_INFO = 371 41 | RPL_ENDOFINFO = 374 42 | RPL_MOTDSTART = 375 43 | RPL_MOTD = 372 44 | RPL_ENDOFMOTD = 376 45 | RPL_YOUREOPER = 381 46 | RPL_REHASHING = 382 47 | RPL_YOURESERVICE = 383 48 | RPL_TIME = 391 49 | RPL_USERSSTART = 392 50 | RPL_USERS = 393 51 | RPL_ENDOFUSERS = 394 52 | RPL_NOUSERS = 395 53 | RPL_TRACELINK = 200 54 | RPL_TRACECONNECTING = 201 55 | RPL_TRACEHANDSHAKE = 202 56 | RPL_TRACEUNKNOWN = 203 57 | RPL_TRACEOPERATOR = 204 58 | RPL_TRACEUSER = 205 59 | RPL_TRACESERVER = 206 60 | RPL_TRACESERVICE = 207 61 | RPL_TRACENEWTYPE = 208 62 | RPL_TRACECLASS = 209 63 | RPL_TRACELOG = 261 64 | RPL_TRACEEND = 262 65 | RPL_STATSLINKINFO = 211 66 | RPL_STATSCOMMANDS = 212 67 | RPL_ENDOFSTATS = 219 68 | RPL_STATSUPTIME = 242 69 | RPL_STATSOLINE = 243 70 | RPL_UMODEIS = 221 71 | RPL_SERVLIST = 234 72 | RPL_SERVLISTEND = 235 73 | RPL_LUSERCLIENT = 251 74 | RPL_LUSEROP = 252 75 | RPL_LUSERUNKNOWN = 253 76 | RPL_LUSERCHANNELS = 254 77 | RPL_LUSERME = 255 78 | RPL_ADMINME = 256 79 | RPL_ADMINLOC1 = 257 80 | RPL_ADMINLOC2 = 258 81 | RPL_ADMINEMAIL = 259 82 | RPL_TRYAGAIN = 263 83 | ERR_NOSUCHNICK = 401 84 | ERR_NOSUCHSERVER = 402 85 | ERR_NOSUCHCHANNEL = 403 86 | ERR_CANNOTSENDTOCHAN = 404 87 | ERR_TOOMANYCHANNELS = 405 88 | ERR_WASNOSUCHNICK = 406 89 | ERR_TOOMANYTARGETS = 407 90 | ERR_NOSUCHSERVICE = 408 91 | ERR_NOORIGIN = 409 92 | ERR_NORECIPIENT = 410 93 | ERR_NOTEXTTOSEND = 412 94 | ERR_NOTOPLEVEL = 413 95 | ERR_WILDTOPLEVEL = 414 96 | ERR_BADMASK = 415 97 | ERR_UNKNOWNCOMMAND = 421 98 | ERR_NOMOTD = 422 99 | ERR_NOADMININFO = 423 100 | ERR_FILEERROR = 424 101 | ERR_NONICKNAMEGIVEN = 431 102 | ERR_ERRONEUSNICKNAME = 432 103 | ERR_NICKNAMEINUSE = 433 104 | ERR_NICKCOLLISION = 436 105 | ERR_UNAVAILRESOURCE = 437 106 | ERR_USERNOTINCHANNEL = 441 107 | ERR_NOTONCHANNEL = 442 108 | ERR_USERONCHANNEL = 443 109 | ERR_NOLOGIN = 444 110 | ERR_SUMMONDISABLED = 445 111 | ERR_USERSDISABLED = 446 112 | ERR_NOTREGISTERED = 451 113 | ERR_NEEDMOREPARAMS = 461 114 | ERR_ALREADYREGISTRED = 462 115 | ERR_NOPERMFORHOST = 463 116 | ERR_PASSWDMISMATCH = 464 117 | ERR_YOUREBANNEDCREEP = 465 118 | ERR_YOUWILLBEBANNED = 466 119 | ERR_KEYSET = 467 120 | ERR_CHANNELISFULL = 471 121 | ERR_UNKNOWNMODE = 472 122 | ERR_INVITEONLYCHAN = 473 123 | ERR_BANNEDFROMCHAN = 474 124 | ERR_BADCHANNELKEY = 475 125 | ERR_BADCHANMASK = 476 126 | ERR_NOCHANMODES = 477 127 | ERR_BANLISTFULL = 478 128 | ERR_NOPRIVILEGES = 481 129 | ERR_CHANOPRIVSNEEDED = 482 130 | ERR_CANTKILLSERVER = 483 131 | ERR_RESTRICTED = 484 132 | ERR_UNIQOPPRIVSNEEDED = 485 133 | ERR_NOOPERHOST = 491 134 | ERR_UMODEUNKNOWNFLAG = 501 135 | ERR_USERSDONTMATCH = 502 136 | 137 | reply_names = {value: key for key, value in locals().items() if isinstance(value, int)} 138 | -------------------------------------------------------------------------------- /ircproto/events.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import codecs 4 | 5 | from ircproto.exceptions import ProtocolError, UnknownCommand 6 | from ircproto.constants import * 7 | 8 | 9 | class IRCEvent(object): 10 | """ 11 | Base class for all IRC events. 12 | 13 | :ivar sender: either a server host name or nickname!username@host 14 | """ 15 | 16 | __slots__ = ('sender',) 17 | 18 | def __init__(self, sender): 19 | self.sender = sender 20 | 21 | def encode(self, *params): 22 | """ 23 | Encode the event into a string. 24 | 25 | :return: a unicode string ending in CRLF 26 | :raises ProtocolError: if any parameter save the last one contains spaces 27 | 28 | """ 29 | buffer = '' 30 | if self.sender: 31 | buffer += ':' + self.sender + ' ' 32 | 33 | for i, param in enumerate(params): 34 | if not param: 35 | continue 36 | 37 | if ' ' in param: 38 | if i == len(params) - 1: 39 | param = ':' + param 40 | else: 41 | raise ProtocolError('only the last parameter can contain spaces') 42 | 43 | if i > 0: 44 | buffer += ' ' 45 | buffer += param 46 | 47 | return buffer + '\r\n' 48 | 49 | 50 | class Command(IRCEvent): 51 | """ 52 | Base class for all command events. 53 | 54 | :var str command: the associated command word 55 | :var bool privileged: ``True`` if this command requires IRC operator privileges 56 | :var tuple allowed_replies: allowed reply codes for this command 57 | """ 58 | 59 | __slots__ = () 60 | 61 | command = None # type: str 62 | privileged = False 63 | allowed_replies = () 64 | 65 | def process_reply(self, code): 66 | if code not in reply_names: 67 | raise ProtocolError('%s is not a known reply code' % code) 68 | 69 | if code not in self.allowed_replies: 70 | code_name = reply_names[code] 71 | raise ProtocolError('reply code %s is not allowed for %s' % (code_name, self.command)) 72 | 73 | return True 74 | 75 | @classmethod 76 | def decode(cls, sender, *params): 77 | try: 78 | return cls(sender, *params) 79 | except TypeError: 80 | raise ProtocolError('wrong number of arguments for %s' % cls.command) 81 | 82 | def encode(self, *params): 83 | return super(Command, self).encode(self.command, *params) 84 | 85 | 86 | class Reply(IRCEvent): 87 | """ 88 | Represents a numeric reply from a server to a client. 89 | 90 | :ivar int code: a numeric reply code 91 | :ivar str message: the reply message 92 | """ 93 | 94 | __slots__ = ('code', 'message') 95 | 96 | def __init__(self, sender, code, message): 97 | super(Reply, self).__init__(sender) 98 | self.code = int(code) 99 | self.message = message 100 | 101 | @property 102 | def is_error(self): 103 | """Return ``True`` if this is an error reply, ``False`` otherwise.""" 104 | return self.code >= 400 105 | 106 | def encode(self): 107 | return super(Reply, self).encode(str(self.code), self.message) 108 | 109 | 110 | # Section 3.1.1 111 | class Password(Command): 112 | __slots__ = ('password',) 113 | 114 | command = 'PASS' 115 | allowed_replies = (ERR_NEEDMOREPARAMS, ERR_ALREADYREGISTRED) 116 | 117 | def __init__(self, sender, password): 118 | super(Password, self).__init__(sender) 119 | self.password = password 120 | 121 | def encode(self): 122 | return super(Password, self).encode(self.password) 123 | 124 | 125 | # Section 3.1.2 126 | class Nick(Command): 127 | __slots__ = ('nickname',) 128 | 129 | command = 'NICK' 130 | allowed_replies = (ERR_NONICKNAMEGIVEN, ERR_ERRONEUSNICKNAME, ERR_NICKNAMEINUSE, 131 | ERR_NICKCOLLISION, ERR_UNAVAILRESOURCE, ERR_RESTRICTED) 132 | 133 | def __init__(self, sender, nickname): 134 | super(Nick, self).__init__(sender) 135 | self.nickname = nickname 136 | 137 | def encode(self): 138 | return super(Nick, self).encode(self.nickname) 139 | 140 | 141 | # Section 3.1.3 142 | class User(Command): 143 | __slots__ = ('user', 'mode', 'realname') 144 | 145 | command = 'USER' 146 | allowed_replies = (ERR_NEEDMOREPARAMS, ERR_ALREADYREGISTRED) 147 | 148 | def __init__(self, sender, user, mode, realname): 149 | super(User, self).__init__(sender) 150 | self.user = user 151 | self.mode = mode 152 | self.realname = realname 153 | 154 | @classmethod 155 | def decode(cls, sender, *params): 156 | return super(User).decode(sender, params[0], params[1], params[3]) 157 | 158 | def encode(self): 159 | return super(User, self).encode(self.user, self.mode, '*', self.realname) 160 | 161 | 162 | # Section 3.1.4 163 | class Oper(Command): 164 | __slots__ = ('name', 'password') 165 | 166 | command = 'OPER' 167 | allowed_replies = (ERR_NEEDMOREPARAMS, ERR_NOOPERHOST, ERR_PASSWDMISMATCH, RPL_YOUREOPER) 168 | 169 | def __init__(self, sender, name, password): 170 | super(Oper, self).__init__(sender) 171 | self.name = name 172 | self.password = password 173 | 174 | def encode(self): 175 | return super(Oper, self).encode(self.name, self.password) 176 | 177 | 178 | # Section 3.1.5 / 3.2.3 179 | class Mode(Command): 180 | __slots__ = ('target', 'modes', 'modeparams') 181 | 182 | command = 'MODE' 183 | allowed_replies = (ERR_NEEDMOREPARAMS, ERR_USERSDONTMATCH, ERR_UMODEUNKNOWNFLAG, RPL_UMODEIS) 184 | 185 | def __init__(self, sender, target, modes, *modeparams): 186 | super(Mode, self).__init__(sender) 187 | self.target = target 188 | self.modes = modes 189 | self.modeparams = modeparams 190 | 191 | def encode(self): 192 | return super(Mode, self).encode(self.target, self.modes, *self.modeparams) 193 | 194 | 195 | # Section 3.1.6 196 | class Service(Command): 197 | __slots__ = ('nickname', 'distribution', 'type', 'info') 198 | 199 | command = 'SERVICE' 200 | allowed_replies = (ERR_ALREADYREGISTRED, ERR_NEEDMOREPARAMS, ERR_ERRONEUSNICKNAME, 201 | RPL_YOURESERVICE, RPL_YOURHOST, RPL_MYINFO) 202 | 203 | def __init__(self, sender, nickname, distribution, type_, info): 204 | super(Service, self).__init__(sender) 205 | self.nickname = nickname 206 | self.distribution = distribution 207 | self.type = type_ 208 | self.info = info 209 | 210 | def encode(self): 211 | return super(Service, self).encode(self.nickname, '*', self.distribution, self.type, '*', 212 | self.info) 213 | 214 | 215 | # Section 3.1.7 216 | class Quit(Command): 217 | __slots = ('message',) 218 | 219 | command = 'QUIT' 220 | 221 | def __init__(self, sender, message=None): 222 | super(Quit, self).__init__(sender) 223 | self.message = message 224 | 225 | def encode(self): 226 | return super(Quit, self).encode(self.message) 227 | 228 | 229 | # Section 3.1.8 230 | class SQuit(Command): 231 | __slots__ = ('server', 'comment') 232 | 233 | command = 'SQUIT' 234 | privileged = True 235 | allowed_replies = (ERR_NOPRIVILEGES, ERR_NOSUCHSERVER, ERR_NEEDMOREPARAMS) 236 | 237 | def __init__(self, sender, server, comment): 238 | super(SQuit, self).__init__(sender) 239 | self.server = server 240 | self.comment = comment 241 | 242 | def encode(self): 243 | return super(SQuit, self).encode(self.server, self.comment) 244 | 245 | 246 | # Section 3.2.1 247 | class Join(Command): 248 | __slots__ = ('channel', 'key') 249 | 250 | command = 'JOIN' 251 | allowed_replies = (ERR_NEEDMOREPARAMS, ERR_BANNEDFROMCHAN, ERR_INVITEONLYCHAN, 252 | ERR_BADCHANNELKEY, ERR_CHANNELISFULL, ERR_BADCHANMASK, ERR_NOSUCHCHANNEL, 253 | ERR_TOOMANYCHANNELS, ERR_TOOMANYTARGETS, ERR_UNAVAILRESOURCE, RPL_TOPIC) 254 | 255 | def __init__(self, sender, channel, key=None): 256 | super(Join, self).__init__(sender) 257 | self.channel = channel 258 | self.key = key 259 | 260 | def encode(self): 261 | return super(Join, self).encode(self.channel, self.key) 262 | 263 | 264 | # Section 3.2.2 265 | class Part(Command): 266 | __slots__ = ('channel', 'message') 267 | 268 | command = 'PART' 269 | allowed_replies = (ERR_NEEDMOREPARAMS, ERR_NOSUCHCHANNEL, ERR_NOTONCHANNEL) 270 | 271 | def __init__(self, sender, channel, message=None): 272 | super(Part, self).__init__(sender) 273 | self.channel = channel 274 | self.message = message 275 | 276 | def encode(self): 277 | super(Part, self).encode(self.channel) 278 | 279 | 280 | # Section 3.2.4 281 | class Topic(Command): 282 | __slots__ = ('channel', 'topic') 283 | 284 | command = 'TOPIC' 285 | allowed_replies = (ERR_NEEDMOREPARAMS, ERR_NOTONCHANNEL, RPL_NOTOPIC, RPL_TOPIC, 286 | ERR_CHANOPRIVSNEEDED, ERR_NOCHANMODES) 287 | 288 | def __init__(self, sender, channel, topic): 289 | super(Topic, self).__init__(sender) 290 | self.channel = channel 291 | self.topic = topic 292 | 293 | def encode(self, *params): 294 | return super(Topic, self).encode(self.channel, self.topic) 295 | 296 | 297 | # Section 3.2.5 298 | class Names(Command): 299 | __slots__ = () 300 | 301 | command = 'NAMES' 302 | allowed_replies = (ERR_NOSUCHSERVER, RPL_NAMREPLY, RPL_ENDOFNAMES) 303 | 304 | 305 | # Section 3.2.6 306 | class List(Command): 307 | __slots__ = () 308 | 309 | command = 'LIST' 310 | allowed_replies = (ERR_NOSUCHSERVER, RPL_LIST, RPL_LISTEND) 311 | 312 | 313 | # Section 3.2.7 314 | class Invite(Command): 315 | __slots__ = ('nickname', 'channel') 316 | 317 | command = 'INVITE' 318 | allowed_replies = (ERR_NEEDMOREPARAMS, ERR_NOSUCHNICK, ERR_NOTONCHANNEL, ERR_USERONCHANNEL, 319 | ERR_CHANOPRIVSNEEDED, RPL_INVITING, RPL_AWAY) 320 | 321 | def __init__(self, sender, nickname, channel): 322 | super(Invite, self).__init__(sender) 323 | self.nickname = nickname 324 | self.channel = channel 325 | 326 | def encode(self): 327 | return super(Invite, self).encode(self.nickname, self.channel) 328 | 329 | 330 | # Section 3.2.8 331 | class Kick(Command): 332 | __slots__ = ('channel', 'nickname', 'comment') 333 | 334 | command = 'KICK' 335 | allowed_replies = (ERR_NEEDMOREPARAMS, ERR_NOSUCHCHANNEL, ERR_BADCHANMASK, 336 | ERR_CHANOPRIVSNEEDED, ERR_USERNOTINCHANNEL, ERR_NOTONCHANNEL) 337 | 338 | def __init__(self, sender, channel, nickname, comment=None): 339 | super(Kick, self).__init__(sender) 340 | self.channel = channel 341 | self.nickname = nickname 342 | self.comment = comment 343 | 344 | def encode(self): 345 | return super(Kick, self).encode(self.channel, self.nickname, self.comment) 346 | 347 | 348 | # Section 3.3.1 349 | class PrivateMessage(Command): 350 | __slots__ = ('recipient', 'message') 351 | 352 | command = 'PRIVMSG' 353 | allowed_replies = (ERR_NORECIPIENT, ERR_NOTEXTTOSEND, ERR_CANNOTSENDTOCHAN, ERR_NOTOPLEVEL, 354 | ERR_WILDTOPLEVEL, ERR_TOOMANYTARGETS, ERR_NOSUCHNICK, RPL_AWAY) 355 | 356 | def __init__(self, sender, recipient, message): 357 | super(PrivateMessage, self).__init__(sender) 358 | self.recipient = recipient 359 | self.message = message 360 | 361 | def encode(self): 362 | return super(PrivateMessage, self).encode(self.recipient, self.message) 363 | 364 | 365 | # Section 3.3.2 366 | class Notice(Command): 367 | __slots__ = ('recipient', 'message') 368 | 369 | command = 'NOTICE' 370 | allowed_replies = (ERR_NORECIPIENT, ERR_NOTEXTTOSEND, ERR_CANNOTSENDTOCHAN, ERR_NOTOPLEVEL, 371 | ERR_WILDTOPLEVEL, ERR_TOOMANYTARGETS, ERR_NOSUCHNICK) 372 | 373 | def __init__(self, sender, recipient, message): 374 | super(Notice, self).__init__(sender) 375 | self.recipient = recipient 376 | self.message = message 377 | 378 | @classmethod 379 | def decode(cls, sender, *params): 380 | recipient, message = params 381 | if message.startswith('\x01'): 382 | return CTCPMessage(sender, recipient, message[1:-1]) 383 | else: 384 | return Notice(sender, *params) 385 | 386 | def encode(self): 387 | return super(Notice, self).encode(self.recipient, self.message) 388 | 389 | 390 | class CTCPMessage(IRCEvent): 391 | """ 392 | Represents a client-to-client protocol message. 393 | 394 | :ivar str recipient: 395 | """ 396 | 397 | def __init__(self, sender, recipient, message): 398 | super(CTCPMessage, self).__init__(sender) 399 | self.recipient = recipient 400 | self.message = message 401 | 402 | def encode(self): 403 | return super(CTCPMessage, self).encode(self.recipient, '\x01' + self.message + '\x01') 404 | 405 | 406 | # Section 3.4.1 407 | class Motd(Command): 408 | __slots__ = ('target',) 409 | 410 | command = 'MOTD' 411 | allowed_replies = (RPL_MOTDSTART, RPL_MOTD, RPL_ENDOFMOTD, ERR_NOMOTD) 412 | 413 | def __init__(self, sender, target=None): 414 | super(Motd, self).__init__(sender) 415 | self.target = target 416 | 417 | def encode(self): 418 | return super(Motd, self).encode(self.target) 419 | 420 | 421 | # Section 3.4.2 422 | class Lusers(Command): 423 | __slots__ = ('mask', 'target') 424 | 425 | command = 'LUSERS' 426 | allowed_replies = (RPL_LUSERCLIENT, RPL_LUSEROP, RPL_LUSERUNKNOWN, RPL_LUSERCHANNELS, 427 | RPL_LUSERME, ERR_NOSUCHSERVER) 428 | 429 | def __init__(self, sender, mask=None, target=None): 430 | super(Lusers, self).__init__(sender) 431 | self.mask = mask 432 | self.target = target 433 | 434 | def encode(self): 435 | return super(Lusers, self).encode(self.mask, self.target) 436 | 437 | 438 | # Section 3.4.3 439 | class Version(Command): 440 | __slots__ = ('target',) 441 | 442 | command = 'VERSION' 443 | allowed_replies = (ERR_NOSUCHSERVER, RPL_VERSION) 444 | 445 | def __init__(self, sender, target=None): 446 | super(Version, self).__init__(sender) 447 | self.target = target 448 | 449 | def encode(self): 450 | return super(Version, self).encode(self.target) 451 | 452 | 453 | # Section 3.4.4 454 | class Stats(Command): 455 | __slots__ = ('query', 'target') 456 | 457 | command = 'STATS' 458 | allowed_replies = (ERR_NOSUCHSERVER, RPL_STATSLINKINFO, RPL_STATSUPTIME, RPL_STATSCOMMANDS, 459 | RPL_STATSOLINE, RPL_ENDOFSTATS) 460 | 461 | def __init__(self, sender, query=None, target=None): 462 | super(Stats, self).__init__(sender) 463 | self.query = query 464 | self.target = target 465 | 466 | def encode(self): 467 | return super(Stats, self).encode(self.query, self.target) 468 | 469 | 470 | # Section 3.4.5 471 | class Links(Command): 472 | __slots__ = ('remote_server', 'server_mask') 473 | 474 | command = 'LINKS' 475 | allowed_replies = (ERR_NOSUCHSERVER, RPL_STATSLINKINFO, RPL_STATSUPTIME, RPL_STATSCOMMANDS, 476 | RPL_STATSOLINE, RPL_ENDOFSTATS) 477 | 478 | def __init__(self, sender, remote_server=None, server_mask=None): 479 | super(Links, self).__init__(sender) 480 | self.remote_server = remote_server 481 | self.server_mask = server_mask 482 | 483 | def encode(self): 484 | return super(Links, self).encode(self.remote_server, self.server_mask) 485 | 486 | 487 | # Section 3.4.6 488 | class Time(Command): 489 | __slots__ = ('target',) 490 | 491 | command = 'TIME' 492 | allowed_replies = (ERR_NOSUCHSERVER, RPL_TIME) 493 | 494 | def __init__(self, sender, target=None): 495 | super(Time, self).__init__(sender) 496 | self.target = target 497 | 498 | def encode(self): 499 | return super(Time, self).encode(self.target) 500 | 501 | 502 | # Section 3.4.7 503 | class Connect(Command): 504 | __slots__ = ('target_server', 'port', 'remote_server') 505 | 506 | command = 'CONNECT' 507 | privileged = True 508 | allowed_replies = (ERR_NOSUCHSERVER, ERR_NOPRIVILEGES, ERR_NEEDMOREPARAMS) 509 | 510 | def __init__(self, sender, target_server, port, remote_server=None): 511 | super(Connect, self).__init__(sender) 512 | self.target_server = target_server 513 | self.port = int(port) 514 | self.remote_server = remote_server 515 | 516 | def encode(self): 517 | return super(Connect, self).encode(self.target_server, self.port, self.remote_server) 518 | 519 | 520 | # Section 3.4.8 521 | class Trace(Command): 522 | __slots__ = ('target',) 523 | 524 | command = 'TRACE' 525 | allowed_replies = (ERR_NOSUCHSERVER, RPL_TRACELINK, RPL_TRACECONNECTING, RPL_TRACEHANDSHAKE, 526 | RPL_TRACEUNKNOWN, RPL_TRACEOPERATOR, RPL_TRACEUSER, RPL_TRACESERVER, 527 | RPL_TRACESERVICE, RPL_TRACENEWTYPE, RPL_TRACECLASS, RPL_TRACELOG, 528 | RPL_TRACEEND) 529 | 530 | def __init__(self, sender, target=None): 531 | super(Trace, self).__init__(sender) 532 | self.target = target 533 | 534 | def encode(self): 535 | return super(Trace, self).encode(self.target) 536 | 537 | 538 | # Section 3.4.9 539 | class Admin(Command): 540 | __slots__ = ('target',) 541 | 542 | command = 'ADMIN' 543 | allowed_replies = (ERR_NOSUCHSERVER, RPL_ADMINME, RPL_ADMINLOC1, RPL_ADMINLOC2, RPL_ADMINEMAIL) 544 | 545 | def __init__(self, sender, target=None): 546 | super(Admin, self).__init__(sender) 547 | self.target = target 548 | 549 | def encode(self): 550 | return super(Admin, self).encode(self.target) 551 | 552 | 553 | # Section 3.4.10 554 | class Info(Command): 555 | __slots__ = ('target',) 556 | 557 | command = 'INFO' 558 | allowed_replies = (ERR_NOSUCHSERVER, RPL_INFO, RPL_ENDOFINFO) 559 | 560 | def __init__(self, sender, target=None): 561 | super(Info, self).__init__(sender) 562 | self.target = target 563 | 564 | def encode(self): 565 | return super(Info, self).encode(self.target) 566 | 567 | 568 | # Section 3.7.1 569 | class Kill(Command): 570 | __slots__ = ('nickname', 'comment') 571 | 572 | command = 'KILL' 573 | privileged = True 574 | allowed_replies = (ERR_NOPRIVILEGES, ERR_NEEDMOREPARAMS, ERR_NOSUCHNICK, ERR_CANTKILLSERVER) 575 | 576 | def __init__(self, sender, nickname, comment): 577 | super(Kill, self).__init__(sender) 578 | self.nickname = nickname 579 | self.comment = comment 580 | 581 | def encode(self): 582 | return super(Kill, self).encode(self.nickname, self.comment) 583 | 584 | 585 | # Section 3.7.2 586 | class Ping(Command): 587 | __slots__ = ('server1', 'server2') 588 | 589 | command = 'PING' 590 | allowed_replies = (ERR_NOORIGIN, ERR_NOSUCHSERVER) 591 | 592 | def __init__(self, sender, server1, server2=None): 593 | super(Ping, self).__init__(sender) 594 | self.server1 = server1 595 | self.server2 = server2 596 | 597 | def encode(self): 598 | return super(Ping, self).encode(self.server1, self.server2) 599 | 600 | 601 | # Section 3.7.3 602 | class Pong(Command): 603 | __slots__ = ('server1', 'server2') 604 | 605 | command = 'PONG' 606 | allowed_replies = (ERR_NOORIGIN, ERR_NOSUCHSERVER) 607 | 608 | def __init__(self, sender, server1, server2=None): 609 | super(Pong, self).__init__(sender) 610 | self.server1 = server1 611 | self.server2 = server2 612 | 613 | def encode(self): 614 | return super(Pong, self).encode(self.server1, self.server2) 615 | 616 | 617 | # Section 3.7.4 618 | class Error(Command): 619 | __slots__ = ('message',) 620 | 621 | command = 'ERROR' 622 | 623 | def __init__(self, sender, message): 624 | super(Error, self).__init__(sender) 625 | self.message = message 626 | 627 | def encode(self): 628 | return super(Error, self).encode(self.message) 629 | 630 | 631 | # Section 4.1 632 | class Away(Command): 633 | __slots__ = ('text',) 634 | 635 | command = 'AWAY' 636 | allowed_replies = (RPL_UNAWAY, RPL_NOWAWAY) 637 | 638 | def __init__(self, sender, text=None): 639 | super(Away, self).__init__(sender) 640 | self.text = text 641 | 642 | def encode(self): 643 | return super(Away, self).encode(self.text) 644 | 645 | 646 | # Section 4.2 647 | class Rehash(Command): 648 | __slots__ = () 649 | 650 | command = 'REHASH' 651 | privileged = True 652 | allowed_replies = (RPL_REHASHING, ERR_NOPRIVILEGES) 653 | 654 | 655 | # Section 4.3 656 | class Die(Command): 657 | __slots__ = () 658 | 659 | command = 'DIE' 660 | privileged = True 661 | allowed_replies = (ERR_NOPRIVILEGES,) 662 | 663 | 664 | # Section 4.3 665 | class Restart(Command): 666 | __slots__ = () 667 | 668 | command = 'RESTART' 669 | privileged = True 670 | allowed_replies = (ERR_NOPRIVILEGES,) 671 | 672 | 673 | # Section 4.5 674 | class Summon(Command): 675 | __slots__ = ('user', 'target', 'channel') 676 | 677 | command = 'SUMMON' 678 | allowed_replies = (ERR_NORECIPIENT, ERR_FILEERROR, ERR_NOLOGIN, ERR_NOSUCHSERVER, 679 | ERR_SUMMONDISABLED, RPL_SUMMONING) 680 | 681 | def __init__(self, sender, user, target=None, channel=None): 682 | super(Summon, self).__init__(sender) 683 | self.user = user 684 | self.target = target 685 | self.channel = channel 686 | 687 | def encode(self): 688 | return super(Summon, self).encode(self.user, self.target, self.channel) 689 | 690 | 691 | # Section 4.6 692 | class Users(Command): 693 | __slots__ = ('target',) 694 | 695 | command = 'USERS' 696 | allowed_replies = (ERR_NORECIPIENT, ERR_FILEERROR, ERR_NOLOGIN, ERR_NOSUCHSERVER, 697 | ERR_SUMMONDISABLED, RPL_SUMMONING) 698 | 699 | def __init__(self, sender, target=None): 700 | super(Users, self).__init__(sender) 701 | self.target = target 702 | 703 | def encode(self): 704 | return super(Users, self).encode(self.target) 705 | 706 | 707 | # Section 4.7 708 | class Operwall(Command): 709 | __slots__ = ('text',) 710 | 711 | command = 'WALLOPS' 712 | allowed_replies = (ERR_NEEDMOREPARAMS,) 713 | 714 | def __init__(self, sender, text=None): 715 | super(Operwall, self).__init__(sender) 716 | self.text = text 717 | 718 | def encode(self): 719 | return super(Operwall, self).encode(self.text) 720 | 721 | 722 | # Section 4.8 723 | class Userhost(Command): 724 | __slots__ = ('nicknames',) 725 | 726 | command = 'USERHOST' 727 | allowed_replies = (RPL_USERHOST, ERR_NEEDMOREPARAMS) 728 | 729 | def __init__(self, sender, nickname, *nicknames): 730 | super(Userhost, self).__init__(sender) 731 | self.nicknames = (nickname,) + nicknames 732 | 733 | def encode(self): 734 | return super(Userhost, self).encode(*self.nicknames) 735 | 736 | 737 | # Section 4.9 738 | class Ison(Command): 739 | __slots__ = ('nicknames',) 740 | 741 | command = 'ISON' 742 | allowed_replies = (RPL_ISON, ERR_NEEDMOREPARAMS) 743 | 744 | def __init__(self, sender, nickname, *nicknames): 745 | super(Ison, self).__init__(sender) 746 | self.nicknames = (nickname,) + nicknames 747 | 748 | def encode(self): 749 | return super(Ison, self).encode(*self.nicknames) 750 | 751 | 752 | commands = {cls.command: cls for cls in locals().values() # type: ignore 753 | if isinstance(cls, type) and issubclass(cls, Command)} 754 | 755 | 756 | def decode_event(buffer, decoder=codecs.getdecoder('utf-8'), 757 | fallback_decoder=codecs.getdecoder('iso-8859-1')): 758 | end_index = buffer.find(b'\r\n') 759 | if end_index == -1: 760 | return None 761 | elif end_index > 510: 762 | # Section 2.3 763 | raise ProtocolError('received oversized message (%d bytes)' % (end_index + 2)) 764 | 765 | try: 766 | message = decoder(buffer[:end_index])[0] 767 | except UnicodeDecodeError: 768 | message = fallback_decoder(buffer[:end_index], 'replace')[0] 769 | 770 | del buffer[:end_index + 2] 771 | 772 | if message[0] == ':': 773 | prefix, _, rest = message[1:].partition(' ') 774 | command, _, rest = rest.partition(' ') 775 | else: 776 | prefix = None 777 | command, _, rest = message.partition(' ') 778 | 779 | if command.isdigit(): 780 | return Reply(prefix, command, rest) 781 | 782 | try: 783 | command_class = commands[command] 784 | except KeyError: 785 | raise UnknownCommand(command) 786 | 787 | params = [] 788 | if rest: 789 | parts = rest.split(' ') 790 | for i, param in enumerate(parts): 791 | if param.startswith(':'): 792 | param = param[1:] 793 | if parts[i + 1:]: 794 | param += ' ' + ' '.join(parts[i + 1:]) 795 | 796 | params.append(param) 797 | break 798 | elif param: 799 | params.append(param) 800 | 801 | return command_class.decode(prefix, *params) 802 | -------------------------------------------------------------------------------- /ircproto/exceptions.py: -------------------------------------------------------------------------------- 1 | class ProtocolError(Exception): 2 | """Raised by the state machine when the IRC protocol is being violated.""" 3 | 4 | def __init__(self, message): 5 | super(ProtocolError, self).__init__(u'IRC protocol violation: %s' % message) 6 | 7 | 8 | class UnknownCommand(ProtocolError): 9 | """Raised by the state machine when an unrecognized command has been received.""" 10 | 11 | def __init__(self, command): 12 | super(UnknownCommand, self).__init__(u'unknown command: %s' % command) 13 | -------------------------------------------------------------------------------- /ircproto/replies.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from ircproto.constants import * 4 | 5 | reply_templates = { 6 | RPL_WELCOME: "Welcome to the Internet Relay Network {nickname}!{username}@{host}", 7 | RPL_YOURHOST: "Your host is {host}, running version {version}", 8 | RPL_CREATED: "This server was created {date}", 9 | RPL_MYINFO: "{servername} {version} {available_user_modes} {available_channel_modes}", 10 | RPL_BOUNCE: "Try server {server_name}, port {port_number}", 11 | RPL_USERHOST: None, 12 | RPL_ISON: None, 13 | RPL_AWAY: "{nick} :{away_message}", 14 | RPL_UNAWAY: ":You are no longer marked as being away", 15 | RPL_NOWAWAY: ":You have been marked as being away", 16 | RPL_WHOISUSER: "{nick} {user} {host} * :{real_name}", 17 | RPL_WHOISSERVER: "{nick} {server} :{server_info}", 18 | RPL_WHOISOPERATOR: "{nick} :is an IRC operator", 19 | RPL_WHOISSIDLE: "{nick} {integer} :seconds idle", 20 | RPL_ENDOFWHOIS: "{nick} :End of WHOIS list", 21 | RPL_WHOISCHANNELS: None, 22 | RPL_WHOWASUSER: "{nick} {user} {host} * :{real_name}", 23 | RPL_ENDOFWHOWAS: "{nick} :End of WHOWAS", 24 | RPL_LIST: "{channel} {visible} :{topic}", 25 | RPL_LISTEND: ":End of LIST", 26 | RPL_UNIQOPIS: "{channel} {nickname}", 27 | RPL_CHANNELMODEIS: "{channel} {mode} {mode_params}", 28 | RPL_NOTOPIC: "{channel} :No topic is set", 29 | RPL_TOPIC: "{channel} :{topic}", 30 | RPL_INVITING: "{channel} {nick}", 31 | RPL_SUMMONING: "{user} :Summoning user to IRC", 32 | RPL_INVITELIST: "{channel} {invitemask}", 33 | RPL_ENDOFINVITELIST: "{channel} :End of channel invite list", 34 | RPL_EXCEPTLIST: "{channel} {exceptionmask}", 35 | RPL_ENDOFEXCEPTLIST: "{channel} :End of channel exception list", 36 | RPL_VERSION: "{version}.{debuglevel} {server} :{comments}", 37 | RPL_WHOREPLY: "{channel} {user} {host} {server} {nick} {flags} :{hop_count} {real_name}", 38 | RPL_ENDOFWHO: "{name} :End of WHO list", 39 | RPL_NAMREPLY: None, 40 | RPL_ENDOFNAMES: "{channel} :End of NAMES list", 41 | RPL_LINKS: "{mask} {server} :{hopcount} {server_info}", 42 | RPL_ENDOFLINKS: "{mask} :End of LINKS list", 43 | RPL_BANLIST: "{channel} {banmask}", 44 | RPL_ENDOFBANLIST: "{channel} :End of channel ban list", 45 | RPL_INFO: ":{string}", 46 | RPL_ENDOFINFO: ":End of INFO list", 47 | RPL_MOTDSTART: ":- {server} Message of the day - ", 48 | RPL_MOTD: ":- {text}", 49 | RPL_ENDOFMOTD: ":End of MOTD command", 50 | RPL_YOUREOPER: ":You are now an IRC operator", 51 | RPL_REHASHING: "{config_file} :Rehashing", 52 | RPL_YOURESERVICE: "You are service {servicename}", 53 | RPL_TIME: "{server} :{string showing server's local time}", 54 | RPL_USERSSTART: ":UserID Terminal Host", 55 | RPL_USERS: ":{username} {ttyline} {hostname}", 56 | RPL_ENDOFUSERS: ":End of users", 57 | RPL_NOUSERS: ":Nobody logged in", 58 | RPL_TRACELINK: ("Link {version_debug_level} {destination} {next_server} V{protocol_version} " 59 | "{link uptime in seconds} {backstream_sendq} {upstream_sendq}"), 60 | RPL_TRACECONNECTING: "Try. {class} {server}", 61 | RPL_TRACEHANDSHAKE: "H.S. {class} {server}", 62 | RPL_TRACEUNKNOWN: "???? {class} [{client IP address in dot form}]", 63 | RPL_TRACEOPERATOR: "Oper {class} {nick}", 64 | RPL_TRACEUSER: "User {class} {nick}", 65 | RPL_TRACESERVER: ("Serv {class} {int}S {int}C {server} {nick!user|*!*}@{host|server} " 66 | "V{protocol_version}"), 67 | RPL_TRACESERVICE: "Service {class} {name} {type} {active_type}", 68 | RPL_TRACENEWTYPE: "{newtype} 0 {client_name}", 69 | RPL_TRACECLASS: "Class {class} {count}", 70 | RPL_TRACELOG: "File {logfile} {debug_level}", 71 | RPL_TRACEEND: "{server_name} {version & debug level} :End of TRACE", 72 | RPL_STATSLINKINFO: ("{linkname} {sendq} {sent_messages} {sent_kbytes} {received_messages} " 73 | "{received_kbytes} {time_open}"), 74 | RPL_STATSCOMMANDS: "{command} {count} {byte_count} {remote_count}", 75 | RPL_ENDOFSTATS: "{stats_letter} :End of STATS report", 76 | RPL_STATSUPTIME: ":Server Up %d days %d:%02d:%02d", 77 | RPL_STATSOLINE: "O {hostmask} * {name}", 78 | RPL_UMODEIS: "{user_mode_string}", 79 | RPL_SERVLIST: "{name} {server} {mask} {type} {hopcount} {info}", 80 | RPL_SERVLISTEND: "{mask} {type} :End of service listing", 81 | RPL_LUSERCLIENT: ":There are {integer} users and {integer} services on {integer} servers", 82 | RPL_LUSEROP: "{integer} :operator(s) online", 83 | RPL_LUSERUNKNOWN: "{integer} :unknown connection(s)", 84 | RPL_LUSERCHANNELS: "{integer} :channels formed", 85 | RPL_LUSERME: ":I have {integer} clients and {integer} servers", 86 | RPL_ADMINME: "{server} :Administrative info", 87 | RPL_ADMINLOC1: ":{admin_info}", 88 | RPL_ADMINLOC2: ":{admin_info}", 89 | RPL_ADMINEMAIL: ":{admin_info}", 90 | RPL_TRYAGAIN: "{command} :Please wait a while and try again.", 91 | ERR_NOSUCHNICK: "{nickname} :No such nick/channel", 92 | ERR_NOSUCHSERVER: "{server_name} :No such server", 93 | ERR_NOSUCHCHANNEL: "{channel_name} :No such channel", 94 | ERR_CANNOTSENDTOCHAN: "{channel_name} :Cannot send to channel", 95 | ERR_TOOMANYCHANNELS: "{channel_name} :You have joined too many channels", 96 | ERR_WASNOSUCHNICK: "{nickname} :There was no such nickname", 97 | ERR_TOOMANYTARGETS: "{target} :{code} recipients. {message}", 98 | ERR_NOSUCHSERVICE: "{service_name} :No such service", 99 | ERR_NOORIGIN: ":No origin specified", 100 | ERR_NORECIPIENT: ":No recipient given ({command})", 101 | ERR_NOTEXTTOSEND: ":No text to send", 102 | ERR_NOTOPLEVEL: "{mask} :No toplevel domain specified", 103 | ERR_WILDTOPLEVEL: "{mask} :Wildcard in toplevel domain", 104 | ERR_BADMASK: "{mask} :Bad Server/host mask", 105 | ERR_UNKNOWNCOMMAND: "{command} :Unknown command", 106 | ERR_NOMOTD: ":MOTD File is missing", 107 | ERR_NOADMININFO: "{server} :No administrative info available", 108 | ERR_FILEERROR: ":File error doing {file_op} on {file}", 109 | ERR_NONICKNAMEGIVEN: ":No nickname given", 110 | ERR_ERRONEUSNICKNAME: "{nick} :Erroneous nickname", 111 | ERR_NICKNAMEINUSE: "{nick} :Nickname is already in use", 112 | ERR_NICKCOLLISION: "{nick} :Nickname collision KILL from {user@{host}", 113 | ERR_UNAVAILRESOURCE: "{nick/channel} :Nick/channel is temporarily unavailable", 114 | ERR_USERNOTINCHANNEL: "{nick} {channel} :They aren't on that channel", 115 | ERR_NOTONCHANNEL: "{channel} :You're not on that channel", 116 | ERR_USERONCHANNEL: "{user} {channel} :is already on channel", 117 | ERR_NOLOGIN: "{user} :User not logged in", 118 | ERR_SUMMONDISABLED: ":SUMMON has been disabled", 119 | ERR_USERSDISABLED: ":USERS has been disabled", 120 | ERR_NOTREGISTERED: ":You have not registered", 121 | ERR_NEEDMOREPARAMS: "{command} :Not enough parameters", 122 | ERR_ALREADYREGISTRED: ":Unauthorized command (already registered)", 123 | ERR_NOPERMFORHOST: ":Your host isn't among the privileged", 124 | ERR_PASSWDMISMATCH: ":Password incorrect", 125 | ERR_YOUREBANNEDCREEP: ":You are banned from this server", 126 | ERR_YOUWILLBEBANNED: "", 127 | ERR_KEYSET: "{channel} :Channel key already set", 128 | ERR_CHANNELISFULL: "{channel} :Cannot join channel (+l)", 129 | ERR_UNKNOWNMODE: "{char} :is unknown mode char to me for {channel}", 130 | ERR_INVITEONLYCHAN: "{channel} :Cannot join channel (+i)", 131 | ERR_BANNEDFROMCHAN: "{channel} :Cannot join channel (+b)", 132 | ERR_BADCHANNELKEY: "{channel} :Cannot join channel (+k)", 133 | ERR_BADCHANMASK: "{channel} :Bad Channel Mask", 134 | ERR_NOCHANMODES: "{channel} :Channel doesn't support modes", 135 | ERR_BANLISTFULL: "{channel} {char} :Channel list is full", 136 | ERR_NOPRIVILEGES: ":Permission Denied- You're not an IRC operator", 137 | ERR_CHANOPRIVSNEEDED: "{channel} :You're not channel operator", 138 | ERR_CANTKILLSERVER: ":You can't kill a server!", 139 | ERR_RESTRICTED: ":Your connection is restricted!", 140 | ERR_UNIQOPPRIVSNEEDED: ":You're not the original channel operator", 141 | ERR_NOOPERHOST: ":No O-lines for your host", 142 | ERR_UMODEUNKNOWNFLAG: ":Unknown MODE flag", 143 | ERR_USERSDONTMATCH: ":Cannot change mode for other users" 144 | } 145 | -------------------------------------------------------------------------------- /ircproto/states.py: -------------------------------------------------------------------------------- 1 | from ircproto.constants import * 2 | from ircproto.utils import match_hostmask 3 | 4 | 5 | class IRCChannel(object): 6 | """ 7 | Represents the state of an IRC channel. 8 | 9 | :ivar str name: name of the channel 10 | :ivar str topic: current topic 11 | :ivar str key: current channel key 12 | :ivar int limit: current channel limit (maximum number of users) 13 | :ivar list users: list of client connections who are currently on this channel 14 | :ivar list bans: list of hostmasks (matching clients are prohibited from joining) 15 | :ivar list invites: list of nicknames who are invited to join the channel 16 | """ 17 | 18 | __slots__ = ('name', 'modes', 'topic', 'key', 'limit', 'users', 'bans', 'invites') 19 | 20 | def __init__(self, name, modes): 21 | self.name = name 22 | self.modes = modes 23 | self.topic = self.key = self.limit = None 24 | self.bans = [] 25 | self.invites = [] 26 | self.users = [] 27 | 28 | 29 | class IRCServer(object): 30 | """ 31 | Represents the state of an IRC server. 32 | 33 | :ivar str host: host name of the server 34 | :ivar dict clients: list of all client connections 35 | :ivar dict channels: dictionary of channel names to :class:`.IRCChannel` instances 36 | :ivar dict nicknames: dictionary of nicknames to client connections 37 | """ 38 | 39 | __slots__ = ('host', 'default_channel_modes', 'clients', 'servers', 'channels', 'nicknames') 40 | 41 | def __init__(self, host, default_channel_modes='nt'): 42 | self.host = host 43 | self.default_channel_modes = default_channel_modes 44 | self.clients = [] 45 | self.servers = [] 46 | self.channels = {} 47 | self.nicknames = {} 48 | 49 | def add_client_connection(self, connection): 50 | self.clients[connection.nickname] = connection 51 | 52 | def add_server_connection(self, connection): 53 | self.servers[connection.host] = connection 54 | 55 | def handle_join(self, connection, event): 56 | channel_name = event.channel 57 | channel = self.channels.get(channel_name) 58 | if not channel: 59 | channel = self.channels[channel_name] = IRCChannel(channel_name, 60 | self.default_channel_modes) 61 | else: 62 | if channel.limit and len(channel.users) >= channel.limit: 63 | connection.process_reply(ERR_CHANNELISFULL, channel=channel_name) 64 | return 65 | elif any(match_hostmask(connection, mask) for mask in channel.bans): 66 | connection.process_reply(ERR_BANNEDFROMCHAN, channel=channel_name) 67 | return 68 | elif 'i' in channel.modes and connection.nickname not in channel.invites: 69 | connection.process_reply(ERR_INVITEONLYCHAN, channel=channel_name) 70 | return 71 | 72 | channel.users.append(connection) 73 | connection.process_reply(RPL_TOPIC, channel.topic) 74 | for conn in (channel.users + self.servers): 75 | conn.reply() 76 | -------------------------------------------------------------------------------- /ircproto/styles.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import re 4 | from enum import Enum 5 | 6 | 7 | class IRCTextColor(Enum): 8 | """ 9 | Enumeration of colors usable with :func:`styled`. 10 | 11 | Available values: 12 | * white 13 | * black 14 | * navy 15 | * green 16 | * red 17 | * maroon 18 | * purple 19 | * olive 20 | * yellow 21 | * lightgreen 22 | * teal 23 | * cyan 24 | * royalblue 25 | * magenta 26 | * gray 27 | * lightgray 28 | """ 29 | 30 | white = 0 31 | black = 1 32 | navy = 2 33 | green = 3 34 | red = 4 35 | maroon = 5 36 | purple = 6 37 | olive = 7 38 | yellow = 8 39 | lightgreen = 9 40 | teal = 10 41 | cyan = 11 42 | royalblue = 12 43 | magenta = 13 44 | gray = 14 45 | lightgray = 15 46 | 47 | 48 | class IRCTextStyle(Enum): 49 | """ 50 | Enumeration of text styles usable with :func:`styled`. 51 | 52 | Available values: 53 | * bold 54 | * italic 55 | * underline 56 | * reverse 57 | * plain 58 | """ 59 | 60 | bold = '\x02' 61 | italic = '\x1d' 62 | underline = '\x1f' 63 | reverse = '\x16' 64 | plain = '\x0f' 65 | 66 | 67 | styles_re = re.compile('(\x03\d+(?:,\d+)?)|[\x02\x03\x1d\x1f\x16\x0f]') 68 | 69 | 70 | def styled(text, foreground=None, background=None, styles=None): 71 | """ 72 | Apply mIRC compatible colors and styles to the given text. 73 | 74 | :param text: the text to be styled 75 | :param foreground: the foreground color 76 | :param background: the background color (only works if foreground is defined too) 77 | :param styles: a text style or iterable of text styles to apply 78 | 79 | """ 80 | # Apply coloring 81 | if foreground and not background: 82 | text = '\x03%d%s\x03' % (foreground.value, text) 83 | elif foreground and background: 84 | text = '\x03%d,%d%s\x03' % (foreground.value, background.value, text) 85 | 86 | # Apply text styles 87 | if styles: 88 | if isinstance(styles, IRCTextStyle): 89 | text = styles.value + text 90 | else: 91 | text = ''.join(style.value for style in styles) + text 92 | 93 | text += IRCTextStyle.plain.value # reset to default at the end 94 | 95 | return text 96 | 97 | 98 | def strip_styles(text): 99 | """ 100 | Remove all mIRC compatible styles and coloring from the given text. 101 | 102 | :param str text: the text to be sanitized 103 | :return: input text with styles removed 104 | 105 | """ 106 | return styles_re.sub('', text) 107 | -------------------------------------------------------------------------------- /ircproto/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from ircproto.exceptions import ProtocolError 4 | 5 | nickname_re = re.compile(b'[a-zA-Z[\]\\\\`_^{|}][a-zA-Z[\]\\\\`_^{|}0-9-]{0,8}$') 6 | channel_re = re.compile(b'([#+&]|![A-Z0-9]{5})[^\x00\x0b\r\n ,:]+$') 7 | hostmask_re = re.compile(b'(?:[^\x00?*]|[^\x00\\\\]\\?|\\*)+') 8 | 9 | 10 | def validate_channel_name(name): 11 | """ 12 | Ensure that a channel name conforms to the restrictions of RFC 2818. 13 | 14 | :param bytes name: the channel name to validate 15 | :raises ..exceptions.ProtocolError: if the channel name is invalid 16 | 17 | """ 18 | if not channel_re.match(name): 19 | raise ProtocolError(u'invalid channel name: %s' % name.decode('ascii', 20 | errors='backslashreplace')) 21 | 22 | 23 | def validate_nickname(name): 24 | """ 25 | Ensure that a nickname conforms to the restrictions of RFC 2818. 26 | 27 | :param bytes name: the nickname to validate 28 | :raises ..exceptions.ProtocolError: if the nickname is invalid 29 | 30 | """ 31 | if not nickname_re.match(name): 32 | raise ProtocolError(u'invalid nickname: %s' % name.decode('ascii', 33 | errors='backslashreplace')) 34 | 35 | 36 | def validate_hostmask(mask): 37 | """ 38 | Ensure that a host mask conforms to the restrictions of RFC 2818. 39 | 40 | :param bytes mask: the mask to validate 41 | :raises ..exceptions.ProtocolError: if the host mask is invalid 42 | 43 | """ 44 | if not hostmask_re.match(mask): 45 | raise ProtocolError(u'invalid host mask: %s' % mask.decode('ascii', 46 | errors='backslashreplace')) 47 | 48 | 49 | def match_hostmask(prefix, mask): 50 | """ 51 | Match a prefix against a hostmask. 52 | 53 | :param bytes prefix: prefix to match the mask against 54 | :param bytes mask: a mask that may contain wildcards like ``*`` or ``?`` 55 | :return: ``True`` if the prefix matches the mask, ``False`` otherwise 56 | 57 | """ 58 | prefix_index = mask_index = 0 59 | escape = False 60 | while prefix_index < len(prefix) and mask_index < len(mask): 61 | mask_char = mask[mask_index] 62 | prefix_char = prefix[prefix_index] 63 | if mask[mask_index] == b'\\': 64 | escape = True 65 | mask_index += 1 66 | mask_char = mask[mask_index] 67 | 68 | prefix_index += 1 69 | mask_index += 1 70 | if escape or mask_char not in b'?*': 71 | if mask_char != prefix_char: 72 | return False 73 | elif mask_char == b'?': 74 | pass 75 | elif mask_char == b'*': 76 | if mask_index < len(mask): 77 | mask_char = mask[mask_index] 78 | prefix_index = prefix.find(mask_char, prefix_index) 79 | if prefix_index == -1: 80 | return False 81 | else: 82 | break 83 | 84 | return True 85 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [build_sphinx] 2 | source-dir = docs 3 | build-dir = docs/_build 4 | 5 | [upload_docs] 6 | upload-dir = docs/_build/html 7 | 8 | [tool:pytest] 9 | addopts = -rsx --cov --tb=short 10 | testpaths = tests 11 | 12 | [flake8] 13 | max-line-length = 99 14 | ignore = F403,F405 15 | exclude = .tox,build,docs 16 | 17 | [bdist_wheel] 18 | universal = 1 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import os.path 3 | 4 | from setuptools import setup, find_packages 5 | 6 | 7 | here = os.path.dirname(__file__) 8 | readme_path = os.path.join(here, 'README.rst') 9 | readme = open(readme_path).read() 10 | 11 | setup( 12 | name='ircproto', 13 | use_scm_version={ 14 | 'version_scheme': 'post-release', 15 | 'local_scheme': 'dirty-tag' 16 | }, 17 | description='IRC state-machine based protocol implementation', 18 | long_description=readme, 19 | author=u'Alex Grönholm', 20 | author_email='alex.gronholm@nextday.fi', 21 | url='https://github.com/agronholm/ircproto', 22 | classifiers=[ 23 | 'Development Status :: 5 - Production/Stable', 24 | 'Intended Audience :: Developers', 25 | 'License :: OSI Approved :: MIT License', 26 | 'Programming Language :: Python', 27 | 'Programming Language :: Python :: 2.7', 28 | 'Programming Language :: Python :: 3', 29 | 'Programming Language :: Python :: 3.3', 30 | 'Programming Language :: Python :: 3.4', 31 | 'Programming Language :: Python :: 3.5' 32 | ], 33 | keywords='irc', 34 | license='MIT', 35 | packages=find_packages(exclude=['tests']), 36 | setup_requires=['setuptools_scm'] 37 | ) 38 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agronholm/ircproto/7993857e0f475f9fe8484bc669906cf1d6add0dd/tests/test_connection.py -------------------------------------------------------------------------------- /tests/test_events.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | 3 | import pytest 4 | 5 | from ircproto.events import decode_event 6 | from ircproto.exceptions import ProtocolError, UnknownCommand 7 | 8 | 9 | def test_decode_event_oversized(): 10 | buffer = bytearray(b':foo!bar@blah PRIVMSG hey' + b'y' * 600 + b'\r\n') 11 | exc = pytest.raises(ProtocolError, decode_event, buffer) 12 | assert str(exc.value) == 'IRC protocol violation: received oversized message (627 bytes)' 13 | 14 | 15 | def test_decode_event_unknown_command(): 16 | buffer = bytearray(b':foo!bar@blah FROBNICATE\r\n') 17 | exc = pytest.raises(UnknownCommand, decode_event, buffer) 18 | assert str(exc.value) == 'IRC protocol violation: unknown command: FROBNICATE' 19 | 20 | 21 | def test_decode_fallback_conversion(): 22 | decoder = codecs.getdecoder('utf-8') 23 | fallback_decoder = codecs.getdecoder('iso-8859-1') 24 | buffer = bytearray(b':foo!bar@blah PRIVMSG hey du\xe2\xa9k\r\n') 25 | assert decode_event(buffer, decoder=decoder, fallback_decoder=fallback_decoder) 26 | -------------------------------------------------------------------------------- /tests/test_styles.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ircproto.styles import styled, IRCTextColor, IRCTextStyle, strip_styles 4 | 5 | 6 | @pytest.mark.parametrize('args, expected', [ 7 | (('test', IRCTextColor.cyan), '\x0311test\x03'), 8 | (('test', IRCTextColor.cyan, IRCTextColor.red), '\x0311,4test\x03'), 9 | (('test', None, None, IRCTextStyle.bold), '\x02test\x0f'), 10 | (('test', None, None, [IRCTextStyle.bold, IRCTextStyle.italic]), '\x02\x1dtest\x0f'), 11 | (('test', IRCTextColor.cyan, IRCTextColor.red, [IRCTextStyle.bold, IRCTextStyle.italic]), 12 | '\x02\x1d\x0311,4test\x03\x0f') 13 | ], ids=['foreground', 'both_colors', 'bold', 'bold_italic', 'colors_styles']) 14 | def test_styled(args, expected): 15 | assert styled(*args) == expected 16 | 17 | 18 | @pytest.mark.parametrize('text, expected', [ 19 | ('abc def \x0311test\x03 xyz', 'abc def test xyz'), 20 | ('\x0311,4blah\x03 text \x02bold', 'blah text bold'), 21 | ('\x0311all colors', 'all colors') 22 | ]) 23 | def test_strip_styles(text, expected): 24 | assert strip_styles(text) == expected 25 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ircproto.exceptions import ProtocolError 4 | from ircproto.utils import validate_nickname 5 | 6 | 7 | @pytest.mark.parametrize('name', [ 8 | b'a', 9 | b'[]\\_^{|}-', 10 | b'nick1' 11 | ]) 12 | def test_validate_nickname(name): 13 | validate_nickname(name) 14 | 15 | 16 | @pytest.mark.parametrize('name', [ 17 | b'', 18 | b'toolongggg', 19 | b'1nick', 20 | b'\x00nick', 21 | b'nick name' 22 | ], ids=['empty', 'toolong', 'starts_with_number', 'starts_with_nul', 'space']) 23 | def test_validate_nickname_invalid(name): 24 | exc = pytest.raises(ProtocolError, validate_nickname, name) 25 | assert str(exc.value) == (u'IRC protocol violation: invalid nickname: %s' % 26 | name.decode('ascii', errors='backslashreplace')) 27 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py33,py34,py35,flake8,mypy 3 | skip_missing_interpreters = true 4 | 5 | [tox:travis] 6 | 2.7 = py27 7 | 3.3 = py33 8 | 3.4 = py34 9 | 3.5 = py35, flake8, mypy 10 | pypy = pypy 11 | 12 | [testenv] 13 | commands = python -m pytest {posargs} 14 | deps = pytest 15 | pytest-cov 16 | {py33,py27,pypy}: enum34 17 | 18 | [testenv:flake8] 19 | basepython = python3.5 20 | deps = flake8 21 | commands = flake8 ircproto tests 22 | skip_install = true 23 | 24 | [testenv:mypy] 25 | basepython = python3.5 26 | deps = mypy-lang 27 | commands = mypy -p ircproto 28 | skip_install = true 29 | --------------------------------------------------------------------------------