@@ -6,3 +6,4 @@ __pycache__ | |||
build | |||
dist | |||
docs/_build | |||
venv |
@@ -1,6 +1,6 @@ | |||
# -*- 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 | |||
# 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 | |||
other channels.""" | |||
name = "chanops" | |||
commands = ["chanops", "voice", "devoice", "op", "deop", "join", "part"] | |||
commands = ["chanops", "voice", "devoice", "op", "deop", "join", "part", "listchans"] | |||
def process(self, data): | |||
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 | |||
de_escalate = data.command in ["devoice", "deop"] | |||
if de_escalate and (not data.args or data.args[0] == data.nick): | |||
@@ -44,6 +45,8 @@ class ChanOps(Command): | |||
self.do_join(data) | |||
elif data.command == "part": | |||
self.do_part(data) | |||
elif data.command == "listchans": | |||
self.do_list(data) | |||
else: | |||
# If it is just !op/!devoice/whatever without arguments, assume | |||
# they want to do this to themselves: | |||
@@ -89,3 +92,11 @@ class ChanOps(Command): | |||
log += ' ("{0}")'.format(reason) | |||
self.part(channel, msg) | |||
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 -*- | |||
# | |||
# 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 | |||
# 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", | |||
"allstalks", "allwatches", "unstalkall", "unwatchall"] | |||
hooks = ["msg", "rc"] | |||
MAX_STALKS_PER_USER = 10 | |||
MAX_STALKS_PER_USER = 5 | |||
def setup(self): | |||
self._users = {} | |||
@@ -49,7 +49,8 @@ class Stalk(Command): | |||
def process(self, data): | |||
if isinstance(data, RC): | |||
return self._process_rc(data) | |||
self._process_rc(data) | |||
return | |||
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)) | |||
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("_", " ") | |||
if target.startswith("[[") and target.endswith("]]"): | |||
target = target[2:-2] | |||
@@ -89,14 +96,14 @@ class Stalk(Command): | |||
if data.command in ["stalk", "watch"]: | |||
if data.is_private: | |||
stalkinfo = (data.nick, None) | |||
stalkinfo = (data.nick, None, modifiers) | |||
elif not data.is_admin: | |||
self.reply(data, "You must be a bot admin to stalk users or " | |||
"watch pages publicly. Retry this command in " | |||
"a private message.") | |||
return | |||
else: | |||
stalkinfo = (data.nick, data.chan) | |||
stalkinfo = (data.nick, data.chan, modifiers) | |||
if data.command == "stalk": | |||
self._add_stalk("user", data, target, stalkinfo) | |||
@@ -113,38 +120,53 @@ class Stalk(Command): | |||
def _process_rc(self, rc): | |||
"""Process a watcher event.""" | |||
def _update_chans(items): | |||
def _update_chans(items, flags): | |||
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] 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]) | |||
else: | |||
chans[item[1]] = {item[0]} | |||
if modifiers.get("nocolor"): | |||
nocolor.add(item[1]) | |||
else: | |||
chans[item[0]] = None | |||
if modifiers.get("nocolor"): | |||
nocolor.add(item[0]) | |||
def _regex_match(target, 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(): | |||
if target == tag or _regex_match(target, tag): | |||
_update_chans(stalks) | |||
_update_chans(stalks, flags) | |||
chans = {} | |||
_process(self._users, rc.user) | |||
nocolor = set() | |||
_process(self._users, rc.user, rc.flags) | |||
if rc.is_edit: | |||
_process(self._pages, rc.page) | |||
_process(self._pages, rc.page, rc.flags) | |||
if not chans: | |||
return | |||
with self.bot.component_lock: | |||
frontend = self.bot.frontend | |||
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) | |||
else: | |||
msg = pretty | |||
@@ -181,6 +203,10 @@ class Stalk(Command): | |||
"for non-bot admins.") | |||
self.reply(data, msg.format(verb, nstalks, stalktype)) | |||
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 stalkinfo in table[target]: | |||
@@ -285,8 +311,13 @@ class Stalk(Command): | |||
"""Return all existing stalks, for bot admins.""" | |||
def _format_info(info): | |||
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): | |||
return ", ".join(_format_info(info) for info in data) | |||
@@ -1,6 +1,6 @@ | |||
# -*- 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 | |||
# of this software and associated documentation files (the "Software"), to deal | |||
@@ -48,6 +48,7 @@ class Frontend(IRCConnection): | |||
cf["realname"], bot.logger.getChild("frontend")) | |||
self._auth_wait = False | |||
self._channels = set() | |||
self._connect() | |||
def __repr__(self): | |||
@@ -62,18 +63,45 @@ class Frontend(IRCConnection): | |||
return res.format(self.nick, self.ident, self.host, self.port) | |||
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) | |||
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): | |||
"""Process a single message from IRC.""" | |||
if line[1] == "JOIN": | |||
data = Data(self.nick, line, msgtype="JOIN") | |||
if data.nick == self.nick: | |||
self._add_channel(data.chan) | |||
self.bot.commands.call("join", data) | |||
elif line[1] == "PART": | |||
data = Data(self.nick, line, msgtype="PART") | |||
if data.nick == self.nick: | |||
self._remove_channel(data.chan) | |||
self.bot.commands.call("part", data) | |||
elif line[1] == "PRIVMSG": | |||
@@ -93,6 +121,10 @@ class Frontend(IRCConnection): | |||
sleep(2) # Wait for hostname change to propagate | |||
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 | |||
# If we're supposed to auth to NickServ, do that: | |||
try: | |||
@@ -111,3 +143,8 @@ class Frontend(IRCConnection): | |||
# Services is down, or something...? | |||
self._auth_wait = False | |||
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 -*- | |||
# | |||
# 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 | |||
# 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_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): | |||
self.chan = chan | |||
@@ -57,7 +59,7 @@ class RC(object): | |||
try: | |||
page, self.flags, url, user, comment = self.re_edit.findall(msg)[0] | |||
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: | |||
page, flags, user, comment = self.re_log.findall(msg)[0] | |||
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 | |||
def prettify(self): | |||
def prettify(self, color=True): | |||
"""Make a nice, colorful message to send back to the IRC front-end.""" | |||
flags = self.flags | |||
if self.is_edit: | |||
@@ -82,8 +84,8 @@ class RC(object): | |||
event = "bot edit" # "New bot edit:" | |||
if "M" in flags: | |||
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": | |||
event = "deletion" # "New deletion:" | |||
@@ -93,4 +95,5 @@ class RC(object): | |||
event = "user" # "New user:" | |||
else: | |||
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) |