From 3825b93dee78a3d7bfb3b3fe10b5c2722b83e4b8 Mon Sep 17 00:00:00 2001 From: James Lu Date: Fri, 13 Apr 2018 22:08:37 -0700 Subject: [PATCH] Initial pass of a mass-highlight blocking plugin (#359) --- plugins/antispam.py | 141 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 plugins/antispam.py diff --git a/plugins/antispam.py b/plugins/antispam.py new file mode 100644 index 0000000..5ed5ef0 --- /dev/null +++ b/plugins/antispam.py @@ -0,0 +1,141 @@ +# antispam.py: Basic services-side spamfilters for IRC + +from pylinkirc import utils, world, conf +from pylinkirc.log import log + +mydesc = ("Provides anti-spam functionality.") +sbot = utils.register_service("antispam", default_nick="AntiSpam", desc=mydesc) + +def die(irc=None): + utils.unregister_service("antispam") + +PUNISH_OPTIONS = ['kill', 'ban', 'quiet', 'kick'] +EXEMPT_OPTIONS = ['voice', 'halfop', 'op'] +DEFAULT_EXEMPT_OPTION = 'halfop' +def _punish(irc, target, channel, reason): + """Punishes the target user.""" + if irc.is_oper(target, allowAuthed=False): + log.debug("(%s) antispam: refusing to punish oper %s/%s", irc.name, target, irc.get_friendly_name(target)) + return + + exempt_level = irc.get_service_option('antispam', 'exempt_level', DEFAULT_EXEMPT_OPTION).lower() + c = irc.channels[channel] + + if exempt_level not in EXEMPT_OPTIONS: + log.error('(%s) Antispam exempt %r is not a valid setting, ' + 'falling back to defaults; accepted settings include: %s', + irc.name, exempt_level, ', '.join(EXEMPT_OPTIONS)) + exempt_level = DEFAULT_EXEMPT_OPTION + + if exempt_level == 'voice' and c.is_voice_plus(target): + log.debug("(%s) antispam: refusing to punish voiced and above %s/%s", irc.name, target, irc.get_friendly_name(target)) + return + elif exempt_level == 'halfop' and c.is_halfop_plus(target): + log.debug("(%s) antispam: refusing to punish halfop and above %s/%s", irc.name, target, irc.get_friendly_name(target)) + return + elif exempt_level == 'op' and c.is_op_plus(target): + log.debug("(%s) antispam: refusing to punish op and above %s/%s", irc.name, target, irc.get_friendly_name(target)) + return + + my_uid = sbot.uids.get(irc.name) + + punishment = irc.get_service_option('antispam', 'punishment', + 'ban+kill').lower() + bans = set() + log.debug('(%s) antispam: got %r as punishment for %s/%s', irc.name, punishment, + target, irc.get_friendly_name(target)) + + def _ban(): + bans.add(irc.make_channel_ban(target)) + def _quiet(): + bans.add(irc.make_channel_ban(target, ban_type='quiet')) + def _kick(): + irc.kick(my_uid, channel, target, reason) + irc.call_hooks([my_uid, 'ANTISPAM_KICK', {'channel': channel, 'text': reason, 'target': target, + 'parse_as': 'KICK'}]) + def _kill(): + userdata = irc.users[target] + irc.kill(my_uid, target, reason) + irc.call_hooks([my_uid, 'ANTISPAM_KILL', {'target': target, 'text': reason, + 'userdata': userdata, 'parse_as': 'KILL'}]) + + kill = False + for action in set(punishment.split('+')): + if action not in PUNISH_OPTIONS: + log.error('(%s) Antispam punishment %r is not a valid setting; ' + 'accepted settings include: %s OR any combination of ' + 'these joined together with a "+".', + irc.name, punishment, ', '.join(PUNISH_OPTIONS)) + return + elif action == 'kill': + kill = True # Delay kills so that the user data doesn't disappear. + elif action == 'kick': + _kick() + elif action == 'ban': + _ban() + elif action == 'quiet': + _quiet() + + if bans: # Set all bans at once to prevent spam + irc.mode(my_uid, channel, bans) + irc.call_hooks([my_uid, 'ANTISPAM_BAN', + {'target': channel, 'modes': bans, 'parse_as': 'MODE'}]) + if kill: + _kill() + +MASSHIGHLIGHT_DEFAULTS = { + 'min_length': 50, + 'min_nicks': 5, + 'reason': "Mass highlight spam is prohibited" +} +def handle_masshighlight(irc, source, command, args): + """Handles mass highlight attacks.""" + channel = args['target'] + text = args['text'] + mhl_settings = irc.get_service_option('antispam', 'masshighlight', + MASSHIGHLIGHT_DEFAULTS) + my_uid = sbot.uids.get(irc.name) + + if (not irc.connected.is_set()) or (not my_uid): + # Break if the network isn't ready. + log.debug("(%s) antispam: skipping processing; network isn't ready", irc.name) + return + elif not irc.is_channel(channel): + # Not a channel. + log.debug("(%s) antispam: skipping processing; %r is not a channel", irc.name, channel) + return + elif irc.is_internal_client(source): + # Ignore messages from our own clients. + log.debug("(%s) antispam: skipping processing message from internal client %s", irc.name, source) + return + elif channel not in irc.channels or my_uid not in irc.channels[channel].users: + # We're not monitoring this channel. + log.debug("(%s) antispam: skipping processing message from channel %r we're not in", irc.name, channel) + return + elif len(text) < mhl_settings.get('min_length', MASSHIGHLIGHT_DEFAULTS['min_length']): + log.debug("(%s) antispam: skipping processing message %r; it's too short", irc.name, text) + return + + # Strip :, from potential nicks + words = [word.rstrip(':,') for word in text.split()] + + userlist = [irc.users[uid].nick for uid in irc.channels[channel].users.copy()] + min_nicks = mhl_settings.get('min_nicks', MASSHIGHLIGHT_DEFAULTS['min_nicks']) + + # Don't allow repeating the same nick to trigger punishment + nicks_caught = set() + + for word in words: + if word in userlist: + nicks_caught.add(word) + if len(nicks_caught) >= min_nicks: + reason = mhl_settings.get('reason', MASSHIGHLIGHT_DEFAULTS['reason']) + log.debug('(%s) antispam: calling _punish on %s/%s', irc.name, + source, irc.get_friendly_name(source)) + _punish(irc, source, channel, reason) + break + + log.debug('(%s) antispam: got %s/%s nicks on message to %r', irc.name, len(nicks_caught), min_nicks, channel) + +utils.add_hook(handle_masshighlight, 'PRIVMSG') +utils.add_hook(handle_masshighlight, 'NOTICE')