aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md20
-rw-r--r--VERSION2
-rwxr-xr-xbitbotd48
-rw-r--r--docs/bot.conf.example12
-rw-r--r--docs/help/config.md2
-rw-r--r--modules/channel_op.py28
-rw-r--r--modules/define.py5
-rw-r--r--modules/dice.py8
-rw-r--r--modules/eval_python.py2
-rw-r--r--modules/git_webhooks/github.py6
-rw-r--r--modules/healthcheck.py7
-rw-r--r--modules/hostmask_tracking.py40
-rw-r--r--modules/ircv3_typing.py7
-rw-r--r--modules/karma.py6
-rw-r--r--modules/quotes.py28
-rw-r--r--modules/relay.py2
-rw-r--r--modules/sed.py7
-rw-r--r--modules/user_time.py32
-rw-r--r--modules/words.py19
-rw-r--r--src/Database.py40
-rw-r--r--src/DatabaseEngines.py64
-rw-r--r--src/core_modules/admin.py14
-rw-r--r--src/core_modules/aliases.py38
-rw-r--r--src/core_modules/commands/__init__.py20
-rw-r--r--src/core_modules/cron.py3
-rw-r--r--src/core_modules/line_handler/channel.py5
-rw-r--r--src/core_modules/line_handler/core.py2
-rw-r--r--src/core_modules/line_handler/message.py11
-rw-r--r--src/core_modules/nick_regain.py40
-rw-r--r--src/core_modules/permissions/__init__.py16
-rw-r--r--src/utils/http.py3
-rw-r--r--src/utils/parse.py35
32 files changed, 411 insertions, 161 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 223b32c5..590cf364 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,23 @@
+# TBD - BitBot v1.18.0
+
+Added:
+- Show `transferred` github issues by default (`git_webhooks`)
+- `hostmask-tracking.py` - keep a history of what hostmasks each nickname has used
+- Also watch `NICK` and `QUIT` lines to see when our nickname might be freee (`nick_regain.py`)
+
+Changed:
+- Removed `--data-dir`, `--database` and `--log-dir`, these options have been moved to `bot.conf`
+- Reworded karma change output
+- `!grab` not tries to attach quote to account, not just nickname (`quotes.py`)
+- `!wordiest` not defaults to the current channel (`words.py`)
+- `!part` now works for `+o` users or users with channel_access `part` (`admin.py`)
+
+Fixed:
+- cron would fail to initialise at 59 minutes past the hour (`core_modules/cron.py`)
+- Don't send typing notications by default for pattern-based commands
+- Regex error when replacement starts with a number (`sed.py`)
+- Reimplement lost IRCv3 `account-tag` functionality (`permissions`)
+
# 2020-01-08 - BitBot v1.17.2
Fixed:
diff --git a/VERSION b/VERSION
index 06fb41b6..8b53d414 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.17.2
+1.18.0-rc1
diff --git a/bitbotd b/bitbotd
index dd032983..f678adad 100755
--- a/bitbotd
+++ b/bitbotd
@@ -25,16 +25,6 @@ arg_parser.add_argument("--version", "-v", action="store_true")
arg_parser.add_argument("--config", "-c", help="Location of config file",
default=os.path.join(default_data, "bot.conf"))
-arg_parser.add_argument("--data-dir", "-x",
- help="Location of data files (database, lock, socket)",
- default=default_data)
-
-arg_parser.add_argument("--database", "-d",
- help="Location of the sqlite3 database file")
-
-arg_parser.add_argument("--log-dir", "-l",
- help="Location of the log directory")
-
arg_parser.add_argument("--add-server", "-a",
help="Add a new server", action="store_true")
@@ -56,36 +46,28 @@ if args.version:
print("BitBot %s" % IRCBot.VERSION)
sys.exit(0)
-if not os.path.isdir(args.data_dir):
- os.mkdir(args.data_dir)
+config = Config.Config(args.config)
+config.load()
-database_location = None
-lock_location = None
-sock_locaiton = None
-log_directory = None
-if not args.database == None:
- database_location = args.database
- lock_location = "%s.lock" % args.database
- sock_location = "%s.sock" % args.database
-else:
- database_location = os.path.join(args.data_dir, "bot.db")
- lock_location = os.path.join(args.data_dir, "bot.lock")
- sock_location = os.path.join(args.data_dir, "bot.sock")
+DATA_DIR = os.path.expanduser(config.get("data-directory", "~/.bitbot"))
+LOG_DIR = config.get("log-directory", "{DATA}/logs/").format(DATA=DATA_DIR)
+DATABASE = config.get("database", "sqlite3:{DATA}/bot.db").format(DATA=DATA_DIR)
+LOCK_FILE = config.get("lock-file", "{DATA}/bot.lock").format(DATA=DATA_DIR)
+SOCK_FILE = config.get("sock-file", "{DATA}/bot.sock").format(DATA=DATA_DIR)
-log_directory = args.log_dir or os.path.join(args.data_dir, "logs")
-if not os.path.isdir(log_directory):
- os.mkdir(log_directory)
+if not os.path.isdir(LOG_DIR):
+ os.mkdir(LOG_DIR)
log_level = args.log_level
if not log_level:
log_level = "debug" if args.verbose else "warn"
-log = Logging.Log(not args.no_logging, log_level, log_directory)
+log = Logging.Log(not args.no_logging, log_level, LOG_DIR)
log.info("Starting BitBot %s (Python v%s, db %s)",
- [IRCBot.VERSION, platform.python_version(), database_location])
+ [IRCBot.VERSION, platform.python_version(), DATABASE])
-lock_file = LockFile.LockFile(lock_location)
+lock_file = LockFile.LockFile(LOCK_FILE)
if not lock_file.available():
log.critical("Database is locked. Is BitBot already running?")
sys.exit(utils.consts.Exit.LOCKED)
@@ -93,7 +75,7 @@ if not lock_file.available():
atexit.register(lock_file.unlock)
lock_file.lock()
-database = Database.Database(log, database_location)
+database = Database.Database(log, DATABASE)
if args.remove_server:
alias = args.remove_server
@@ -117,8 +99,6 @@ if args.add_server:
sys.exit(0)
cache = Cache.Cache()
-config = Config.Config(args.config)
-config.load()
events = EventManager.EventRoot(log).wrap()
exports = Exports.Exports()
timers = Timers.Timers(database, events, log)
@@ -139,7 +119,7 @@ bot.add_poll_hook(cache)
bot.add_poll_hook(lock_file)
bot.add_poll_hook(timers)
-control = Control.Control(bot, sock_location)
+control = Control.Control(bot, SOCK_FILE)
control.bind()
bot.add_poll_source(control)
diff --git a/docs/bot.conf.example b/docs/bot.conf.example
index f22d4fc4..6c7808dc 100644
--- a/docs/bot.conf.example
+++ b/docs/bot.conf.example
@@ -2,6 +2,18 @@
# will be disabled.
[bot]
+
+# configuration related to where/how bitbot accesses files and databases.
+# commented out values are the default values. {DATA} is replaced with data-directory
+
+#data-directory = ~/.bitbot
+#log-directory = /var/log/bitbot/
+#lock-file = {DATA}/bot.lock
+#sock-file = {DATA}/bot.sock
+
+# database - currently only supports sqlite3
+#database = sqlite3:{DATA}/bot.db
+
# client-side tls key/cert for IRC connections
tls-key =
tls-certificate =
diff --git a/docs/help/config.md b/docs/help/config.md
index 94305ce9..e11d14ec 100644
--- a/docs/help/config.md
+++ b/docs/help/config.md
@@ -8,7 +8,7 @@
* `/msg <bot> register <password here>` to register your nickname with the bot
* (use `/msg <bot> identify <password>` to log in in the future)
* `/msg <bot> masterlogin <master admin password>` to login as master admin
-* `/msg <bot> givepermission <your nickname> *` to give your account admin permissions
+* `/msg <bot> permission add <your nickname> *` to give your account admin permissions
### Configure client TLS certificate
diff --git a/modules/channel_op.py b/modules/channel_op.py
index bc307778..98f170bb 100644
--- a/modules/channel_op.py
+++ b/modules/channel_op.py
@@ -51,13 +51,12 @@ class Module(ModuleManager.BaseModule):
event["args_split"][1:])
def _format_hostmask(self, user, s):
- mask_split = s.split("$$")
- for i, mask_part in enumerate(mask_split):
- mask_split[i] = (mask_part.replace("$n", user.nickname)
- .replace("$u", user.username)
- .replace("$h", user.hostname)
- .replace("$a", user.account or ""))
- return "$".join(mask_split)
+ vars = {}
+ vars["n"] = vars["nickname"] = user.nickname
+ vars["u"] = vars["username"] = user.username
+ vars["h"] = vars["hostname"] = user.hostname
+ vars["a"] = vars["account"] = user.account or ""
+ return utils.parse.format_token_replace(s, vars)
def _get_hostmask(self, channel, user):
if not user.account == None:
account_format = channel.get_setting("ban-format-account", None)
@@ -123,13 +122,24 @@ class Module(ModuleManager.BaseModule):
self._kick(event["server"], event["target"], args[0], args[1:])
@utils.hook("received.command.op")
- @utils.hook("received.command.deop")
+ @utils.hook("received.command.up", alias_of="op")
@utils.kwarg("channel_only", True)
@utils.kwarg("require_mode", "o")
@utils.kwarg("require_access", "op")
@utils.kwarg("usage", "[nickname]")
def op(self, event):
- add = event["command"] == "op"
+ self._op(True, event)
+
+ @utils.hook("received.command.deop")
+ @utils.hook("received.command.down", alias_of="deop")
+ @utils.kwarg("channel_only", True)
+ @utils.kwarg("require_mode", "o")
+ @utils.kwarg("require_access", "op")
+ @utils.kwarg("usage", "[nickname]")
+ def deop(self, event):
+ self._op(False, event)
+
+ def _op(self, add, event):
target = event["args_split"][0] if event["args"] else event[
"user"].nickname
event["target"].send_mode("+o" if add else "-o", [target])
diff --git a/modules/define.py b/modules/define.py
index 4e83c65c..6bc37774 100644
--- a/modules/define.py
+++ b/modules/define.py
@@ -36,6 +36,11 @@ class Module(ModuleManager.BaseModule):
word = event["args"]
else:
word = event["target"].buffer.get(from_self=False)
+ if word:
+ word = word.message
+
+ if not word:
+ raise utils.EventError("No phrase provided")
word = word.replace(" ", "+")
success, definition = self._get_definition(word)
diff --git a/modules/dice.py b/modules/dice.py
index cf4f53a2..8e34c9e0 100644
--- a/modules/dice.py
+++ b/modules/dice.py
@@ -4,7 +4,8 @@ import random, re
from src import ModuleManager, utils
ERROR_FORMAT = "Incorrect format! Format must be [number]d[number], e.g. 1d20"
-RE_DICE = re.compile("^([1-9]\d*)?d([1-9]\d*)((?:[-+][1-9]\d{,2})*)$", re.I)
+RE_DICE = re.compile("^([1-9]\d*)?d([1-9]\d*)((?:\s*[-+][1-9]\d{,2})*)\s*$",
+ re.I)
RE_MODIFIERS = re.compile("([-+]\d+)")
MAX_DICE = 6
@@ -18,7 +19,7 @@ class Module(ModuleManager.BaseModule):
def roll_dice(self, event):
args = None
if event["args_split"]:
- args = event["args_split"][0]
+ args = event["args"]
else:
args = "1d6"
@@ -27,7 +28,8 @@ class Module(ModuleManager.BaseModule):
roll = match.group(0)
dice_count = int(match.group(1) or "1")
side_count = int(match.group(2))
- modifiers = RE_MODIFIERS.findall(match.group(3))
+ modifiers_str = "".join(match.group(3).split())
+ modifiers = RE_MODIFIERS.findall(modifiers_str)
if dice_count > 6:
raise utils.EventError("Max number of dice is %s" % MAX_DICE)
diff --git a/modules/eval_python.py b/modules/eval_python.py
index 5454b7d3..eb2b5898 100644
--- a/modules/eval_python.py
+++ b/modules/eval_python.py
@@ -21,6 +21,6 @@ class Module(ModuleManager.BaseModule):
if page and page.data:
event["stdout"].write("%s: %s" % (event["user"].nickname,
- page.decode().rstrip("\n")))
+ page.decode("utf8").rstrip("\n")))
else:
event["stderr"].write("%s: failed to eval" % event["user"].nickname)
diff --git a/modules/git_webhooks/github.py b/modules/git_webhooks/github.py
index 20a3fb74..96dc26c3 100644
--- a/modules/git_webhooks/github.py
+++ b/modules/git_webhooks/github.py
@@ -39,12 +39,14 @@ EVENT_CATEGORIES = {
"pull_request_review_comment/deleted"
],
"issue-minimal": [
- "issues/opened", "issues/closed", "issues/reopened", "issues/deleted"
+ "issues/opened", "issues/closed", "issues/reopened", "issues/deleted",
+ "issues/transferred"
],
"issue": [
"issues/opened", "issues/closed", "issues/reopened", "issues/deleted",
"issues/edited", "issues/assigned", "issues/unassigned",
- "issues/locked", "issues/unlocked", "issue_comment"
+ "issues/locked", "issues/unlocked", "issues/transferred",
+ "issue_comment",
],
"issue-all": [
"issues", "issue_comment"
diff --git a/modules/healthcheck.py b/modules/healthcheck.py
index 0cf2c72b..3b5acfe2 100644
--- a/modules/healthcheck.py
+++ b/modules/healthcheck.py
@@ -9,4 +9,9 @@ class Module(ModuleManager.BaseModule):
@utils.hook("cron")
@utils.kwarg("schedule", "*/10")
def ten_minutes(self, event):
- utils.http.request(self.bot.config["healthcheck-url"])
+ url = self.bot.config["healthcheck-url"]
+ try:
+ utils.http.request(url)
+ except Exception as e:
+ self.log.error("Failed to cal healthcheck-url (%s)", [url],
+ exc_info=True)
diff --git a/modules/hostmask_tracking.py b/modules/hostmask_tracking.py
new file mode 100644
index 00000000..5cf82232
--- /dev/null
+++ b/modules/hostmask_tracking.py
@@ -0,0 +1,40 @@
+from src import ModuleManager, utils
+
+class Module(ModuleManager.BaseModule):
+ _name = "Hostmasks"
+
+ @utils.hook("new.user")
+ def new_user(self, event):
+ userhost = event["user"].userhost()
+ if not userhost == None:
+ known_hostmasks = event["user"].get_setting("known-hostmasks", [])
+ if not userhost in known_hostmasks:
+ known_hostmasks.append(userhost)
+ event["user"].set_setting("known-hostmasks", known_hostmasks)
+
+ @utils.hook("received.command.maskfind")
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("help", "Find all nicknames that used a given hostmask")
+ @utils.kwarg("usage", "<hostmask>")
+ @utils.kwarg("permission", "maskfind")
+ def maskfind(self, event):
+ all_userhosts = event["server"].get_all_user_settings("known-hostmasks")
+ nicknames = set([])
+ hostmask_str = event["args_split"][0]
+ hostmask = utils.irc.hostmask_parse(hostmask_str)
+
+ searched = 0
+ for nickname, userhosts in all_userhosts:
+ searched += len(userhosts)
+ for userhost in userhosts:
+ if hostmask.match(userhost):
+ nicknames.add((nickname, userhost))
+
+ if nicknames:
+ outs = []
+ for nickname, userhost in sorted(nicknames):
+ outs.append("%s (%s)" % (utils.irc.bold(nickname), userhost))
+ event["stdout"].write("%s (%d/%d): %s" %
+ (hostmask_str, len(nicknames), searched, ", ".join(outs)))
+ else:
+ event["stderr"].write("Hostmask not found")
diff --git a/modules/ircv3_typing.py b/modules/ircv3_typing.py
index ca2fce2b..3c8c9c6d 100644
--- a/modules/ircv3_typing.py
+++ b/modules/ircv3_typing.py
@@ -9,10 +9,13 @@ class Module(ModuleManager.BaseModule):
def _has_tags(self, server):
return server.has_capability(CAP)
+ def _expect_output(self, event):
+ kwarg = event["hook"].get_kwarg("expect_output", None)
+ return kwarg if not kwarg is None else event["expect_output"]
+
@utils.hook("preprocess.command")
def preprocess(self, event):
- if (self._has_tags(event["server"]) and
- event["hook"].get_kwarg("expect_output", True)):
+ if self._has_tags(event["server"]) and self._expect_output(event):
event["target"]._typing = True
event["server"].send(self._tagmsg(event["target_str"], "active"),
immediate=True)
diff --git a/modules/karma.py b/modules/karma.py
index f728e7e1..194bd424 100644
--- a/modules/karma.py
+++ b/modules/karma.py
@@ -7,7 +7,7 @@ from src import EventManager, ModuleManager, utils
KARMA_DELAY_SECONDS = 3
-REGEX_WORD = re.compile(r"^([^(\s,:]+)(?:[:,]\s*)?(\+\+|--)\s*$")
+REGEX_WORD = re.compile(r"^([^(\s,:]+)(?:[:,])?\s*(\+\+|--)\s*$")
REGEX_WORD_START = re.compile(r"^(\+\+|--)(?:\s*)([^(\s,:]+)\s*$")
REGEX_PARENS = re.compile(r"\(([^)]+)\)(\+\+|--)")
@@ -68,8 +68,8 @@ class Module(ModuleManager.BaseModule):
karma_total = self._karma_str(self._get_karma(server, target))
- return True, "%s has given %s %s karma (%s total)" % (
- sender.nickname, target, karma_str, karma_total)
+ return True, "%s now has %s karma (%s from %s)" % (
+ target, karma_total, karma_str, sender.nickname)
@utils.hook("command.regex", pattern=REGEX_WORD)
@utils.hook("command.regex", pattern=REGEX_PARENS)
diff --git a/modules/quotes.py b/modules/quotes.py
index 7a1c0939..f6dd223f 100644
--- a/modules/quotes.py
+++ b/modules/quotes.py
@@ -14,10 +14,10 @@ class Module(ModuleManager.BaseModule):
return category, None
return category, quote.strip()
- def _get_quotes(self, server, category):
- return server.get_setting("quotes-%s" % category, [])
- def _set_quotes(self, server, category, quotes):
- server.set_setting("quotes-%s" % category, quotes)
+ def _get_quotes(self, target, category):
+ return target.get_setting("quotes-%s" % category, [])
+ def _set_quotes(self, target, category, quotes):
+ target.set_setting("quotes-%s" % category, quotes)
@utils.hook("received.command.qadd", alias_of="quoteadd")
@utils.hook("received.command.quoteadd", min_args=1)
@@ -39,6 +39,9 @@ class Module(ModuleManager.BaseModule):
else:
event["stderr"].write("Please provide a category AND quote")
+ def _target_zip(self, target, quotes):
+ return [[u, t, q, target] for u, t, q in quotes]
+
@utils.hook("received.command.qdel", alias_of="quotedel")
@utils.hook("received.command.quotedel", min_args=1)
@utils.kwarg("help", "Delete a given quote from a given category")
@@ -48,27 +51,34 @@ class Module(ModuleManager.BaseModule):
category = category or event["args"].strip()
message = None
- setting = "quotes-%s" % category
- quotes = event["server"].get_setting(setting, [])
+ quotes = self._target_zip(event["server"],
+ self._get_quotes(event["server"], category))
+ if event["is_channel"]:
+ quotes += self._target_zip(event["target"],
+ self._get_quotes(event["target"], category))
+ quotes = sorted(quotes, key=lambda q: q[1])
if not quotes:
raise utils.EventError("Quote category '%s' not found" %
category)
+ found_target = None
if not remove_quote == None:
remove_quote_lower = remove_quote.lower()
- for nickname, time_added, quote in quotes[:]:
+ for nickname, time_added, quote, target in quotes[:]:
if quote.lower() == remove_quote_lower:
quotes.remove([nickname, time_added, quote])
+ found_target = target
message = "Removed quote from '%s'"
break
else:
if quotes:
- quotes.pop(-1)
+ quote = quotes.pop(-1)
+ found_target = quote[-1]
message = "Removed last '%s' quote"
if not message == None:
- event["server"].set_setting(setting, quotes)
+ self._set_quotes(found_target, category, quotes)
event["stdout"].write(message % category)
else:
event["stderr"].write("Quote not found")
diff --git a/modules/relay.py b/modules/relay.py
index 9e266043..4719ee85 100644
--- a/modules/relay.py
+++ b/modules/relay.py
@@ -81,8 +81,6 @@ class Module(ModuleManager.BaseModule):
@utils.kwarg("min_args", 1)
@utils.kwarg("help", "Edit configured relay groups")
@utils.kwarg("usage", "list")
- @utils.kwarg("usage", "add <name>")
- @utils.kwarg("usage", "remove <name>")
@utils.kwarg("usage", "join <name>")
@utils.kwarg("usage", "leave <name>")
@utils.kwarg("permission", "relay")
diff --git a/modules/sed.py b/modules/sed.py
index f3fe4864..af597f81 100644
--- a/modules/sed.py
+++ b/modules/sed.py
@@ -73,7 +73,12 @@ class Module(ModuleManager.BaseModule):
if match:
replace = sed_split[2]
replace = replace.replace("\\/", "/")
- replace = re.sub(SED_AMPERSAND, "\\1%s" % match, replace)
+
+ with utils.deadline():
+ for found in SED_AMPERSAND.finditer(replace):
+ found = found.group(1)
+ replace.replace(found, "%s%s" % (found, match))
+
replace_color = utils.irc.bold(replace)
new_message = re.sub(pattern, replace, message, count)
diff --git a/modules/user_time.py b/modules/user_time.py
index 19ed4b22..48077d81 100644
--- a/modules/user_time.py
+++ b/modules/user_time.py
@@ -15,6 +15,9 @@ class LocationType(enum.Enum):
class Module(ModuleManager.BaseModule):
_name = "Time"
+ def on_load(self):
+ self.exports.add("time-localise", self.time_localise)
+
def _find_setting(self, event):
query = None
target_user = None
@@ -41,6 +44,15 @@ class Module(ModuleManager.BaseModule):
else:
return LocationType.NAME, event["args"], None
+ def _timezoned(self, dt, timezone):
+ dt = dt.astimezone(pytz.timezone(timezone))
+ utc_offset = (dt.utcoffset().total_seconds()/60)/60
+ tz = "UTC"
+ if not utc_offset == 0.0:
+ if utc_offset > 0:
+ tz += "+"
+ tz += "%g" % utc_offset
+ return "%s %s" % (utils.datetime.datetime_human(dt), tz)
@utils.hook("received.command.time")
@utils.kwarg("help", "Get the time for you or someone else")
@@ -51,20 +63,13 @@ class Module(ModuleManager.BaseModule):
type, name, timezone = self._find_setting(event)
if not timezone == None:
- dt = datetime.datetime.now(tz=pytz.timezone(timezone))
- utc_offset = (dt.utcoffset().total_seconds()/60)/60
- tz = "UTC"
- if not utc_offset == 0.0:
- if utc_offset > 0:
- tz += "+"
- tz += "%g" % utc_offset
- human = utils.datetime.datetime_human(dt)
+ human = self._timezoned(datetime.datetime.now(), timezone)
out = None
if type == LocationType.USER:
- out = "Time for %s: %s %s" % (name, human, tz)
+ out = "Time for %s: %s" % (name, human)
else:
- out = "It is %s in %s %s" % (human, name, tz)
+ out = "It is %s in %s" % (human, name)
event["stdout"].write(out)
else:
out = None
@@ -74,3 +79,10 @@ class Module(ModuleManager.BaseModule):
out = NOLOCATION_NAME
event["stderr"].write(out % name)
+
+ def time_localise(self, user, dt):
+ location = user.get_setting("location", None)
+ timezone = "UTC"
+ if not location == None:
+ timezone = location["timezone"]
+ return self._timezoned(dt, timezone)
diff --git a/modules/words.py b/modules/words.py
index c76fb3dc..716b7437 100644
--- a/modules/words.py
+++ b/modules/words.py
@@ -129,6 +129,12 @@ class Module(ModuleManager.BaseModule):
else:
event["stderr"].write("That word is not being tracked")
+ def _get_nickname(self, server, target, nickname):
+ nickname = server.get_user(nickname).nickname
+ if target.get_setting("wordiest-prevent-highlight", True):
+ nickname = utils.prevent_highlight(nickname)
+ return nickname
+
@utils.hook("received.command.wordiest")
def wordiest(self, event):
"""
@@ -137,8 +143,13 @@ class Module(ModuleManager.BaseModule):
"""
channel_query = None
word_prefix = ""
- if event["args_split"]:
- channel_query = event["args_split"][0].lower()
+ if event["args"]:
+ if not event["args_split"][0] == "*":
+ channel_query = event["args_split"][0].lower()
+ elif event["is_channel"]:
+ channel_query = event["target"].name
+
+ if channel_query:
word_prefix = " (%s)" % channel_query
words = event["server"].find_all_user_channel_settings("words")
@@ -150,7 +161,7 @@ class Module(ModuleManager.BaseModule):
user_words[nickname] += word_count
top_10 = utils.top_10(user_words,
- convert_key=lambda nickname:
- event["server"].get_user(nickname).nickname)
+ convert_key=lambda nickname: self._get_nickname(
+ event["server"], event["target"], nickname))
event["stdout"].write("wordiest%s: %s" % (
word_prefix, ", ".join(top_10)))
diff --git a/src/Database.py b/src/Database.py
index 683de5a6..6c79bb1d 100644
--- a/src/Database.py
+++ b/src/Database.py
@@ -1,7 +1,8 @@
-import json, os, sqlite3, threading, time, typing
+import json, os, threading, time, typing, urllib.parse
from src import Logging, utils
-sqlite3.register_converter("BOOLEAN", lambda v: bool(int(v)))
+from .DatabaseEngines import DatabaseEngine, DatabaseEngineCursor
+from .DatabaseEngines import SQLite3Engine
class Table(object):
def __init__(self, database):
@@ -297,14 +298,21 @@ class UserChannelSettings(Table):
[user_id, channel_id, setting.lower()])
class Database(object):
- def __init__(self, log: "Logging.Log", location: str):
+ _engine: DatabaseEngine
+
+ def __init__(self, log: "Logging.Log", database: str):
+ db_parts = urllib.parse.urlparse(database)
+
+ if db_parts.scheme == "sqlite3":
+ self._engine = SQLite3Engine()
+ else:
+ raise ValueError("Unknown database engine '%s'" % db_parts.scheme)
+ self._engine.config(hostname=db_parts.hostname, port=db_parts.port,
+ path=db_parts.path, username=db_parts.username,
+ password=db_parts.password)
+ self._engine.connect()
+
self.log = log
- self.location = location
- self.database = sqlite3.connect(self.location,
- check_same_thread=False, isolation_level=None,
- detect_types=sqlite3.PARSE_DECLTYPES)
- self.database.execute("PRAGMA foreign_keys = ON")
- self._cursor = None
self._lock = threading.Lock()
self.make_servers_table()
@@ -325,13 +333,8 @@ class Database(object):
self.user_settings = UserSettings(self)
self.user_channel_settings = UserChannelSettings(self)
- def cursor(self):
- if self._cursor == None:
- self._cursor = self.database.cursor()
- return self._cursor
-
def _execute_fetch(self, query: str,
- fetch_func: typing.Callable[[sqlite3.Cursor], typing.Any],
+ fetch_func: typing.Callable[[DatabaseEngineCursor], typing.Any],
params: typing.List=[]):
if not utils.is_main_thread():
raise RuntimeError("Can't access Database outside of main thread")
@@ -339,7 +342,7 @@ class Database(object):
printable_query = " ".join(query.split())
start = time.monotonic()
- cursor = self.cursor()
+ cursor = self._engine.cursor()
with self._lock:
cursor.execute(query, params)
value = fetch_func(cursor)
@@ -360,10 +363,7 @@ class Database(object):
return self._execute_fetch(query, lambda cursor: None, params)
def has_table(self, table_name: str):
- result = self.execute_fetchone("""SELECT COUNT(*) FROM
- sqlite_master WHERE type='table' AND name=?""",
- [table_name])
- return result[0] == 1
+ return self._engine.has_table(table_name)
def make_servers_table(self):
if not self.has_table("servers"):
diff --git a/src/DatabaseEngines.py b/src/DatabaseEngines.py
new file mode 100644
index 00000000..165e64d5
--- /dev/null
+++ b/src/DatabaseEngines.py
@@ -0,0 +1,64 @@
+import dataclasses, typing
+import sqlite3
+
+class DatabaseEngineCursor(object):
+ def execute(self, query: str, args: typing.List[str]):
+ pass
+ def fetchone(self) -> typing.Any:
+ pass
+ def fetchall(self) -> typing.List[typing.Any]:
+ pass
+
+class DatabaseEngine(object):
+ def config(self, hostname: str=None, port: int=None, path: str=None,
+ username: str=None, password: str=None):
+ self.hostname = hostname
+ self.port = port
+ self.path = path
+ self.username = username
+ self.password = password
+
+ def database_name(self):
+ return self.path
+ def connect(self):
+ pass
+ def cursor(self) -> DatabaseEngineCursor:
+ pass
+ def has_table(self, name: str):
+ pass
+
+ def execute(self, query: str, args: typing.List[str]):
+ pass
+ def fetchone(self, query: str, args: typing.List[str]):
+ pass
+ def fetchall(self, query: str, args: typing.List[str]):
+ pass
+
+class SQLite3Cursor(DatabaseEngineCursor):
+ def __init__(self, cursor: sqlite3.Cursor):
+ self._cursor = cursor
+ def execute(self, query: str, args: typing.List[str]):
+ self._cursor.execute(query, args)
+ def fetchone(self):
+ return self._cursor.fetchone()
+ def fetchall(self):
+ return self._cursor.fetchall()
+class SQLite3Engine(DatabaseEngine):
+ _connection: sqlite3.Connection
+
+ def connect(self):
+ sqlite3.register_converter("BOOLEAN", lambda v: bool(int(v)))
+ self._connection = sqlite3.connect(self.path,
+ check_same_thread=False, isolation_level=None,
+ detect_types=sqlite3.PARSE_DECLTYPES)
+ self._connection.execute("PRAGMA foreign_keys = ON")
+
+ def has_table(self, name: str):
+ cursor = self.cursor()
+ cursor.execute(
+ "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
+ [name])
+ return cursor.fetchone()[0] == 1
+
+ def cursor(self):
+ return SQLite3Cursor(self._connection.cursor())
diff --git a/src/core_modules/admin.py b/src/core_modules/admin.py
index 364380d4..3a92dd39 100644
--- a/src/core_modules/admin.py
+++ b/src/core_modules/admin.py
@@ -33,18 +33,22 @@ class Module(ModuleManager.BaseModule):
event["stderr"].write("Line was filtered")
@utils.hook("received.command.part")
+ @utils.kwarg("help", "Part from the current or given channel")
+ @utils.kwarg("usage", "[channel]")
def part(self, event):
- """
- :help: Part from the current or given channel
- :usage: [channel]
- :permission: part
- """
+ check = utils.Check("permission", "part")
+
if event["args"]:
target = event["args_split"][0]
elif event["is_channel"]:
target = event["target"].name
+ check |= utils.Check("channel-mode", "high")
+ check |= utils.Check("channel-access", "part")
else:
event["stderr"].write("No channel provided")
+
+ event["check_assert"](check)
+
event["server"].send_part(target)
def _id_from_alias(self, alias):
diff --git a/src/core_modules/aliases.py b/src/core_modules/aliases.py
index f1648fea..277a4922 100644
--- a/src/core_modules/aliases.py
+++ b/src/core_modules/aliases.py
@@ -1,30 +1,17 @@
#--depends-on commands
-import re
from src import EventManager, ModuleManager, utils
-REGEX_ARG_NUMBER = re.compile(r"\$(?:(\d+)(-?)|(-))")
SETTING_PREFIX = "command-alias-"
class Module(ModuleManager.BaseModule):
- def _arg_replace(self, s, args_split):
- parts = s.split("$$")
- for i, part in enumerate(parts):
- for match in REGEX_ARG_NUMBER.finditer(s):
- if match.group(1):
- index = int(match.group(1))
- continuous = match.group(2) == "-"
- if index >= len(args_split):
- raise IndexError("Unknown alias arg index")
- else:
- index = 0
- continuous = True
-
- if continuous:
- replace = " ".join(args_split[index:])
- else:
- replace = args_split[index]
- parts[i] = part.replace(match.group(0), replace)
- return "$".join(parts)
+ def _arg_replace(self, s, args_split, kwargs):
+ vars = {}
+ for i in range(len(args_split)):
+ vars[str(i)] = args_split[i]
+ vars["%d-" % i] = " ".join(args_split[i:])
+ vars["-"] = " ".join(args_split)
+ vars.update(kwargs)
+ return utils.parse.format_token_replace(s, vars)
def _get_alias(self, server, target, command):
setting = "%s%s" % (SETTING_PREFIX, command)
@@ -54,9 +41,14 @@ class Module(ModuleManager.BaseModule):
event["command"].command)
if not alias == None:
alias, alias_args = alias
+
+ given_args = []
+ if event["command"].args:
+ given_args = event["command"].args.split(" ")
+
event["command"].command = alias
- event["command"].args = self._arg_replace(alias_args,
- event["command"].args.split(" "))
+ event["command"].args = self._arg_replace(alias_args, given_args,
+ {"NICK": event["user"].nickname})
@utils.hook("received.command.alias")
@utils.hook("received.command.balias")
diff --git a/src/core_modules/commands/__init__.py b/src/core_modules/commands/__init__.py
index a7b55f2b..b22541d5 100644
--- a/src/core_modules/commands/__init__.py
+++ b/src/core_modules/commands/__init__.py
@@ -73,11 +73,12 @@ class Module(ModuleManager.BaseModule):
server.get_setting(COMMAND_METHOD,
self.bot.get_setting(COMMAND_METHOD, default))).upper()
- def _find_command_hook(self, server, target, is_channel, command, args):
+ def _find_command_hook(self, server, target, is_channel, command, user,
+ args):
if not self.has_command(command):
command_event = CommandEvent(command, args)
self.events.on("get.command").call(command=command_event,
- server=server, target=target, is_channel=is_channel)
+ server=server, target=target, is_channel=is_channel, user=user)
command = command_event.command
args = command_event.args
@@ -303,7 +304,8 @@ class Module(ModuleManager.BaseModule):
if command:
try:
hook, command, args_split = self._find_command_hook(
- event["server"], event["channel"], True, command, args)
+ event["server"], event["channel"], True, command,
+ event["user"], args)
except BadContextException:
event["channel"].send_message(
"%s: That command is not valid in a channel" %
@@ -318,7 +320,7 @@ class Module(ModuleManager.BaseModule):
self.command(event["server"], event["channel"],
event["target_str"], True, event["user"], command,
args_split, event["line"], hook,
- command_prefix=command_prefix,
+ command_prefix=command_prefix, expect_output=True,
buffer_line=event["buffer_line"])
else:
self.events.on("unknown.command").call(server=event["server"],
@@ -330,6 +332,9 @@ class Module(ModuleManager.BaseModule):
for hook in regex_hooks:
if event["action"] and hook.get_kwarg("ignore_action", True):
continue
+ if event["statusmsg"] and not hook.get_kwarg("statusmsg", False
+ ):
+ continue
pattern = hook.get_kwarg("pattern", None)
if pattern:
@@ -340,7 +345,7 @@ class Module(ModuleManager.BaseModule):
event["target_str"], True, event["user"], command,
"", event["line"], hook, match=match,
message=event["message"], command_prefix="",
- action=event["action"],
+ action=event["action"], expect_output=False,
buffer_line=event["buffer_line"])
if res:
@@ -361,7 +366,8 @@ class Module(ModuleManager.BaseModule):
try:
hook, command, args_split = self._find_command_hook(
- event["server"], event["user"], False, command, args)
+ event["server"], event["user"], False, command,
+ event["user"], args)
except BadContextException:
event["user"].send_message(
"That command is not valid in a PM")
@@ -371,7 +377,7 @@ class Module(ModuleManager.BaseModule):
self.command(event["server"], event["user"],
event["user"].nickname, False, event["user"], command,
args_split, event["line"], hook, command_prefix="",
- buffer_line=event["buffer_line"])
+ buffer_line=event["buffer_line"], expect_output=True)
else:
self.events.on("unknown.command").call(server=event["server"],
target=event["user"], user=event["user"], command=command,
diff --git a/src/core_modules/cron.py b/src/core_modules/cron.py
index fa6b15a3..69e0105b 100644
--- a/src/core_modules/cron.py
+++ b/src/core_modules/cron.py
@@ -4,7 +4,8 @@ from src import ModuleManager, utils
class Module(ModuleManager.BaseModule):
def on_load(self):
now = datetime.datetime.utcnow()
- next_minute = now.replace(minute=now.minute+1, second=0, microsecond=0)
+ next_minute = now.replace(second=0, microsecond=0)
+ next_minute += datetime.timedelta(minutes=1)
until = time.time()+((next_minute-now).total_seconds())
self.timers.add("cron", self._minute, 60, until)
diff --git a/src/core_modules/line_handler/channel.py b/src/core_modules/line_handler/channel.py
index 91150839..0def7828 100644
--- a/src/core_modules/line_handler/channel.py
+++ b/src/core_modules/line_handler/channel.py
@@ -123,8 +123,9 @@ def handle_324(events, event):
args_str=args)
def handle_329(event):
- channel = event["server"].channels.get(event["line"].args[1])
- channel.creation_timestamp = int(event["line"].args[2])
+ if event["line"].args[1] in event["server"].channels:
+ channel = event["server"].channels.get(event["line"].args[1])
+ channel.creation_timestamp = int(event["line"].args[2])
def handle_477(timers, event):
pass
diff --git a/src/core_modules/line_handler/core.py b/src/core_modules/line_handler/core.py
index d72bf223..60bde125 100644
--- a/src/core_modules/line_handler/core.py
+++ b/src/core_modules/line_handler/core.py
@@ -51,7 +51,7 @@ def handle_005(events, event):
event["server"].channel_modes = list(modes[3])
if "CHANTYPES" in isupport:
event["server"].channel_types = list(isupport["CHANTYPES"])
- if "CASEMAPPING" in isupport:
+ if "CASEMAPPING" in isupport and isupport["CASEMAPPING"]:
event["server"].case_mapping = isupport["CASEMAPPING"]
if "STATUSMSG" in isupport:
event["server"].statusmsg = list(isupport["STATUSMSG"])
diff --git a/src/core_modules/line_handler/message.py b/src/core_modules/line_handler/message.py
index fa36dbc2..035116b6 100644
--- a/src/core_modules/line_handler/message.py
+++ b/src/core_modules/line_handler/message.py
@@ -41,7 +41,13 @@ def message(events, event):
# strip prefix_symbols from the start of target, for when people use
# e.g. 'PRIVMSG +#channel :hi' which would send a message to only
# voiced-or-above users
- target = target_str.lstrip("".join(event["server"].statusmsg))
+ statusmsg = ""
+ for char in target_str:
+ if char in event["server"].statusmsg:
+ statusmsg += char
+ else:
+ break
+ target = target_str.replace(statusmsg, "", 1)
is_channel = event["server"].is_channel(target)
@@ -54,7 +60,8 @@ def message(events, event):
kwargs = {"server": event["server"], "target": target_obj,
"target_str": target_str, "user": user, "tags": event["line"].tags,
- "is_channel": is_channel, "from_self": from_self, "line": event["line"]}
+ "is_channel": is_channel, "from_self": from_self, "line": event["line"],
+ "statusmsg": statusmsg}
action = False
diff --git a/src/core_modules/nick_regain.py b/src/core_modules/nick_regain.py
index cf1dfa48..7a05988c 100644
--- a/src/core_modules/nick_regain.py
+++ b/src/core_modules/nick_regain.py
@@ -2,8 +2,8 @@ from src import ModuleManager, utils
class Module(ModuleManager.BaseModule):
def _done_connecting(self, server):
- target_nick = server.connection_params.nickname
- if not server.irc_equals(server.nickname, target_nick):
+ target_nick = self._target(server)
+ if not self._regained(server, target_nick):
if "MONITOR" in server.isupport:
server.send_raw("MONITOR + %s" % target_nick)
else:
@@ -17,16 +17,34 @@ class Module(ModuleManager.BaseModule):
def no_motd(self, event):
self._done_connecting(event["server"])
+ def _regained(self, server, target_nickname):
+ return server.irc_equals(target_nickname, server.nickname)
+ def _target(self, server):
+ return server.connection_params.nickname
+
@utils.hook("self.nick")
def self_nick(self, event):
- target_nick = event["server"].connection_params.nickname
- if event["server"].irc_equals(event["new_nickname"], target_nick):
+ target_nick = self._target(event["server"])
+ if self._regained(event["server"], target_nick):
if "MONITOR" in event["server"].isupport:
- event["server"].send_raw("MONITOR - %s " % target_nick)
+ event["server"].send_raw("MONITOR - %s" % target_nick)
+
+ @utils.hook("received.nick")
+ def nick(self, event):
+ self._check(event["server"], event["old_nickname"])
+ @utils.hook("received.quit")
+ def quit(self, event):
+ self._check(event["server"], event["user"].nickname)
+
+ def _check(self, server, nickname):
+ target_nick = self._target(server)
+ if (not self._regained(server, target_nick)
+ and server.irc_equals(nickname, target_nick)):
+ server.send_nick(target_nick)
@utils.hook("received.731")
def mon_offline(self, event):
- target_nick = event["server"].connection_params.nickname
+ target_nick = self._target(event["server"])
nicks = event["line"].args[1].split(",")
nicks = [event["server"].irc_lower(n) for n in nicks]
if event["server"].irc_lower(target_nick) in nicks:
@@ -34,15 +52,15 @@ class Module(ModuleManager.BaseModule):
def _ison_check(self, timer):
server = timer.kwargs["server"]
- target_nick = server.connection_params.nickname
- if not server.irc_equals(server.nickname, target_nick):
+ target_nick = self._target(server)
+ if not self._regained(server, target_nick):
server.send_raw("ISON %s" % target_nick)
timer.redo()
@utils.hook("received.303")
def ison_response(self, event):
- target_nick = event["server"].connection_params.nickname
- if not event["line"].args[1] and not event["server"].irc_equals(
- event["server"].nickname, target_nick):
+ target_nick = self._target(event["server"])
+ if (not event["line"].args[1] and
+ not self._regained(event["server"], target_nick)):
event["server"].send_nick(target_nick)
diff --git a/src/core_modules/permissions/__init__.py b/src/core_modules/permissions/__init__.py
index 0559774c..0c591ebe 100644
--- a/src/core_modules/permissions/__init__.py
+++ b/src/core_modules/permissions/__init__.py
@@ -2,10 +2,11 @@
import base64, binascii, os
import scrypt
-from src import ModuleManager, utils
+from src import EventManager, ModuleManager, utils
HOSTMASKS_SETTING = "hostmask-account"
NO_PERMISSION = "You do not have permission to do that"
+ACCOUNT_TAG = utils.irc.MessageTag("account")
class Module(ModuleManager.BaseModule):
def on_load(self):
@@ -56,6 +57,8 @@ class Module(ModuleManager.BaseModule):
def _has_identified(self, server, user, account):
user._id_override = server.get_user_id(account)
+ self.events.on("internal.identified").call(server=server, user=user,
+ accunt=account)
def _is_identified(self, user):
return not user._id_override == None
def _signout(self, user):
@@ -117,6 +120,13 @@ class Module(ModuleManager.BaseModule):
event["user"].account)
else:
self._set_hostmask(event["server"], event["user"])
+ @utils.hook("received.message.private")
+ @utils.hook("received.message.channel")
+ @utils.kwarg("priority", EventManager.PRIORITY_HIGH)
+ def account_tag(self, event):
+ account = ACCOUNT_TAG.get_value(event["line"].tags)
+ if not account == None:
+ self._has_identified(event["server"], event["user"], account)
def _get_permissions(self, user):
if self._is_identified(user):
@@ -219,8 +229,6 @@ class Module(ModuleManager.BaseModule):
event["stdout"].write("Correct password, you have "
"been identified as %s." % account)
- self.events.on("internal.identified").call(
- user=event["user"])
else:
event["stderr"].write("Incorrect password for '%s'" %
account)
@@ -307,7 +315,7 @@ class Module(ModuleManager.BaseModule):
hostmasks.append(hostmask)
event["user"].set_setting(HOSTMASKS_SETTING, hostmasks)
- hostmask_obj = utils.irc.hostmask_parse(hostmaks)
+ hostmask_obj = utils.irc.hostmask_parse(hostmask)
self._specific_hostmask(event["server"], hostmask_obj, account)
self._add_hostmask(event["server"], hostmask_obj, account)
diff --git a/src/utils/http.py b/src/utils/http.py
index 8b43a753..7cdae077 100644
--- a/src/utils/http.py
+++ b/src/utils/http.py
@@ -33,7 +33,6 @@ USERAGENT = "Mozilla/5.0 (compatible; BitBot/%s; +%s)" % (
RESPONSE_MAX = (1024*1024)*100
SOUP_CONTENT_TYPES = ["text/html", "text/xml", "application/xml"]
-DECODE_CONTENT_TYPES = ["text/plain"]+SOUP_CONTENT_TYPES
UTF8_CONTENT_TYPES = ["application/json"]
class HTTPException(Exception):
@@ -99,7 +98,7 @@ class Request(object):
def get_headers(self) -> typing.Dict[str, str]:
headers = self.headers.copy()
if not "Accept-Language" in headers:
- headers["Accept-Language"] = "en-GB"
+ headers["Accept-Language"] = "en-GB,en;q=0.5"
if not "User-Agent" in headers:
headers["User-Agent"] = self.useragent or USERAGENT
if not "Content-Type" in headers and self.content_type:
diff --git a/src/utils/parse.py b/src/utils/parse.py
index ce2ee793..c0740785 100644
--- a/src/utils/parse.py
+++ b/src/utils/parse.py
@@ -120,3 +120,38 @@ def timed_args(args, min_args):
return time, args[1:]
return None, args
+def format_tokens(s: str, names: typing.List[str], sigil: str="$"
+ ) -> typing.List[typing.Tuple[int, str]]:
+ names = names.copy()
+ names.sort()
+ names.reverse()
+
+ i = 0
+ max = len(s)-1
+ sigil_found = False
+ tokens: typing.List[typing.Tuple[int, str]] = []
+
+ while i < max:
+ if s[i] == sigil:
+ i += 1
+ if not s[i] == sigil:
+ for name in names:
+ if len(name) <= (len(s)-i) and s[i:i+len(name)] == name:
+ tokens.append((i-1, "%s%s" % (sigil, name)))
+ i += len(name)
+ break
+ else:
+ tokens.append((i, "$"))
+ i += 1
+ return tokens
+
+def format_token_replace(s: str, vars: typing.Dict[str, str],
+ sigil: str="$") -> str:
+ vars = vars.copy()
+ vars.update({"": ""})
+ tokens = format_tokens(s, list(vars.keys()), sigil)
+ tokens.sort(key=lambda x: x[0])
+ tokens.reverse()
+ for i, token in tokens:
+ s = s[:i] + vars[token.replace(sigil, "", 1)] + s[i+len(token):]
+ return s