@@ -6,3 +6,4 @@ __pycache__ | |||||
build | build | ||||
dist | dist | ||||
docs/_build | docs/_build | ||||
venv |
@@ -1,6 +1,6 @@ | |||||
# -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
# | # | ||||
# Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
# Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
# | # | ||||
# Permission is hereby granted, free of charge, to any person obtaining a copy | # Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
# of this software and associated documentation files (the "Software"), to deal | # of this software and associated documentation files (the "Software"), to deal | ||||
@@ -26,12 +26,13 @@ class ChanOps(Command): | |||||
"""Voice, devoice, op, or deop users in the channel, or join or part from | """Voice, devoice, op, or deop users in the channel, or join or part from | ||||
other channels.""" | other channels.""" | ||||
name = "chanops" | name = "chanops" | ||||
commands = ["chanops", "voice", "devoice", "op", "deop", "join", "part"] | |||||
commands = ["chanops", "voice", "devoice", "op", "deop", "join", "part", "listchans"] | |||||
def process(self, data): | def process(self, data): | ||||
if data.command == "chanops": | if data.command == "chanops": | ||||
msg = "Available commands are !voice, !devoice, !op, !deop, !join, and !part." | |||||
self.reply(data, msg) | |||||
msg = "Available commands are {0}." | |||||
self.reply(data, msg.format(", ".join( | |||||
"!" + cmd for cmd in self.commands if cmd != data.command))) | |||||
return | return | ||||
de_escalate = data.command in ["devoice", "deop"] | de_escalate = data.command in ["devoice", "deop"] | ||||
if de_escalate and (not data.args or data.args[0] == data.nick): | if de_escalate and (not data.args or data.args[0] == data.nick): | ||||
@@ -44,6 +45,8 @@ class ChanOps(Command): | |||||
self.do_join(data) | self.do_join(data) | ||||
elif data.command == "part": | elif data.command == "part": | ||||
self.do_part(data) | self.do_part(data) | ||||
elif data.command == "listchans": | |||||
self.do_list(data) | |||||
else: | else: | ||||
# If it is just !op/!devoice/whatever without arguments, assume | # If it is just !op/!devoice/whatever without arguments, assume | ||||
# they want to do this to themselves: | # they want to do this to themselves: | ||||
@@ -89,3 +92,11 @@ class ChanOps(Command): | |||||
log += ' ("{0}")'.format(reason) | log += ' ("{0}")'.format(reason) | ||||
self.part(channel, msg) | self.part(channel, msg) | ||||
self.logger.info(log) | self.logger.info(log) | ||||
def do_list(self, data): | |||||
chans = self.bot.frontend.channels | |||||
if not chans: | |||||
self.reply(data, "I am currently in no channels.") | |||||
return | |||||
self.reply(data, "I am currently in \x02{0}\x0F channel{1}: {2}.".format( | |||||
len(chans), "" if len(chans) == 1 else "s", ", ".join(chans))) |
@@ -1,6 +1,6 @@ | |||||
# -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
# | # | ||||
# Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
# Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
# | # | ||||
# Permission is hereby granted, free of charge, to any person obtaining a copy | # Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
# of this software and associated documentation files (the "Software"), to deal | # of this software and associated documentation files (the "Software"), to deal | ||||
@@ -33,7 +33,7 @@ class Stalk(Command): | |||||
commands = ["stalk", "watch", "unstalk", "unwatch", "stalks", "watches", | commands = ["stalk", "watch", "unstalk", "unwatch", "stalks", "watches", | ||||
"allstalks", "allwatches", "unstalkall", "unwatchall"] | "allstalks", "allwatches", "unstalkall", "unwatchall"] | ||||
hooks = ["msg", "rc"] | hooks = ["msg", "rc"] | ||||
MAX_STALKS_PER_USER = 10 | |||||
MAX_STALKS_PER_USER = 5 | |||||
def setup(self): | def setup(self): | ||||
self._users = {} | self._users = {} | ||||
@@ -49,7 +49,8 @@ class Stalk(Command): | |||||
def process(self, data): | def process(self, data): | ||||
if isinstance(data, RC): | if isinstance(data, RC): | ||||
return self._process_rc(data) | |||||
self._process_rc(data) | |||||
return | |||||
data.is_admin = self.config.irc["permissions"].is_admin(data) | data.is_admin = self.config.irc["permissions"].is_admin(data) | ||||
@@ -77,6 +78,12 @@ class Stalk(Command): | |||||
self.reply(data, self._current_stalks(data.nick)) | self.reply(data, self._current_stalks(data.nick)) | ||||
return | return | ||||
modifiers = {} | |||||
for modifier in ["noping", "nobots", "nominor", "nocolor"]: | |||||
if "!" + modifier in data.args: | |||||
modifiers[modifier] = True | |||||
data.args.remove("!" + modifier) | |||||
target = " ".join(data.args).replace("_", " ") | target = " ".join(data.args).replace("_", " ") | ||||
if target.startswith("[[") and target.endswith("]]"): | if target.startswith("[[") and target.endswith("]]"): | ||||
target = target[2:-2] | target = target[2:-2] | ||||
@@ -89,14 +96,14 @@ class Stalk(Command): | |||||
if data.command in ["stalk", "watch"]: | if data.command in ["stalk", "watch"]: | ||||
if data.is_private: | if data.is_private: | ||||
stalkinfo = (data.nick, None) | |||||
stalkinfo = (data.nick, None, modifiers) | |||||
elif not data.is_admin: | elif not data.is_admin: | ||||
self.reply(data, "You must be a bot admin to stalk users or " | self.reply(data, "You must be a bot admin to stalk users or " | ||||
"watch pages publicly. Retry this command in " | "watch pages publicly. Retry this command in " | ||||
"a private message.") | "a private message.") | ||||
return | return | ||||
else: | else: | ||||
stalkinfo = (data.nick, data.chan) | |||||
stalkinfo = (data.nick, data.chan, modifiers) | |||||
if data.command == "stalk": | if data.command == "stalk": | ||||
self._add_stalk("user", data, target, stalkinfo) | self._add_stalk("user", data, target, stalkinfo) | ||||
@@ -113,38 +120,53 @@ class Stalk(Command): | |||||
def _process_rc(self, rc): | def _process_rc(self, rc): | ||||
"""Process a watcher event.""" | """Process a watcher event.""" | ||||
def _update_chans(items): | |||||
def _update_chans(items, flags): | |||||
for item in items: | for item in items: | ||||
modifiers = item[2] if len(item) > 2 else {} | |||||
if modifiers.get("nobots") and "B" in flags: | |||||
continue | |||||
if modifiers.get("nominor") and "M" in flags: | |||||
continue | |||||
if item[1]: | if item[1]: | ||||
if item[1] in chans: | |||||
if modifiers.get("noping"): | |||||
if item[1] not in chans: | |||||
chans[item[1]] = set() | |||||
elif item[1] in chans: | |||||
chans[item[1]].add(item[0]) | chans[item[1]].add(item[0]) | ||||
else: | else: | ||||
chans[item[1]] = {item[0]} | chans[item[1]] = {item[0]} | ||||
if modifiers.get("nocolor"): | |||||
nocolor.add(item[1]) | |||||
else: | else: | ||||
chans[item[0]] = None | chans[item[0]] = None | ||||
if modifiers.get("nocolor"): | |||||
nocolor.add(item[0]) | |||||
def _regex_match(target, tag): | def _regex_match(target, tag): | ||||
return target.startswith("re:") and re.match(target[3:], tag) | return target.startswith("re:") and re.match(target[3:], tag) | ||||
def _process(table, tag): | |||||
def _process(table, tag, flags): | |||||
for target, stalks in table.iteritems(): | for target, stalks in table.iteritems(): | ||||
if target == tag or _regex_match(target, tag): | if target == tag or _regex_match(target, tag): | ||||
_update_chans(stalks) | |||||
_update_chans(stalks, flags) | |||||
chans = {} | chans = {} | ||||
_process(self._users, rc.user) | |||||
nocolor = set() | |||||
_process(self._users, rc.user, rc.flags) | |||||
if rc.is_edit: | if rc.is_edit: | ||||
_process(self._pages, rc.page) | |||||
_process(self._pages, rc.page, rc.flags) | |||||
if not chans: | if not chans: | ||||
return | return | ||||
with self.bot.component_lock: | with self.bot.component_lock: | ||||
frontend = self.bot.frontend | frontend = self.bot.frontend | ||||
if frontend and not frontend.is_stopped(): | if frontend and not frontend.is_stopped(): | ||||
pretty = rc.prettify() | |||||
for chan in chans: | |||||
if chans[chan]: | |||||
nicks = ", ".join(sorted(chans[chan])) | |||||
for chan, users in chans.iteritems(): | |||||
if chan.startswith("#") and chan not in frontend.channels: | |||||
continue | |||||
pretty = rc.prettify(color=chan not in nocolor) | |||||
if users: | |||||
nicks = ", ".join(sorted(users)) | |||||
msg = "\x02{0}\x0F: {1}".format(nicks, pretty) | msg = "\x02{0}\x0F: {1}".format(nicks, pretty) | ||||
else: | else: | ||||
msg = pretty | msg = pretty | ||||
@@ -181,6 +203,10 @@ class Stalk(Command): | |||||
"for non-bot admins.") | "for non-bot admins.") | ||||
self.reply(data, msg.format(verb, nstalks, stalktype)) | self.reply(data, msg.format(verb, nstalks, stalktype)) | ||||
return | return | ||||
if stalkinfo[1] and not stalkinfo[1].startswith("##"): | |||||
msg = "You must be a bot admin to {0} {1}s in public channels." | |||||
self.reply(data, msg.format(verb, stalktype)) | |||||
return | |||||
if target in table: | if target in table: | ||||
if stalkinfo in table[target]: | if stalkinfo in table[target]: | ||||
@@ -285,8 +311,13 @@ class Stalk(Command): | |||||
"""Return all existing stalks, for bot admins.""" | """Return all existing stalks, for bot admins.""" | ||||
def _format_info(info): | def _format_info(info): | ||||
if info[1]: | if info[1]: | ||||
return "for {0} in {1}".format(info[0], info[1]) | |||||
return "for {0} privately".format(info[0]) | |||||
result = "for {0} in {1}".format(info[0], info[1]) | |||||
else: | |||||
result = "for {0} privately".format(info[0]) | |||||
modifiers = ", ".join(info[2]) if len(info) > 2 else "" | |||||
if modifiers: | |||||
result += " ({0})".format(modifiers) | |||||
return result | |||||
def _format_data(data): | def _format_data(data): | ||||
return ", ".join(_format_info(info) for info in data) | return ", ".join(_format_info(info) for info in data) | ||||
@@ -1,6 +1,6 @@ | |||||
# -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
# | # | ||||
# Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
# Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
# | # | ||||
# Permission is hereby granted, free of charge, to any person obtaining a copy | # Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
# of this software and associated documentation files (the "Software"), to deal | # of this software and associated documentation files (the "Software"), to deal | ||||
@@ -48,6 +48,7 @@ class Frontend(IRCConnection): | |||||
cf["realname"], bot.logger.getChild("frontend")) | cf["realname"], bot.logger.getChild("frontend")) | ||||
self._auth_wait = False | self._auth_wait = False | ||||
self._channels = set() | |||||
self._connect() | self._connect() | ||||
def __repr__(self): | def __repr__(self): | ||||
@@ -62,18 +63,45 @@ class Frontend(IRCConnection): | |||||
return res.format(self.nick, self.ident, self.host, self.port) | return res.format(self.nick, self.ident, self.host, self.port) | ||||
def _join_channels(self): | def _join_channels(self): | ||||
"""Join all startup channels as specified by the config file.""" | |||||
for chan in self.bot.config.irc["frontend"]["channels"]: | |||||
"""Join all startup channels.""" | |||||
permdb = self.bot.config.irc["permissions"] | |||||
try: | |||||
# Try channels previously joined: | |||||
chans = permdb.get_attr("meta:frontend", "channels").split(",") | |||||
except KeyError: | |||||
# Channels specified in the config file: | |||||
chans = self.bot.config.irc["frontend"]["channels"] | |||||
for chan in chans: | |||||
self.join(chan) | self.join(chan) | ||||
def _save_channels(self): | |||||
"""Save the channel list persistently.""" | |||||
permdb = self.bot.config.irc["permissions"] | |||||
permdb.set_attr("meta:frontend", "channels", ",".join(sorted(self._channels))) | |||||
def _add_channel(self, chan): | |||||
"""Add a channel to the list of our channels.""" | |||||
self._channels.add(chan) | |||||
self._save_channels() | |||||
def _remove_channel(self, chan): | |||||
"""Remove a channel from the list of our channels.""" | |||||
self._channels.discard(chan) | |||||
self._save_channels() | |||||
def _process_message(self, line): | def _process_message(self, line): | ||||
"""Process a single message from IRC.""" | """Process a single message from IRC.""" | ||||
if line[1] == "JOIN": | if line[1] == "JOIN": | ||||
data = Data(self.nick, line, msgtype="JOIN") | data = Data(self.nick, line, msgtype="JOIN") | ||||
if data.nick == self.nick: | |||||
self._add_channel(data.chan) | |||||
self.bot.commands.call("join", data) | self.bot.commands.call("join", data) | ||||
elif line[1] == "PART": | elif line[1] == "PART": | ||||
data = Data(self.nick, line, msgtype="PART") | data = Data(self.nick, line, msgtype="PART") | ||||
if data.nick == self.nick: | |||||
self._remove_channel(data.chan) | |||||
self.bot.commands.call("part", data) | self.bot.commands.call("part", data) | ||||
elif line[1] == "PRIVMSG": | elif line[1] == "PRIVMSG": | ||||
@@ -93,6 +121,10 @@ class Frontend(IRCConnection): | |||||
sleep(2) # Wait for hostname change to propagate | sleep(2) # Wait for hostname change to propagate | ||||
self._join_channels() | self._join_channels() | ||||
elif line[1] == "KICK": | |||||
if line[3] == self.nick: | |||||
self._remove_channel(line[2]) | |||||
elif line[1] == "376": # On successful connection to the server | elif line[1] == "376": # On successful connection to the server | ||||
# If we're supposed to auth to NickServ, do that: | # If we're supposed to auth to NickServ, do that: | ||||
try: | try: | ||||
@@ -111,3 +143,8 @@ class Frontend(IRCConnection): | |||||
# Services is down, or something...? | # Services is down, or something...? | ||||
self._auth_wait = False | self._auth_wait = False | ||||
self._join_channels() | self._join_channels() | ||||
@property | |||||
def channels(self): | |||||
"""A set containing all channels the bot is in.""" | |||||
return self._channels |
@@ -1,6 +1,6 @@ | |||||
# -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
# | # | ||||
# Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
# Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
# | # | ||||
# Permission is hereby granted, free of charge, to any person obtaining a copy | # Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
# of this software and associated documentation files (the "Software"), to deal | # of this software and associated documentation files (the "Software"), to deal | ||||
@@ -32,6 +32,8 @@ class RC(object): | |||||
pretty_edit = "\x02New {0}\x0F: \x0314[[\x0307{1}\x0314]]\x0306 * \x0303{2}\x0306 * \x0302{3}\x0306 * \x0310{4}" | pretty_edit = "\x02New {0}\x0F: \x0314[[\x0307{1}\x0314]]\x0306 * \x0303{2}\x0306 * \x0302{3}\x0306 * \x0310{4}" | ||||
pretty_log = "\x02New {0}\x0F: \x0303{1}\x0306 * \x0302{2}\x0306 * \x0310{3}" | pretty_log = "\x02New {0}\x0F: \x0303{1}\x0306 * \x0302{2}\x0306 * \x0310{3}" | ||||
plain_edit = "New {0}: [[{1}]] * {2} * {3} * {4}" | |||||
plain_log = "New {0}: {1} * {2} * {3}" | |||||
def __init__(self, chan, msg): | def __init__(self, chan, msg): | ||||
self.chan = chan | self.chan = chan | ||||
@@ -57,7 +59,7 @@ class RC(object): | |||||
try: | try: | ||||
page, self.flags, url, user, comment = self.re_edit.findall(msg)[0] | page, self.flags, url, user, comment = self.re_edit.findall(msg)[0] | ||||
except IndexError: | except IndexError: | ||||
# We're probably missing the http:// part, because it's a log | |||||
# We're probably missing the https:// part, because it's a log | |||||
# entry, which lacks a URL: | # entry, which lacks a URL: | ||||
page, flags, user, comment = self.re_log.findall(msg)[0] | page, flags, user, comment = self.re_log.findall(msg)[0] | ||||
url = "https://{0}.org/wiki/{1}".format(self.chan[1:], page) | url = "https://{0}.org/wiki/{1}".format(self.chan[1:], page) | ||||
@@ -70,7 +72,7 @@ class RC(object): | |||||
self.page, self.url, self.user, self.comment = page, url, user, comment | self.page, self.url, self.user, self.comment = page, url, user, comment | ||||
def prettify(self): | |||||
def prettify(self, color=True): | |||||
"""Make a nice, colorful message to send back to the IRC front-end.""" | """Make a nice, colorful message to send back to the IRC front-end.""" | ||||
flags = self.flags | flags = self.flags | ||||
if self.is_edit: | if self.is_edit: | ||||
@@ -82,8 +84,8 @@ class RC(object): | |||||
event = "bot edit" # "New bot edit:" | event = "bot edit" # "New bot edit:" | ||||
if "M" in flags: | if "M" in flags: | ||||
event = "minor " + event # "New minor (bot)? edit:" | event = "minor " + event # "New minor (bot)? edit:" | ||||
return self.pretty_edit.format(event, self.page, self.user, | |||||
self.url, self.comment) | |||||
tmpl = self.pretty_edit if color else self.plain_edit | |||||
return tmpl.format(event, self.page, self.user, self.url, self.comment) | |||||
if flags == "delete": | if flags == "delete": | ||||
event = "deletion" # "New deletion:" | event = "deletion" # "New deletion:" | ||||
@@ -93,4 +95,5 @@ class RC(object): | |||||
event = "user" # "New user:" | event = "user" # "New user:" | ||||
else: | else: | ||||
event = flags # Works for "move", "block", etc | event = flags # Works for "move", "block", etc | ||||
return self.pretty_log.format(event, self.user, self.url, self.comment) | |||||
tmpl = self.pretty_log if color else self.plain_log | |||||
return tmpl.format(event, self.user, self.url, self.comment) |