-
Notifications
You must be signed in to change notification settings - Fork 1
/
handlers.py
336 lines (298 loc) · 13.6 KB
/
handlers.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
"""Handle events dynamically.
Want to be able to reload event handlers as needed
"""
import logging
import irc.modes
import threading
import time
from command import Command, CommandHandler
from abuse import ml #, heuristics
log = logging.getLogger(__name__)
class Handler():
"""Abstract Handler."""
def __init__(self, bot):
"""Create a handler for events."""
self.bot = bot
self.skip_events = []
self.commands = []
def _ignore(self, *args):
pass
def run(self, connection, event):
"""Run on events we have methods for."""
if event.type not in self.skip_events:
getattr(self, f'on_{event.type}', self._ignore)(connection, event)
def load_commands(self):
"""Load in registered commands."""
for command in self.commands:
CommandHandler.commands.append(command)
class Lockdown(Handler):
"""Manage lockdowns."""
can_moderate = ['#miraheze', '#miraheze-cvt']
def __init__(self, bot):
"""Lockdown handler."""
super().__init__(bot)
self.locked_down = bot.saves.setdefault('locked_down', [])
self.pending = {}
self.pending_users = {}
self.auto = bot.saves.setdefault('lock_auto', False)
self.commands.append(Command(
'lockdown',
self.lockdown_cmd,
restriction=Command.DEVELOPER,
help='Manage lockdowns. Command format is $lockdown <enable|lift> [channel]'
))
self.commands.append(Command(
'toggleauto',
self.toggle_auto,
restriction=Command.DEVELOPER,
help='Toggle automatic handling of lockdowns.'
))
def toggle_auto(self, bot, event):
"""Toggle automatic lockdown detection."""
target = event.target if event.type == 'pubmsg' else event.source.nick
old = self.auto
self.auto = bot.saves['lock_auto'] = not old
if old:
bot.connection.privmsg(target, 'Automatic handling of lockdowns was disabled.')
else:
bot.connection.privmsg(target, 'Automatic handling of lockdowns was enabled.')
def lockdown_cmd(self, bot, event):
"""Lockdown command."""
target = event.target if event.type == 'pubmsg' else event.source.nick
args = event.arguments[0].split()[1:]
if len(args) == 0:
bot.connection.privmsg(target, 'Command format is $lockdown <enable|lift> [channel]')
elif len(args) == 1:
if not event.type == 'pubmsg':
bot.connection.privmsg(target, 'A target channel is required if using private messages!')
elif args[0] in ['enable', 'lift']:
if target not in self.can_moderate:
bot.connection.privmsg(target, 'Cannot moderate this channel!')
elif args[0] == 'enable':
self.pre_lockdown(bot.connection, target)
elif args[0] == 'lift':
self.pre_unlock(bot.connection, target)
else:
bot.connection.privmsg(target, f'Unrecognised option "{args[0]}"')
else:
if args[0] in ['enable', 'lift']:
if args[1][0] != '#':
bot.connection.privmsg(target, f'"{args[1]}" is not a channel!')
elif args[1] not in self.can_moderate:
bot.connection.privmsg(target, f'Cannot moderate "{args[1]}"!')
elif args[0] == 'enable':
self.pre_lockdown(bot.connection, args[1])
elif args[0] == 'lift':
self.pre_unlock(bot.connection, args[1])
else:
bot.connection.privmsg(target, 'Something went wrong :(')
else:
bot.connection.privmsg(target, f'Unrecognised option "{args[0]}"')
def pre_lockdown(self, connection, channel):
"""Pre lockdown checks."""
if channel not in self.bot.channels:
log.warn(f'Bot not in expected channel "{channel}"')
return
if self.bot.channels[channel].is_oper(connection.get_nickname()):
self.do_lockdown(connection, channel)
else:
self.pending[channel] = self.do_lockdown
connection.privmsg('ChanServ', f'OP {channel} {connection.get_nickname()}')
def do_lockdown(self, connection, chan):
"""Do lockdown procedure."""
if chan not in self.locked_down:
self.locked_down.append(chan)
else:
log.warn(f'Enabling lockdown in {chan} despite channel appearing locked down?')
channel = self.bot.channels[chan]
connection.mode(chan, '+qz *!*@*')
for user in channel.users():
if user in [connection.get_nickname(), 'ChanServ']:
continue
if chan not in self.pending_users:
self.pending_users[chan] = []
self.pending_users[chan].append(user)
connection.userhost([user])
def on_userhost(self, connection, event):
"""Grant ops to trusted users."""
user = event.arguments[0].split('=')[0].strip(' *')
host = event.arguments[0].split('@')[-1].strip()
for chan in self.pending_users:
if user in self.pending_users[chan]:
if chan in self.bot.trusted.get('op', {}).get(host, []):
connection.mode(chan, '+o ' + user)
def on_join(self, connection, event):
"""Grant ops to trusted users when they join."""
if event.target in self.locked_down:
if event.source == connection.get_nickname():
connection.privmsg('ChanServ', f'OP {event.target} {connection.get_nickname()}')
if event.target in self.bot.trusted.get('op', {}).get(event.source.host, []):
connection.mode(event.target, '+o ' + event.source.nick)
def pre_unlock(self, connection, channel):
"""Pre unlock checks."""
if channel not in self.bot.channels:
log.warn(f'Bot not in expected channel "{channel}"')
return
if self.bot.channels[channel].is_oper(connection.get_nickname()):
self.drop_lockdown(connection, channel)
else:
self.pending[channel] = self.drop_lockdown
connection.privmsg('ChanServ', f'OP {channel} {connection.get_nickname()}')
def drop_lockdown(self, connection, chan):
"""Drop lockdown."""
if chan in self.locked_down:
self.locked_down.remove(chan)
else:
log.warn(f'Removing lockdown from {chan} despite no lockdown in place?')
# channel = self.bot.channels[chan]
connection.mode(chan, '-q *!*@*')
# TODO: DEOP OPS?
def on_mode(self, connection, event):
"""Handle various mode changes."""
if event.target in self.pending:
modes = irc.modes.parse_channel_modes(' '.join(event.arguments))
for mode in modes:
# TODO: verify
if mode == ['+', 'o', connection.get_nickname()]:
self.pending[event.target](connection, event.target)
self.pending.pop(event.target)
if self.auto:
self.on_mode_auto(connection, event)
def on_mode_auto(self, connection, event):
"""Automatically enable/disable lockdown based on mode changes."""
if event.target not in self.can_moderate:
return
# channel = self.bot.channels[event.target]
modes = irc.modes.parse_channel_modes(' '.join(event.arguments))
for mode in modes:
if event.target not in self.locked_down:
if mode == ['+', 'q', '*!*@*']: # and channel.has_mode('z'): # may not be +z yet
self.pre_lockdown(connection, event.target)
break
else:
if mode == ['-', 'q', '*!*@*']: # and channel.has_mode('z'): # z may be dropped first (handle that?)
self.pre_unlock(connection, event.target)
break
class MLHandler(Handler):
"""Implement machine learning abuse detection."""
def __init__(self, bot):
"""Initialize needed stuff."""
super().__init__(bot)
self.pending_bans = {}
# Store values in bot to avoid retraining every reload
self.vectorizer = getattr(bot, 'vectorizer', False)
self.classifier = getattr(bot, 'classifier', False)
self.classifier2 = getattr(bot, 'classifier2', False)
self.heuristics = []
self.timestamps = {}
self.mutex = threading.RLock()
self.commands.append(Command(
'train',
self.train,
restriction=Command.DEVELOPER,
help="(Re)train dataset (if you don't know what this means, don't touch it!)"
))
if not (self.vectorizer and self.classifier and self.classifier2):
t_thread = threading.Thread(target=self.train)
t_thread.start()
def train(self, *args):
"""Load in vectorizer and classifier."""
vectorizer, classifier, classifier2 = ml.train(self.bot.path / 'abuse/dataset.csv')
# Store values in bot to avoid retraining every reload
self.bot.vectorizer = self.vectorizer = vectorizer
self.bot.classifier = self.classifier = classifier
self.bot.classifier2 = self.classifier2 = classifier2
def check_flood(self, nick):
"""Attempt to determine if supplied nick is flooding."""
# TODO: replace with better whitelist (incorporate into heuristics points?)
with self.mutex:
if "Bot" in nick or "Not" in nick:
return False
if nick not in self.timestamps:
self.timestamps[nick] = [time.time()]
return False
now = time.time()
timestamps = self.timestamps[nick]
for timestamp in timestamps.copy():
if now - timestamp > 30:
# Ignore all timestamps older than 30s
timestamps.remove(timestamp)
total = len(timestamps)
if total < 4:
self.timestamps[nick].append(now)
return False
if total > 30:
self.timestamps.pop(nick) # Don't trip repeatedly on the same user
return True # Hard limit at 1msg/sec over 30s
avg = (now - timestamps[0]) / (total + 1) # A simpler system, 0 index should be oldest
if -(2.4 / total) + 3 > avg:
self.timestamps.pop(nick) # Don't trip repeatedly on the same user
return True # I don't want to explain this math, so I hope it works
self.timestamps[nick].append(now)
return False
def _clean(self):
"""Clear old entries from timestamps."""
with self.mutex:
now = time.time()
nicks = list(self.timestamps.keys())
for nick in nicks:
for timestamp in self.timestamps[nick].copy():
if now - timestamp > 30:
self.timestamps[nick].remove(timestamp)
if len(self.timestamps[nick]) == 0:
self.timestamps.pop(nick)
def on_pubmsg(self, connection, event):
"""Process public messages for abuse."""
if not(self.vectorizer and self.classifier):
return
words = ' '.join(event.arguments)
wordbag = self.vectorizer.transform([words])
c = event.target
# Flood detection
if self.check_flood(event.source.nick):
log.warn(f'Detected flooding from "{event.source.nick}" in {c}')
# Classifier1
if self.classifier.predict(wordbag).tolist()[0]:
"""Not ready for use
for rule in self.heuristics:
if rule.apply(event) <= -1000:
return # Whitelist users
"""
log.warn(f'Classifier1 detected abusive message: "{words}" from "{event.source}" in "{c}"')
"""Not ready for use
channel = self.bot.channels[c]
if channel.is_oper(connection.get_nickname):
connection.mode(c, '+b ' + event.sender.host)
connection.kick(c, event.sender.nick)
else:
if c not in self.pending_bans:
self.pending_bans[c] = []
self.pending_bans[c].append(event.sender)
connection.privmsg('ChanServ', f'OP {c} {connection.get_nickname()}')
"""
# Classifier2
points = self.classifier2.predict_proba(wordbag).tolist()[0][1]
points = int(points * 100)
for rule in self.heuristics:
points += rule.apply(event)
if points >= 95 or self.classifier2.predict(wordbag).tolist()[0]:
log.warn(f'Classifier2 tripped with score {points} on: "{words}" from "{event.source}" in "{c}"')
self._clean() # Housekeeping
def on_mode(self, connection, event):
"""Process pending bans."""
if event.target in self.pending_bans:
modes = irc.modes.parse_channel_modes(' '.join(event.arguments))
for mode in modes:
if mode == ['+', 'o', connection.get_nickname()]:
for user in self.pending_bans[event.target]:
connection.mode(event.target, '+b ' + user.host)
connection.kick(event.target, user.nick)
self.pending_bans.pop(event.target)
def load_handlers(bot):
"""Return an array of all in use handlers."""
handlers = []
handlers.append(Lockdown(bot))
# handlers.append(MLHandler(bot))
for handler in handlers:
handler.load_commands()
return handlers