diff --git a/irctest/cases.py b/irctest/cases.py index 95df7ef..03201c6 100644 --- a/irctest/cases.py +++ b/irctest/cases.py @@ -1,6 +1,8 @@ import socket import unittest +from .irc_utils import message_parser + class _IrcTestCase(unittest.TestCase): controllerClass = None # Will be set by __main__.py @@ -22,7 +24,9 @@ class ClientTestCase(_IrcTestCase): def acceptClient(self): """Make the server accept a client connection. Blocking.""" (self.conn, addr) = self.server.accept() - self.conn_file = self.conn.makefile() + self.conn_file = self.conn.makefile(newline='\r\n') def getLine(self): return self.conn_file.readline().strip() + def getMessage(self): + return message_parser.parse_message(self.conn_file.readline()) diff --git a/irctest/clienttests/test_cap.py b/irctest/clienttests/test_cap.py index 6d009da..23c24dd 100644 --- a/irctest/clienttests/test_cap.py +++ b/irctest/clienttests/test_cap.py @@ -1,4 +1,5 @@ from irctest.cases import ClientTestCase +from irctest.irc_utils.message_parser import Message class CapTestCase(ClientTestCase): def testSendCap(self): @@ -9,4 +10,9 @@ class CapTestCase(ClientTestCase): authentication=None, ) self.acceptClient() - print(self.getLine()) + m = self.getMessage() + self.assertEqual(m.command, 'CAP', + 'First message is not CAP LS.') + self.assertEqual(m.subcommand, 'LS', + 'First message is not CAP LS.') + self.assertIn(m.params, ([], ['302'])) # IRCv3.1 or IRVv3.2 diff --git a/irctest/irc_utils/__init__.py b/irctest/irc_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/irctest/irc_utils/message_parser.py b/irctest/irc_utils/message_parser.py new file mode 100644 index 0000000..a33e7f8 --- /dev/null +++ b/irctest/irc_utils/message_parser.py @@ -0,0 +1,66 @@ +import re +import collections +import supybot.utils + +# http://ircv3.net/specs/core/message-tags-3.2.html#escaping-values +TAG_ESCAPE = [ + ('\\', '\\\\'), # \ -> \\ + (' ', r'\s'), + (';', r'\:'), + ('\r', r'\r'), + ('\n', r'\n'), + ] +unescape_tag_value = supybot.utils.str.MultipleReplacer( + dict(map(lambda x:(x[1],x[0]), TAG_ESCAPE))) + +# TODO: validate host +tag_key_validator = re.compile('(\S+/)?[a-zA-Z0-9-]+') + +def parse_tags(s): + tags = {} + for tag in s.split(';'): + if '=' not in tag: + tags[tag] = None + else: + (key, value) = tag.split('=', 1) + assert tag_key_validator.match(key), \ + 'Invalid tag key: {}'.format(key) + tags[key] = unescape_tag_value(value) + return tags + +Message = collections.namedtuple('Message', + 'tags prefix command subcommand params') + +def parse_message(s): + """Parse a message according to + http://tools.ietf.org/html/rfc1459#section-2.3.1 + and + http://ircv3.net/specs/core/message-tags-3.2.html""" + assert s.endswith('\r\n'), 'Message does not end with CR LF' + s = s[0:-2] + if ' :' in s: + (other_tokens, trailing_param) = s.split(' :') + tokens = list(filter(bool, other_tokens.split(' '))) + [trailing_param] + else: + tokens = list(filter(bool, s.split(' '))) + if tokens[0].startswith('@'): + tags = parse_tags(tokens.pop(0)) + else: + tags = [] + if tokens[0].startswith(':'): + prefix = tokens.pop(0) + else: + prefix = None + command = tokens.pop(0) + if command == 'CAP': + subcommand = tokens.pop(0) + else: + subcommand = None + params = tokens + return Message( + tags=tags, + prefix=prefix, + command=command, + subcommand=subcommand, + params=params, + )