diff options
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: @@ -1 +1 @@ -1.17.2 +1.18.0-rc1 @@ -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 |
