Browse Source

Track joined/parted channels on restart; improve !stalk; add !listchans

tags/v0.4
Ben Kurtovic 3 years ago
parent
commit
ee2addf1a2
5 changed files with 113 additions and 30 deletions
  1. +1
    -0
      .gitignore
  2. +15
    -4
      earwigbot/commands/chanops.py
  3. +48
    -17
      earwigbot/commands/stalk.py
  4. +40
    -3
      earwigbot/irc/frontend.py
  5. +9
    -6
      earwigbot/irc/rc.py

+ 1
- 0
.gitignore View File

@@ -6,3 +6,4 @@ __pycache__
build build
dist dist
docs/_build docs/_build
venv

+ 15
- 4
earwigbot/commands/chanops.py View File

@@ -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)))

+ 48
- 17
earwigbot/commands/stalk.py View File

@@ -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)


+ 40
- 3
earwigbot/irc/frontend.py View File

@@ -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

+ 9
- 6
earwigbot/irc/rc.py View File

@@ -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)

Loading…
Cancel
Save