Skip to content

Commit 416454a

Browse files
committed
Add IRC line parser
1 parent 6b0d6c8 commit 416454a

2 files changed

Lines changed: 305 additions & 0 deletions

File tree

cloudbot/util/parsers/__init__.py

Whitespace-only changes.

cloudbot/util/parsers/irc.py

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
"""
2+
Simple IRC line parser
3+
4+
Backported from async-irc (https://github.com/snoonetIRC/async-irc.git)
5+
"""
6+
7+
import re
8+
from abc import ABC, abstractmethod
9+
from collections import OrderedDict
10+
11+
TAGS_SENTINEL = '@'
12+
TAGS_SEP = ';'
13+
TAG_VALUE_SEP = '='
14+
15+
PREFIX_SENTINEL = ':'
16+
PREFIX_USER_SEP = '!'
17+
PREFIX_HOST_SEP = '@'
18+
19+
PARAM_SEP = ' '
20+
TRAIL_SENTINEL = ':'
21+
22+
CAP_SEP = ' '
23+
CAP_VALUE_SEP = '='
24+
25+
PREFIX_RE = re.compile(r':?(?P<nick>.+?)(?:!(?P<user>.+?))?(?:@(?P<host>.+?))?')
26+
27+
TAG_VALUE_ESCAPES = {
28+
'\\s': ' ',
29+
'\\:': ';',
30+
'\\r': '\r',
31+
'\\n': '\n',
32+
'\\\\': '\\',
33+
}
34+
35+
TAG_VALUE_UNESCAPES = {
36+
unescaped: escaped
37+
for escaped, unescaped in TAG_VALUE_ESCAPES.items()
38+
}
39+
40+
41+
class Parseable(ABC):
42+
"""Abstract class for parseable objects"""
43+
44+
@abstractmethod
45+
def __str__(self):
46+
raise NotImplementedError
47+
48+
@staticmethod
49+
@abstractmethod
50+
def parse(text):
51+
"""Parse the object from a string"""
52+
raise NotImplementedError
53+
54+
55+
class Cap(Parseable):
56+
"""Represents a CAP entity as defined in IRCv3.2"""
57+
58+
def __init__(self, name, value=None):
59+
self.name = name
60+
self.value = value or None
61+
62+
def __str__(self):
63+
if self.value:
64+
return CAP_VALUE_SEP.join((self.name, self.value))
65+
return self.name
66+
67+
def __eq__(self, other):
68+
if isinstance(other, Cap):
69+
return self.name == other.name
70+
71+
return NotImplemented
72+
73+
@staticmethod
74+
def parse(text: str):
75+
"""Parse a CAP entity from a string"""
76+
name, _, value = text.partition(CAP_VALUE_SEP)
77+
return Cap(name, value)
78+
79+
80+
class CapList(Parseable, list):
81+
"""Represents a list of CAP entities"""
82+
83+
def __str__(self):
84+
return CAP_SEP.join(self)
85+
86+
@staticmethod
87+
def parse(text):
88+
"""Parse a list of CAPs from a string"""
89+
return CapList(map(Cap.parse, text.split(CAP_SEP)))
90+
91+
92+
class MessageTag(Parseable):
93+
"""
94+
Basic class to wrap a message tag
95+
"""
96+
97+
def __init__(self, name, value=None):
98+
self.name = name
99+
self.value = value
100+
101+
@staticmethod
102+
def unescape(value):
103+
"""
104+
Replace the escaped characters in a tag value with their literals
105+
:param value: Escaped string
106+
:return: Unescaped string
107+
"""
108+
new_value = ""
109+
found = False
110+
for i in range(len(value)):
111+
if found:
112+
found = False
113+
continue
114+
115+
if value[i] == '\\':
116+
if i + 1 >= len(value):
117+
raise ValueError("Unexpected end of string while parsing: {}".format(value))
118+
119+
new_value += TAG_VALUE_ESCAPES[value[i:i + 2]]
120+
found = True
121+
else:
122+
new_value += value[i]
123+
124+
return new_value
125+
126+
@staticmethod
127+
def escape(value):
128+
"""
129+
Replace characters with their escaped variants
130+
:param value: The raw string
131+
:return: The escaped string
132+
"""
133+
return "".join(TAG_VALUE_UNESCAPES.get(c, c) for c in value)
134+
135+
def __str__(self):
136+
if self.value:
137+
return "{}{}{}".format(
138+
self.name, TAG_VALUE_SEP, self.escape(self.value)
139+
)
140+
141+
return self.name
142+
143+
@staticmethod
144+
def parse(text):
145+
"""
146+
Parse a tag from a string
147+
:param text: The basic tag string
148+
:return: The MessageTag object
149+
"""
150+
name, _, value = text.partition(TAG_VALUE_SEP)
151+
if value:
152+
value = MessageTag.unescape(value)
153+
154+
return MessageTag(name, value or None)
155+
156+
157+
class TagList(Parseable, OrderedDict):
158+
"""Object representing the list of message tags on a line"""
159+
160+
def __init__(self, tags) -> None:
161+
super().__init__((tag.name, tag) for tag in tags)
162+
163+
def __str__(self):
164+
return TAGS_SENTINEL + TAGS_SEP.join(map(str, self.values()))
165+
166+
@staticmethod
167+
def parse(text):
168+
"""
169+
Parse the list of tags from a string
170+
:param text: The string to parse
171+
:return: The parsed object
172+
"""
173+
return TagList(
174+
map(MessageTag.parse, filter(None, text.split(TAGS_SEP)))
175+
)
176+
177+
178+
class Prefix(Parseable):
179+
"""
180+
Object representing the prefix of a line
181+
"""
182+
183+
def __init__(self, nick, user=None, host=None):
184+
self.nick = nick
185+
self.user = user
186+
self.host = host
187+
188+
@property
189+
def mask(self):
190+
"""
191+
The complete n!u@h mask
192+
"""
193+
m = self.nick
194+
if self.user:
195+
m += PREFIX_USER_SEP + self.user
196+
197+
if self.host:
198+
m += PREFIX_HOST_SEP + self.host
199+
200+
return m
201+
202+
def __str__(self):
203+
if self:
204+
return PREFIX_SENTINEL + self.mask
205+
206+
return ""
207+
208+
def __bool__(self):
209+
return bool(self.nick)
210+
211+
@staticmethod
212+
def parse(text):
213+
"""
214+
Parse the prefix from a string
215+
:param text: String to parse
216+
:return: Parsed Object
217+
"""
218+
if not text:
219+
return Prefix('')
220+
221+
match = PREFIX_RE.fullmatch(text)
222+
assert match, "Prefix did not match prefix pattern"
223+
nick, user, host = match.groups()
224+
return Prefix(nick, user, host)
225+
226+
227+
class ParamList(Parseable, list):
228+
"""
229+
An object representing the parameter list from a line
230+
"""
231+
232+
def __init__(self, seq, has_trail=False):
233+
super().__init__(seq)
234+
self.has_trail = has_trail or (self and PARAM_SEP in self[-1])
235+
236+
def __str__(self):
237+
if self.has_trail and self[-1][0] != TRAIL_SENTINEL:
238+
return PARAM_SEP.join(self[:-1] + [TRAIL_SENTINEL + self[-1]])
239+
240+
return PARAM_SEP.join(self)
241+
242+
@staticmethod
243+
def parse(text):
244+
"""
245+
Parse a list of parameters
246+
:param text: The list of parameters
247+
:return: The parsed object
248+
"""
249+
args = []
250+
has_trail = False
251+
while text:
252+
if text[0] == TRAIL_SENTINEL:
253+
args.append(text[1:])
254+
has_trail = True
255+
break
256+
257+
arg, _, text = text.partition(PARAM_SEP)
258+
if arg:
259+
args.append(arg)
260+
261+
return ParamList(args, has_trail=has_trail)
262+
263+
264+
class Message(Parseable):
265+
"""
266+
An object representing a parsed IRC line
267+
"""
268+
269+
def __init__(self, tags=None, prefix=None, command=None, parameters=None):
270+
self.tags = tags
271+
self.prefix = prefix
272+
self.command = command
273+
self.parameters = parameters
274+
275+
@property
276+
def parts(self):
277+
"""The parts that make up this message"""
278+
return self.tags, self.prefix, self.command, self.parameters
279+
280+
def __str__(self):
281+
return PARAM_SEP.join(map(str, filter(None, self.parts)))
282+
283+
def __bool__(self):
284+
return any(self.parts)
285+
286+
@staticmethod
287+
def parse(text):
288+
"""Parse an IRC message in to objects"""
289+
if isinstance(text, bytes):
290+
text = text.decode()
291+
292+
tags = ''
293+
prefix = ''
294+
if text.startswith(TAGS_SENTINEL):
295+
tags, _, text = text.partition(PARAM_SEP)
296+
297+
if text.startswith(PREFIX_SENTINEL):
298+
prefix, _, text = text.partition(PARAM_SEP)
299+
300+
command, _, params = text.partition(PARAM_SEP)
301+
tags = TagList.parse(tags[1:])
302+
prefix = Prefix.parse(prefix[1:])
303+
command = command.upper()
304+
params = ParamList.parse(params)
305+
return Message(tags, prefix, command, params)

0 commit comments

Comments
 (0)