Skip to content

Commit 3d1242b

Browse files
committed
Add user and channel tracking
1 parent 4ea66d8 commit 3d1242b

2 files changed

Lines changed: 384 additions & 0 deletions

File tree

plugins/chan_track.py

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
"""
2+
Track channel ops for permissions checks
3+
4+
Requires:
5+
server_info.py
6+
"""
7+
import re
8+
import weakref
9+
from collections import defaultdict
10+
from operator import attrgetter
11+
from weakref import WeakValueDictionary
12+
13+
from cloudbot import hook
14+
15+
NUH_RE = re.compile(r'(?P<nick>.+?)(?:!(?P<user>.+?))?(?:@(?P<host>.+?))?')
16+
17+
18+
class WeakDict(dict):
19+
# Subclass dict to allow it to be weakly referenced
20+
pass
21+
22+
23+
class DataDict(defaultdict):
24+
def __init__(self, *args, **kwargs):
25+
super().__init__(*args, **kwargs)
26+
27+
def __contains__(self, item):
28+
return super().__contains__(item.casefold())
29+
30+
def __getitem__(self, item):
31+
return super().__getitem__(item.casefold())
32+
33+
def __setitem__(self, key, value):
34+
super().__setitem__(key.casefold(), value)
35+
36+
def __delitem__(self, key):
37+
super().__delitem__(key.casefold())
38+
39+
def __missing__(self, key):
40+
data = WeakDict({"name": key, "users": {}})
41+
self[key] = data
42+
return data
43+
44+
45+
def update_chan_data(conn, chan):
46+
chan_data = conn.memory["chan_data"][chan]
47+
chan_data["receiving_names"] = False
48+
conn.cmd("NAMES", chan)
49+
50+
51+
def update_conn_data(conn):
52+
for chan in set(conn.channels):
53+
update_chan_data(conn, chan)
54+
55+
56+
@hook.on_cap_available("userhost-in-names", "multi-prefix")
57+
def do_caps():
58+
pass
59+
60+
61+
@hook.on_start
62+
def get_chan_data(bot):
63+
for conn in bot.connections.values():
64+
if conn.connected:
65+
update_conn_data(conn)
66+
67+
68+
@hook.connect
69+
def init_chan_data(conn):
70+
chan_data = conn.memory.setdefault("chan_data", DataDict())
71+
chan_data.clear()
72+
73+
users = conn.memory.setdefault("users", WeakValueDictionary())
74+
users.clear()
75+
76+
77+
def parse_nuh(mask):
78+
match = NUH_RE.fullmatch(mask)
79+
if not match:
80+
return None, None, None
81+
82+
nick = match.group('nick')
83+
user = match.group('user')
84+
host = match.group('host')
85+
return nick, user, host
86+
87+
88+
def replace_user_data(conn, chan_data):
89+
statuses = {status.prefix: status for status in set(conn.memory["server_info"]["statuses"].values())}
90+
users = conn.memory["users"]
91+
old_users = chan_data['users']
92+
new_data = chan_data.pop("new_users", [])
93+
new_users = {}
94+
caps = conn.memory.get("server_caps", {})
95+
has_uh_i_n = caps.get("userhost-in-names", False)
96+
has_multi_pfx = caps.get("multi-prefix", False)
97+
for name in new_data:
98+
user_data = WeakDict()
99+
memb_data = WeakDict({"user": user_data, "chan": weakref.proxy(chan_data)})
100+
user_statuses = []
101+
while name[:1] in statuses:
102+
status, name = name[:1], name[1:]
103+
user_statuses.append(statuses[status])
104+
if not has_multi_pfx:
105+
# Only run once if we don't have multi-prefix enabled
106+
break
107+
108+
user_statuses.sort(key=attrgetter("level"), reverse=True)
109+
# At this point, user_status[0] will the the Status object representing the highest status the user has
110+
# in the channel
111+
memb_data["status"] = user_statuses
112+
113+
if has_uh_i_n:
114+
nick, user, host = parse_nuh(name)
115+
user_data.update({"nick": nick, "ident": user, "host": host})
116+
else:
117+
user_data["nick"] = name
118+
119+
nick_cf = user_data["nick"].casefold()
120+
new_users[nick_cf] = memb_data
121+
if nick_cf in old_users:
122+
old_data = old_users[nick_cf]
123+
old_data.update(memb_data) # New data takes priority over old data
124+
memb_data.update(old_data)
125+
126+
old_user_data = users.setdefault(nick_cf, user_data)
127+
old_user_data.update(user_data)
128+
user_data = old_user_data
129+
memb_data["user"] = user_data
130+
user_chans = user_data.setdefault("channels", WeakValueDictionary())
131+
user_chans[chan_data["name"].casefold()] = memb_data
132+
133+
old_users.clear()
134+
old_users.update(new_users) # Reassigning the dict would break other references to the data, so just update instead
135+
136+
137+
@hook.irc_raw(['353', '366'], singlethread=True)
138+
def on_names(conn, irc_paramlist, irc_command):
139+
chan = irc_paramlist[2 if irc_command == '353' else 1]
140+
chan_data = conn.memory["chan_data"][chan]
141+
if irc_command == '366':
142+
chan_data["receiving_names"] = False
143+
replace_user_data(conn, chan_data)
144+
return
145+
146+
users = chan_data.setdefault("new_users", [])
147+
if not chan_data.get("receiving_names"):
148+
chan_data["receiving_names"] = True
149+
users.clear()
150+
151+
names = irc_paramlist[-1]
152+
if names.startswith(':'):
153+
names = names[1:].strip()
154+
155+
users.extend(names.split())
156+
157+
158+
def dump_dict(data, indent=2, level=0, _objects=None):
159+
if _objects is None:
160+
_objects = [id(data)]
161+
162+
for key, value in data.items():
163+
yield ((" " * (indent * level)) + "{}:".format(key))
164+
if id(value) in _objects:
165+
yield ((" " * (indent * (level + 1))) + "[...]")
166+
elif isinstance(value, dict):
167+
_objects.append(id(value))
168+
yield from dump_dict(value, indent=indent, level=level + 1, _objects=_objects)
169+
else:
170+
_objects.append(id(value))
171+
yield ((" " * (indent * (level + 1))) + "{}".format(value))
172+
173+
174+
@hook.command(permissions=["botcontrol"], autohelp=False)
175+
def dumpchans(conn):
176+
"""- Dumps all stored channel data for this connection to the console"""
177+
data = conn.memory["chan_data"]
178+
lines = list(dump_dict(data))
179+
print('\n'.join(lines))
180+
return "Printed {} channel records totalling {} lines of data to the console.".format(len(data), len(lines))
181+
182+
183+
@hook.command(permissions=["botcontrol"], autohelp=False)
184+
def dumpusers(conn):
185+
"""- Dumps all stored user data for this connection to the console"""
186+
data = conn.memory["users"]
187+
lines = list(dump_dict(data))
188+
print('\n'.join(lines))
189+
return "Printed {} user records totalling {} lines of data to the console.".format(len(data), len(lines))
190+
191+
192+
@hook.command(permissions=["botcontrol"], autohelp=False)
193+
def updateusers(bot):
194+
"""- Forces an update of all /NAMES data for all channels"""
195+
get_chan_data(bot)
196+
return "Updating all channel data"
197+
198+
199+
@hook.irc_raw('JOIN')
200+
def on_join(chan, nick, user, host, conn):
201+
if chan.startswith(':'):
202+
chan = chan[1:]
203+
204+
users = conn.memory['users']
205+
user_data = WeakDict({"nick": nick, "user": user, "host": host})
206+
user_data = users.setdefault(nick.casefold(), user_data)
207+
chan_data = conn.memory["chan_data"][chan]
208+
memb_data = WeakDict({"chan": weakref.proxy(chan_data), "user": user_data, "status": []})
209+
chan_data["users"][nick.casefold()] = memb_data
210+
user_chans = user_data.setdefault("channels", WeakValueDictionary())
211+
user_chans[chan.casefold()] = memb_data
212+
213+
214+
@hook.irc_raw('PART')
215+
def on_part(chan, nick, conn):
216+
if chan.startswith(':'):
217+
chan = chan[1:]
218+
219+
channels = conn.memory["chan_data"]
220+
nick_cf = nick.casefold()
221+
if nick_cf == conn.nick.casefold():
222+
try:
223+
del channels[chan]
224+
except KeyError:
225+
pass
226+
else:
227+
chan_data = channels[chan]
228+
try:
229+
del chan_data["users"][nick_cf]
230+
except KeyError:
231+
pass
232+
233+
234+
@hook.irc_raw('KICK')
235+
def on_kick(chan, target, conn):
236+
on_part(chan, target, conn)
237+
238+
239+
@hook.irc_raw('QUIT')
240+
def on_quit(nick, conn):
241+
nick_cf = nick.casefold()
242+
users = conn.memory["users"]
243+
if nick_cf in users:
244+
user = users[nick_cf]
245+
for memb in user.get("channels", {}).values():
246+
chan = memb["chan"]
247+
chan["users"].pop(nick_cf)
248+
249+
250+
@hook.irc_raw('MODE')
251+
def on_mode(chan, irc_paramlist, conn):
252+
if chan.startswith(':'):
253+
chan = chan[1:]
254+
255+
serv_info = conn.memory["server_info"]
256+
statuses = serv_info["statuses"]
257+
status_modes = {status.mode for status in statuses.values()}
258+
mode_types = serv_info["channel_modes"]
259+
chans = conn.memory["chan_data"]
260+
if chan not in chans:
261+
return
262+
263+
chan_data = chans[chan]
264+
265+
modes = irc_paramlist[1]
266+
mode_params = irc_paramlist[2:]
267+
new_modes = {}
268+
adding = True
269+
for c in modes:
270+
if c == '+':
271+
adding = True
272+
elif c == '-':
273+
adding = False
274+
else:
275+
new_modes[c] = adding
276+
is_status = c in status_modes
277+
mode_type = mode_types.get(c)
278+
if mode_type:
279+
mode_type = mode_type.type
280+
else:
281+
mode_type = 'B' if is_status else None
282+
283+
if mode_type in "AB" or (mode_type == 'C' and adding):
284+
param = mode_params.pop(0)
285+
286+
if is_status:
287+
memb = chan_data["users"][param.casefold()]
288+
status = statuses[c]
289+
if adding:
290+
memb["status"].append(status)
291+
memb["status"].sort(key=attrgetter("level"), reverse=True)
292+
else:
293+
if status in memb["status"]:
294+
memb["status"].remove(status)
295+
296+
297+
@hook.irc_raw('NICK')
298+
def on_nick(nick, irc_paramlist, conn):
299+
users = conn.memory["users"]
300+
nick_cf = nick.casefold()
301+
new_nick = irc_paramlist[0]
302+
if new_nick.startswith(':'):
303+
new_nick = new_nick[1:]
304+
305+
new_nick_cf = new_nick.casefold()
306+
user = users.pop(nick_cf)
307+
users[new_nick_cf] = user
308+
user["nick"] = nick
309+
for memb in user["channels"].values():
310+
chan_users = memb["chan"]["users"]
311+
chan_users[new_nick_cf] = chan_users.pop(nick_cf)

