├── .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 |
--------------------------------------------------------------------------------