plugins/server_info.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""
2+
Tracks verious server info like ISUPPORT tokens
3+
"""
4+
from collections import namedtuple
5+
6+
from cloudbot import hook
7+
8+
Status = namedtuple('Status', 'prefix mode level')
9+
ChanMode = namedtuple('ChanMode', 'char type')
10+
11+
DEFAULT_STATUS = (
12+
Status('@', 'o', 2),
13+
Status('+', 'v', 1),
14+
)
15+
16+
17+
@hook.on_start
18+
def do_isupport(bot):
19+
for conn in bot.connections.values():
20+
if conn.connected:
21+
clear_isupport(conn)
22+
conn.send("VERSION")
23+
24+
25+
@hook.connect
26+
def clear_isupport(conn):
27+
serv_info = conn.memory.setdefault("server_info", {})
28+
statuses = {s.prefix: s for s in DEFAULT_STATUS}
29+
statuses.update({s.mode: s for s in DEFAULT_STATUS})
30+
serv_info["statuses"] = statuses
31+
32+
isupport_data = serv_info.setdefault("isupport_tokens", {})
33+
isupport_data.clear()
34+
35+
36+
def handle_prefixes(data, serv_info):
37+
modes, prefixes = data.split(')', 1)
38+
modes = modes.strip('(')
39+
statuses = enumerate(reversed(list(zip(modes, prefixes))))
40+
parsed = {}
41+
for lvl, (mode, prefix) in statuses:
42+
status = Status(prefix, mode, lvl + 1)
43+
parsed[status.prefix] = status
44+
parsed[status.mode] = status
45+
46+
serv_info["statuses"] = parsed
47+
48+
49+
def handle_chan_modes(value, serv_info):
50+
types = "ABCD"
51+
modelist = serv_info.setdefault('channel_modes', {})
52+
modelist.clear()
53+
for i, modes in enumerate(value.split(',')):
54+
if i >= len(types):
55+
break
56+
57+
for mode in modes:
58+
modelist[mode] = ChanMode(mode, types[i])
59+
60+
61+
@hook.irc_raw('005', singlethread=True)
62+
def on_isupport(conn, irc_paramlist):
63+
serv_info = conn.memory["server_info"]
64+
token_data = serv_info["isupport_tokens"]
65+
tokens = irc_paramlist[1:-1] # strip the nick and trailing ':are supported by this server' message
66+
for token in tokens:
67+
name, _, value = token.partition('=')
68+
name = name.upper()
69+
token_data[name] = value or None
70+
if name == "PREFIX":
71+
handle_prefixes(value, serv_info)
72+
elif name == "CHANMODES":
73+
handle_chan_modes(value, serv_info)

0 commit comments

Comments
 (0)