diff --git a/botconfig.py.example b/botconfig.py.example index 8102987..f289399 100644 --- a/botconfig.py.example +++ b/botconfig.py.example @@ -22,11 +22,7 @@ CMD_CHAR = "!" SERVER_PASS = "{account}:{password}" OWNERS = ("unaffiliated/wolfbot_admin1",) # The comma is required at the end if there is only one owner. -ADMINS = ("unaffiliated/wolfbot_admin2", "unaffiliated/wolfbot_test*") - OWNERS_ACCOUNTS = ("1owner_acc",) -ADMINS_ACCOUNTS = ("1admin_acc", "2admin_acc") - ALLOWED_NORMAL_MODE_COMMANDS = [] # Debug mode commands to be allowed in normal mode OWNERS_ONLY_COMMANDS = [] # Commands that should only be allowed for owners, regardless of their original permissions diff --git a/messages/en.json b/messages/en.json index 930981f..936cd37 100644 --- a/messages/en.json +++ b/messages/en.json @@ -574,6 +574,7 @@ "account_not_in_stasis": "\u0002{0}\u0002 (Account: {1}) is not in stasis.", "currently_stasised": "Currently stasised: {0}", "noone_stasised": "Nobody is currently stasised.", + "stasis_cannot_increase": "Cannot increase stasis using fstasis; use fwarn instead.", "no_command_specified": "Error: No command specified. Did you mean \u0002-cmds\u0002?", "invalid_option": "Invalid option: {0}", "command_does_not_exist": "That command does not exist.", @@ -796,6 +797,69 @@ "villagergame_win": "Game over! The villagers come to their senses and realize there are actually no wolves, and live in harmony forevermore. Everybody wins.", "villagergame_nope": "Game over! The villagers decided incorrectly that there are actually no wolves, allowing the wolves to slaughter the remainder of them in their sleep with impunity.", "stop_bot_ingame_safeguard": "Warning: A game is currently running. If you want to {what} the bot anyway, use \"{prefix}{cmd} -force\".", + "fwarn_usage": "Usage: fwarn list|view|add|del|set|help. See fwarn help for more details.", + "warn_usage": "Usage: warn list|view|ack|help. See warn help for more details.", + "fwarn_list_syntax": "Usage: fwarn list [-all] [nick[!user@host]|=account] [page]", + "fwarn_view_syntax": "Usage: fwarn view ", + "fwarn_del_syntax": "Usage: fwarn del ", + "fwarn_set_syntax": "Usage: fwarn set [~expiry] [reason] [| notes]", + "fwarn_help_syntax": "Usage: fwarn help ", + "warn_list_syntax": "Usage: warn list [-all] [page]", + "warn_view_syntax": "Usage: warn view ", + "warn_ack_syntax": "Usage: warn ack ", + "warn_help_syntax": "Uwage: warn help ", + "fwarn_add_syntax": "Usage: fwarn add [@] [~expiry] [sanctions] <:reason> [| notes]", + "fwarn_page_invalid": "Invalid page, must be a number 1 or greater.", + "fwarn_points_invalid": "Invalid points, must be a number above 0.", + "fwarn_expiry_invalid": "Invalid expiration amount, must be a number above 0 or 'never' for a warning that never expires.", + "fwarn_expiry_invalid_suffix": "Invalid expiration suffix, must use either d, h, or m.", + "fwarn_cannot_add": "Cannot add warning, double-check your parameters (the nick might be wrong or you are not joined to the channel).", + "fwarn_added": "Added warning {0}.", + "fwarn_done": "Done.", + "fwarn_sanction_invalid": "Invalid sanction, can be either deny or stasis.", + "fwarn_stasis_invalid": "Invalid stasis amount, specify sanction as \"stasis=number\".", + "fwarn_deny_invalid": "Invalid denied commands, specify sanction as \"deny=command,command,command\" (without spaces).", + "fwarn_deny_invalid_command": "Invalid command \"{0}\", specify sanction as \"deny=command,command,command\" (without spaces).", + "fwarn_list_header": "{0} has {1} active warning points. Warnings prefixed with \u0002!\u0002 are unacknowledged.", + "warn_list_header": "You have {0} active warning points. You must acknowledge all warnings prefixed with \u0002!\u0002 by using \"warn ack \" before you can join games.", + "fwarn_list": "{0}{1}[#{2} {3}] to {4} by {5} - {6} ({7} points, {8}){9}", + "warn_list": "{0}{1}[#{2} {3}] {4} ({5} points, {6}){7}", + "fwarn_deleted": "deleted", + "fwarn_expired": "expired", + "fwarn_list_expired": "expired on {0}", + "fwarn_never_expires": "never expires", + "fwarn_list_footer": "More results are available, use fwarn list {0} to view them.", + "warn_list_footer": "More results are available, use warn list {0} to view them.", + "fwarn_list_empty": "No results.", + "fwarn_invalid_warning": "The specified warning id does not exist or you do not have permission to view it.", + "fwarn_view_header": "Warning #{0}, given to {1} on {2} by {3}. {4} points. {5}.", + "warn_view_header": "Warning #{0}, given on {1}. {2} points. {3}.", + "fwarn_view_active": "Currently active, {0}", + "fwarn_view_expires": "expires on {0}", + "fwarn_view_expired": "Expired on {0}", + "fwarn_view_deleted": "Deleted on {0} by {1}", + "fwarn_view_ack": "Warning has not yet been acknowledged.", + "warn_view_ack": "You have not yet acknowledge this warning. You must acknowledge this warning by using \"warn ack {0}\" before you can join games.", + "fwarn_view_sanctions": "Sanctions:", + "fwarn_view_stasis_sing": "1 game of stasis.", + "fwarn_view_stasis_plural": "{0} games of stasis.", + "fwarn_view_deny": "denied {0}.", + "fwarn_reason_required": "A public warning reason is required.", + "warn_unacked": "You have unacknowledged warnings and cannot join at this time. Use \"warn list\" to view them.", + "no_templates": "There are no access templates defined.", + "template_not_found": "There is no template named {0}.", + "template_set": "Set template {0} to flags +{1}.", + "template_deleted": "Removed template {0}. Any access entries using this template have also been deleted.", + "access_set_account": "Set access for account {0} to +{1}.", + "access_set_host": "Set access for host {0} to +{1}.", + "access_deleted_account": "Deleted access for account {0}.", + "access_deleted_host": "Deleted access for host {0}.", + "invalid_flag": "Invalid flag {0}. Valid flags are +{1}.", + "no_access_account": "Account {0} does not have any access.", + "access_account": "Account {0} has access +{1}.", + "no_access_host": "Host {0} does not have any access.", + "access_host": "Host {0} has access +{1}.", + "never_aliases": ["never", "infinite", "infinity", "permanent", "p"], "_": " vim: set sw=4 expandtab:" } diff --git a/src/__init__.py b/src/__init__.py index ac5b109..f011911 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -4,6 +4,7 @@ import time import botconfig import src.settings as var +from src import db # Segue to logger, since src.gamemodes requires it # TODO: throw this into a logger.py perhaps so we aren't breaking up imports with non-import stuff @@ -83,10 +84,6 @@ if args.normal: normal = True botconfig.DEBUG_MODE = debug_mode if not normal else False botconfig.VERBOSE_MODE = verbose if not normal else False -# Initialize Database - -var.init_db() - # Logger # replace characters that can't be encoded with '?' diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..009f0ac --- /dev/null +++ b/src/db.py @@ -0,0 +1,807 @@ +import botconfig +import src.settings as var +import sqlite3 +import os +import json +from collections import defaultdict + +# increment this whenever making a schema change so that the schema upgrade functions run on start +# they do not run by default for performance reasons +SCHEMA_VERSION = 1 + +def init_vars(): + with var.GRAVEYARD_LOCK: + c = conn.cursor() + c.execute("""SELECT + pl.account, + pl.hostmask, + pe.notice, + pe.simple, + pe.deadchat, + pe.pingif, + pe.stasis_amount, + pe.stasis_expires, + COALESCE(at.flags, a.flags) + FROM person pe + JOIN person_player pp + ON pp.person = pe.id + JOIN player pl + ON pl.id = pp.player + LEFT JOIN access a + ON a.person = pe.id + LEFT JOIN access_template at + ON at.id = a.template + WHERE pl.active = 1""") + + var.SIMPLE_NOTIFY = set() # cloaks of people who !simple, who don't want detailed instructions + var.SIMPLE_NOTIFY_ACCS = set() # same as above, except accounts. takes precedence + var.PREFER_NOTICE = set() # cloaks of people who !notice, who want everything /notice'd + var.PREFER_NOTICE_ACCS = set() # Same as above, except accounts. takes precedence + var.STASISED = defaultdict(int) + var.STASISED_ACCS = defaultdict(int) + var.PING_IF_PREFS = {} + var.PING_IF_PREFS_ACCS = {} + var.PING_IF_NUMS = defaultdict(set) + var.PING_IF_NUMS_ACCS = defaultdict(set) + var.DEADCHAT_PREFS = set() + var.DEADCHAT_PREFS_ACCS = set() + var.FLAGS = defaultdict(str) + var.FLAGS_ACCS = defaultdict(str) + var.DENY = defaultdict(set) + var.DENY_ACCS = defaultdict(set) + + for acc, host, notice, simple, dc, pi, stasis, stasisexp, flags in c: + if acc is not None: + if simple == 1: + var.SIMPLE_NOTIFY_ACCS.add(acc) + if notice == 1: + var.PREFER_NOTICE_ACCS.add(acc) + if stasis > 0: + var.STASISED_ACCS[acc] = stasis + if pi is not None and pi > 0: + var.PING_IF_PREFS_ACCS[acc] = pi + var.PING_IF_NUMS_ACCS[pi].add(acc) + if dc == 1: + var.DEADCHAT_PREFS_ACCS.add(acc) + if flags: + var.FLAGS_ACCS[acc] = flags + elif host is not None: + if simple == 1: + var.SIMPLE_NOTIFY.add(host) + if notice == 1: + var.PREFER_NOTICE.add(host) + if stasis > 0: + var.STASISED[host] = stasis + if pi is not None and pi > 0: + var.PING_IF_PREFS[host] = pi + var.PING_IF_NUMS[pi].add(host) + if dc == 1: + var.DEADCHAT_PREFS.add(host) + if flags: + var.FLAGS[host] = flags + + c.execute("""SELECT + pl.account, + pl.hostmask, + ws.data + FROM warning w + JOIN warning_sanction ws + ON ws.warning = w.id + JOIN person pe + ON pe.id = w.target + JOIN person_player pp + ON pp.person = pe.id + JOIN player pl + ON pl.id = pp.player + WHERE + ws.sanction = 'deny command' + AND w.deleted = 0 + AND ( + w.expires IS NULL + OR w.expires > datetime('now') + )""") + for acc, host, command in c: + if acc is not None: + var.DENY_ACCS[acc].add(command) + if host is not None: + var.DENY[host].add(command) + +def decrement_stasis(acc=None, hostmask=None): + peid, plid = _get_ids(acc, hostmask) + if (acc is not None or hostmask is not None) and peid is None: + return + sql = "UPDATE person SET stasis_amount = MAX(0, stasis_amount - 1)" + params = () + if peid is not None: + sql += " WHERE id = ?" + params = (peid,) + + with conn: + c = conn.cursor() + c.execute(sql, params) + +def decrease_stasis(newamt, acc=None, hostmask=None): + peid, plid = _get_ids(acc, hostmask) + if peid is None: + return + if newamt < 0: + newamt = 0 + + with conn: + c = conn.cursor() + c.execute("""UPDATE person + SET stasis_amount = MIN(stasis_amount, ?) + WHERE id = ?""", (newamt, peid)) + +def expire_stasis(): + with conn: + c = conn.cursor() + c.execute("""UPDATE person + SET + stasis_amount = 0, + stasis_expires = NULL + WHERE + stasis_expires IS NOT NULL + AND stasis_expires <= datetime('now')""") + +def get_template(name): + c = conn.cursor() + c.execute("SELECT id, flags FROM access_template WHERE name = ?", (name,)) + row = c.fetchone() + if row is None: + return (None, set()) + return (row[0], row[1]) + +def get_templates(): + c = conn.cursor() + c.execute("SELECT name, flags FROM access_template ORDER BY name ASC") + tpls = [] + for name, flags in c: + tpls.append((name, flags)) + return tpls + +def update_template(name, flags): + with conn: + tid, _ = get_template(name) + c = conn.cursor() + if tid is None: + c.execute("INSERT INTO access_template (name, flags) VALUES (?, ?)", (name, flags)) + else: + c.execute("UPDATE access_template SET flags = ? WHERE id = ?", (flags, tid)) + +def delete_template(name): + with conn: + tid, _ = get_template(name) + if tid is not None: + c = conn.cursor() + c.execute("DELETE FROM access WHERE template = ?", (tid,)) + c.execute("DELETE FROM template WHERE id = ?", (tid,)) + +def set_access(acc, hostmask, flags=None, tid=None): + peid, plid = _get_ids(acc, hostmask) + if peid is None: + return + with conn: + c = conn.cursor() + if flags is None and tid is None: + c.execute("DELETE FROM access WHERE person = ?", (peid,)) + elif tid is not None: + c.execute("""INSERT OR REPLACE INTO access + (person, template, flags) + VALUES (?, ?, NULL)""", (peid, tid)) + else: + c.execute("""INSERT OR REPLACE INTO access + (person, template, flags) + VALUES (?, NULL, ?)""", (peid, flags)) + +def toggle_simple(acc, hostmask): + _toggle_thing("simple", acc, hostmask) + +def toggle_notice(acc, hostmask): + _toggle_thing("notice", acc, hostmask) + +def toggle_deadchat(acc, hostmask): + _toggle_thing("deadchat", acc, hostmask) + +def set_pingif(val, acc, hostmask): + _set_thing("pingif", val, acc, hostmask, raw=False) + +def add_game(mode, size, started, finished, winner, players, options): + """ Adds a game record to the database. + + mode: Game mode (string) + size: Game size on start (int) + started: Time when game started (timestamp) + finished: Time when game ended (timestamp) + winner: Winning team (string) + players: List of players (sequence of dict, described below) + options: Game options (role reveal, stats type, etc., freeform dict) + + Players dict format: + { + nick: "Nickname" + account: "Account name" (or None, "*" is converted to None) + ident: "Ident" + host: "Host" + role: "role name" + templates: ["template names", ...] + special: ["special qualities", ... (lover, entranced, etc.)] + won: True/False + iwon: True/False + dced: True/False + } + """ + + if mode == "roles": + # Do not record stats for games with custom roles + return + + # Normalize players dict + for p in players: + if p["account"] == "*": + p["account"] = None + p["hostmask"] = "{0}!{1}@{2}".format(p["nick"], p["ident"], p["host"]) + c = conn.cursor() + p["personid"], p["playerid"] = _get_ids(p["account"], p["hostmask"], add=True) + with conn: + c = conn.cursor() + if winner.startswith("@"): + # fool won, convert the nick portion into a player id + for p in players: + if p["nick"] == winner[1:]: + winner = "@" + p["playerid"] + break + else: + # invalid winner? We can't find the fool's nick in the player list + # maybe raise an exception here instead of silently failing + return + + c.execute("""INSERT INTO game (gamemode, options, started, finished, gamesize, winner) + VALUES (?, ?, ?, ?, ?, ?)""", (mode, json.dumps(options), started, finished, size, winner)) + gameid = c.lastrowid + for p in players: + c.execute("""INSERT INTO game_player (game, player, team_win, indiv_win, dced) + VALUES (?, ?, ?, ?, ?)""", (gameid, p["playerid"], p["won"], p["iwon"], p["dced"])) + gpid = c.lastrowid + c.execute("""INSERT INTO game_player_role (game_player, role, special) + VALUES (?, ?, 0)""", (gpid, p["role"])) + for tpl in p["templates"]: + c.execute("""INSERT INTO game_player_role (game_player, role, special) + VALUES (?, ?, 0)""", (gpid, tpl)) + for sq in p["special"]: + c.execute("""INSERT INTO game_player_role (game_player, role, special) + VALUES (?, ?, 1)""", (gpid, sq)) + +def get_player_stats(acc, hostmask, role): + peid, plid = _get_ids(acc, hostmask) + if not _total_games(peid): + return "\u0002{0}\u0002 has not played any games.".format(acc if acc and acc != "*" else hostmask) + c = conn.cursor() + c.execute("""SELECT + gpr.role AS role, + SUM(gp.team_win) AS team, + SUM(gp.indiv_win) AS indiv, + COUNT(1) AS total + FROM person pe + JOIN person_player pmap + ON pmap.person = pe.id + JOIN game_player gp + ON gp.player = pmap.player + JOIN game_player_role gpr + ON gpr.game_player = gp.id + AND gpr.role = ? + WHERE pe.id = ? + GROUP BY role""", (role, peid)) + row = c.fetchone() + name = _get_display_name(peid) + if row: + msg = "\u0002{0}\u0002 as \u0002{1}\u0002 | Team wins: {2} (%d%%), Individual wins: {3} (%d%%), Total games: {4}.".format(name, *row) + return msg % (round(row[1]/row[3] * 100), round(row[2]/row[3] * 100)) + return "No stats for \u0002{0}\u0002 as \u0002{1}\u0002.".format(name, role) + +def get_player_totals(acc, hostmask): + peid, plid = _get_ids(acc, hostmask) + total_games = _total_games(peid) + if not total_games: + return "\u0002{0}\u0002 has not played any games.".format(acc if acc and acc != "*" else hostmask) + c = conn.cursor() + c.execute("""SELECT + gpr.role AS role, + COUNT(1) AS total + FROM person pe + JOIN person_player pmap + ON pmap.person = pe.id + JOIN game_player gp + ON gp.player = pmap.player + JOIN game_player_role gpr + ON gpr.game_player = gp.id + WHERE pe.id = ? + GROUP BY role""", (peid,)) + tmp = {} + totals = [] + for row in c: + tmp[row[0]] = row[1] + order = var.role_order() + name = _get_display_name(peid) + #ordered role stats + totals = ["\u0002{0}\u0002: {1}".format(r, tmp[r]) for r in order if r in tmp] + #lover or any other special stats + totals += ["\u0002{0}\u0002: {1}".format(r, t) for r, t in tmp.items() if r not in order] + return "\u0002{0}\u0002's totals | \u0002{1}\u0002 games | {2}".format(name, total_games, var.break_long_message(totals, ", ")) + +def get_game_stats(mode, size): + c = conn.cursor() + c.execute("SELECT COUNT(1) FROM game WHERE gamemode = ? AND gamesize = ?", (mode, size)) + total_games = c.fetchone()[0] + if not total_games: + return "No stats for \u0002{0}\u0002 player games.".format(size) + c.execute("""SELECT + CASE substr(winner, 1, 1) + WHEN '@' THEN 'fools' + ELSE winner END AS team, + COUNT(1) AS games, + CASE winner + WHEN 'villagers' THEN 0 + WHEN 'wolves' THEN 1 + ELSE 2 END AS ord + FROM game + WHERE + gamemode = ? + AND gamesize = ? + AND winner IS NOT NULL + GROUP BY team + ORDER BY ord ASC, team ASC""", (mode, size)) + msg = "\u0002{0}\u0002 player games | {1}" + bits = [] + for row in c: + bits.append("%s wins: %d (%d%%)" % (var.singular(row[0]), row[1], round(row[1]/total_games * 100))) + bits.append("total games: {0}".format(total_games)) + return msg.format(size, ", ".join(bits)) + +def get_game_totals(mode): + c = conn.cursor() + c.execute("SELECT COUNT(1) FROM game WHERE gamemode = ?", (mode,)) + total_games = c.fetchone()[0] + if not total_games: + return "No games have been played in the {0} game mode.".format(mode) + c.execute("""SELECT + gamesize, + COUNT(1) AS games + FROM game + WHERE gamemode = ? + GROUP BY gamesize + ORDER BY gamesize ASC""", (mode,)) + totals = [] + for row in c: + totals.append("\u0002{0}p\u0002: {1}".format(*row)) + return "Total games ({0}) | {1}".format(total_games, ", ".join(totals)) + +def get_warning_points(acc, hostmask): + peid, plid = _get_ids(acc, hostmask) + c = conn.cursor() + c.execute("""SELECT COALESCE(SUM(amount), 0) + FROM warning + WHERE + target = ? + AND deleted = 0 + AND ( + expires IS NULL + OR expires > datetime('now') + )""", (peid,)) + row = c.fetchone() + return row[0] + +def has_unacknowledged_warnings(acc, hostmask): + peid, plid = _get_ids(acc, hostmask) + if peid is None: + return False + c = conn.cursor() + c.execute("""SELECT COALESCE(MIN(acknowledged), 1) + FROM warning + WHERE + target = ? + AND deleted = 0 + AND ( + expires IS NULL + OR expires > datetime('now') + )""", (peid,)) + row = c.fetchone() + return not bool(row[0]) + +def list_all_warnings(list_all=False, skip=0, show=0): + c = conn.cursor() + sql = """SELECT + warning.id, + COALESCE(plt.account, plt.hostmask) AS target, + COALESCE(pls.account, pls.hostmask, ?) AS sender, + warning.amount, + warning.issued, + warning.expires, + CASE WHEN warning.expires IS NULL OR warning.expires > datetime('now') + THEN 0 ELSE 1 END AS expired, + CASE WHEN warning.deleted + OR ( + warning.expires IS NOT NULL + AND warning.expires <= datetime('now') + ) + THEN 1 ELSE warning.acknowledged END AS acknowledged, + warning.deleted, + warning.reason + FROM warning + JOIN person pet + ON pet.id = warning.target + JOIN player plt + ON plt.id = pet.primary_player + LEFT JOIN person pes + ON pes.id = warning.sender + LEFT JOIN player pls + ON pls.id = pes.primary_player + """ + if not list_all: + sql += """WHERE + deleted = 0 + AND ( + expires IS NULL + OR expires > datetime('now') + ) + """ + sql += "ORDER BY warning.issued DESC\n" + if show > 0: + sql += "LIMIT {0} OFFSET {1}".format(show, skip) + + c.execute(sql, (botconfig.NICK,)) + warnings = [] + for row in c: + warnings.append({"id": row[0], + "target": row[1], + "sender": row[2], + "amount": row[3], + "issued": row[4], + "expires": row[5], + "expired": row[6], + "ack": row[7], + "deleted": row[8], + "reason": row[9]}) + return warnings + +def list_warnings(acc, hostmask, expired=False, deleted=False, skip=0, show=0): + peid, plid = _get_ids(acc, hostmask) + c = conn.cursor() + sql = """SELECT + warning.id, + COALESCE(plt.account, plt.hostmask) AS target, + COALESCE(pls.account, pls.hostmask, ?) AS sender, + warning.amount, + warning.issued, + warning.expires, + CASE WHEN warning.expires IS NULL OR warning.expires > datetime('now') + THEN 0 ELSE 1 END AS expired, + CASE WHEN warning.deleted + OR ( + warning.expires IS NOT NULL + AND warning.expires <= datetime('now') + ) + THEN 1 ELSE warning.acknowledged END AS acknowledged, + warning.deleted, + warning.reason + FROM warning + JOIN person pet + ON pet.id = warning.target + JOIN player plt + ON plt.id = pet.primary_player + LEFT JOIN person pes + ON pes.id = warning.sender + LEFT JOIN player pls + ON pls.id = pes.primary_player + WHERE + warning.target = ? + """ + if not deleted: + sql += " AND deleted = 0" + if not expired: + sql += """ AND ( + expires IS NULL + OR expires > datetime('now') + )""" + sql += " ORDER BY warning.issued DESC" + if show > 0: + sql += " LIMIT {0} OFFSET {1}".format(show, skip) + + c.execute(sql, (botconfig.NICK, peid)) + warnings = [] + for row in c: + warnings.append({"id": row[0], + "target": row[1], + "sender": row[2], + "amount": row[3], + "issued": row[4], + "expires": row[5], + "expired": row[6], + "ack": row[7], + "deleted": row[8], + "reason": row[9]}) + return warnings + +def get_warning(warn_id, acc=None, hm=None): + peid, plid = _get_ids(acc, hm) + c = conn.cursor() + sql = """SELECT + warning.id, + COALESCE(plt.account, plt.hostmask) AS target, + COALESCE(pls.account, pls.hostmask, ?) AS sender, + warning.amount, + warning.issued, + warning.expires, + CASE WHEN warning.expires IS NULL OR warning.expires > datetime('now') + THEN 0 ELSE 1 END AS expired, + warning.acknowledged, + warning.deleted, + warning.reason, + warning.notes, + COALESCE(pld.account, pld.hostmask) AS deleted_by, + warning.deleted_on + FROM warning + JOIN person pet + ON pet.id = warning.target + JOIN player plt + ON plt.id = pet.primary_player + LEFT JOIN person pes + ON pes.id = warning.sender + LEFT JOIN player pls + ON pls.id = pes.primary_player + LEFT JOIN person ped + ON ped.id = warning.deleted_by + LEFT JOIN player pld + ON pld.id = ped.primary_player + WHERE + warning.id = ? + """ + params = (botconfig.NICK, warn_id) + if acc is not None and hm is not None: + sql += """ AND warning.target = ? + AND warning.deleted = 0""" + params = (botconfig.NICK, warn_id, peid) + + c.execute(sql, params) + row = c.fetchone() + if not row: + return None + + return {"id": row[0], + "target": row[1], + "sender": row[2], + "amount": row[3], + "issued": row[4], + "expires": row[5], + "expired": row[6], + "ack": row[7], + "deleted": row[8], + "reason": row[9], + "notes": row[10], + "deleted_by": row[11], + "deleted_on": row[12], + "sanctions": get_warning_sanctions(warn_id)} + +def get_warning_sanctions(warn_id): + c = conn.cursor() + c.execute("SELECT sanction, data FROM warning_sanction WHERE warning=?", (warn_id,)) + sanctions = {} + for sanc, data in c: + if sanc == "stasis": + sanctions["stasis"] = int(data) + elif sanc == "deny command": + if "deny" not in sanctions: + sanctions["deny"] = set() + sanctions["deny"].add(data) + + return sanctions + +def add_warning(tacc, thm, sacc, shm, amount, reason, notes, expires, need_ack): + teid, tlid = _get_ids(tacc, thm) + seid, slid = _get_ids(sacc, shm) + ack = 0 if need_ack else 1 + with conn: + c = conn.cursor() + c.execute("""INSERT INTO warning + ( + target, sender, amount, + issued, expires, + reason, notes, + acknowledged + ) + VALUES + ( + ?, ?, ?, + datetime('now'), ?, + ?, ?, + ? + )""", (teid, seid, amount, expires, reason, notes, ack)) + return c.lastrowid + +def add_warning_sanction(warning, sanction, data): + with conn: + c = conn.cursor() + c.execute("""INSERT INTO warning_sanction + (warning, sanction, data) + VALUES + (?, ?, ?)""", (warning, sanction, data)) + + if sanction == "stasis": + c.execute("SELECT target FROM warning WHERE id = ?", (warning,)) + peid = c.fetchone()[0] + c.execute("""UPDATE person + SET + stasis_amount = stasis_amount + ?, + stasis_expires = datetime(CASE WHEN stasis_expires IS NULL + OR stasis_expires <= datetime('now') + THEN 'now' + ELSE stasis_expires END, + '+{0} hours') + WHERE id = ?""".format(int(data)), (data, peid)) + +def del_warning(warning, acc, hm): + peid, plid = _get_ids(acc, hm) + with conn: + c = conn.cursor() + c.execute("""UPDATE warning + SET + acknowledged = 1, + deleted = 1, + deleted_on = datetime('now'), + deleted_by = ? + WHERE + id = ? + AND deleted = 0""", (peid, warning)) + +def set_warning(warning, expires, reason, notes): + with conn: + c = conn.cursor() + c.execute("""UPDATE warning + SET reason = ?, notes = ?, expires = ? + WHERE id = ?""", (reason, notes, expires, warning)) + +def acknowledge_warning(warning): + with conn: + c = conn.cursor() + c.execute("UPDATE warning SET acknowledged = 1 WHERE id = ?", (warning,)) + +def _upgrade(): + # no upgrades yet, once there are some, add methods like _add_table(), _add_column(), etc. + # that check for the existence of that table/column/whatever and adds/drops/whatevers them + # as needed. We can't do this purely in SQL because sqlite lacks a scripting-level IF statement. + pass + +def _migrate(): + dn = os.path.dirname(__file__) + with conn, open(os.path.join(dn, "db.sql"), "rt") as f1, open(os.path.join(dn, "migrate.sql"), "rt") as f2: + c = conn.cursor() + ####################################################### + # Step 1: install the new schema (from db.sql script) # + ####################################################### + c.executescript(f1.read()) + + ################################################################ + # Step 2: migrate relevant info from the old schema to the new # + ################################################################ + c.executescript(f2.read()) + + ###################################################################### + # Step 3: Indicate we have updated the schema to the current version # + ###################################################################### + c.execute("PRAGMA user_version = " + str(SCHEMA_VERSION)) + +def _install(): + dn = os.path.dirname(__file__) + with conn, open(os.path.join(dn, "db.sql"), "rt") as f1: + c = conn.cursor() + c.executescript(f1.read()) + c.execute("PRAGMA user_version = " + str(SCHEMA_VERSION)) + +def _get_ids(acc, hostmask, add=False): + c = conn.cursor() + if acc == "*": + acc = None + if acc is None and hostmask is None: + return (None, None) + elif acc is None: + c.execute("""SELECT pe.id, pl.id + FROM player pl + JOIN person_player pp + ON pp.player = pl.id + JOIN person pe + ON pe.id = pp.person + WHERE + pl.account IS NULL + AND pl.hostmask = ? + AND pl.active = 1""", (hostmask,)) + else: + hostmask = None + c.execute("""SELECT pe.id, pl.id + FROM player pl + JOIN person_player pp + ON pp.player = pl.id + JOIN person pe + ON pe.id = pp.person + WHERE + pl.account = ? + AND pl.hostmask IS NULL + AND pl.active = 1""", (acc,)) + row = c.fetchone() + peid = None + plid = None + if row: + peid, plid = row + elif add: + with conn: + c.execute("INSERT INTO player (account, hostmask) VALUES (?, ?)", (acc, hostmask)) + plid = c.lastrowid + c.execute("INSERT INTO person (primary_player) VALUES (?)", (plid,)) + peid = c.lastrowid + c.execute("INSERT INTO person_player (person, player) VALUES (?, ?)", (peid, plid)) + return (peid, plid) + +def _get_display_name(peid): + if peid is None: + return None + c = conn.cursor() + c.execute("""SELECT COALESCE(pp.account, pp.hostmask) + FROM person pe + JOIN player pp + ON pp.id = pe.primary_player + WHERE pe.id = ?""", (peid,)) + return c.fetchone()[0] + +def _total_games(peid): + if peid is None: + return 0 + c = conn.cursor() + c.execute("""SELECT COUNT(DISTINCT gp.game) + FROM person pe + JOIN person_player pmap + ON pmap.person = pe.id + JOIN game_player gp + ON gp.player = pmap.player + WHERE + pe.id = ?""", (peid,)) + # aggregates without GROUP BY always have exactly one row, + # so no need to check for None here + return c.fetchone()[0] + +def _set_thing(thing, val, acc, hostmask, raw=False): + with conn: + c = conn.cursor() + peid, plid = _get_ids(acc, hostmask, add=True) + if raw: + params = (peid,) + else: + params = (val, peid) + val = "?" + c.execute("""UPDATE person SET {0} = {1} WHERE id = ?""".format(thing, val), params) + +def _toggle_thing(thing, acc, hostmask): + _set_thing(thing, "CASE {0} WHEN 1 THEN 0 ELSE 1 END".format(thing), acc, hostmask, raw=True) + +need_install = not os.path.isfile("data.sqlite3") +conn = sqlite3.connect("data.sqlite3") +with conn: + c = conn.cursor() + c.execute("PRAGMA foreign_keys = ON") + if need_install: + _install() + c.execute("PRAGMA user_version") + row = c.fetchone() + if row[0] == 0: + # new schema does not exist yet, migrate from old schema + # NOTE: game stats are NOT migrated to the new schema; the old gamestats table + # will continue to exist to allow queries against it, however given how horribly + # inaccurate the stats on it are, it would be a disservice to copy those inaccurate + # statistics over to the new schema which has the capability of actually being accurate. + _migrate() + elif row[0] < SCHEMA_VERSION: + _upgrade() + c.close() + +del need_install, c +init_vars() + +# vim: set expandtab:sw=4:ts=4: diff --git a/src/db.sql b/src/db.sql new file mode 100644 index 0000000..e3553a0 --- /dev/null +++ b/src/db.sql @@ -0,0 +1,171 @@ +-- Base schema, when editing be sure to increment the SCHEMA_VERSION in src/db.py +-- Additionally, add the appropriate bits to the update function, as this script +-- does not perform alters on already-existing tables + +-- Player tracking. This is just what the bot decides is a unique player, two entries +-- here may end up corresponding to the same actual person (see below). +CREATE TABLE IF NOT EXISTS player ( + id INTEGER PRIMARY KEY, + -- NickServ account name, or NULL if this player is based on a hostmask + account TEXT, + -- Hostmask for the player, if not based on an account (NULL otherwise) + hostmask TEXT, + -- If a player entry needs to be retired (for example, an account expired), + -- setting this to 0 allows for that entry to be re-used without corrupting old stats/logs + active BOOLEAN NOT NULL DEFAULT 1 +); + +CREATE INDEX IF NOT EXISTS player_idx ON player (account, hostmask, active); + +-- Person tracking; a person can consist of multiple players (for example, someone may have +-- an account player for when they are logged in and 3 hostmask players for when they are +-- logged out depending on what connection they are using). +CREATE TABLE IF NOT EXISTS person ( + id INTEGER PRIMARY KEY, + -- Primary player for this person + primary_player INTEGER NOT NULL UNIQUE REFERENCES player(id), + -- If 1, the bot will notice the player instead of sending privmsgs + notice BOOLEAN NOT NULL DEFAULT 0, + -- If 1, the bot will send simple role notifications to the player + simple BOOLEAN NOT NULL DEFAULT 0, + -- If 1, the bot will automatically join the player to deadchat upon them dying + deadchat BOOLEAN NOT NULL DEFAULT 1, + -- Pingif preference for the person, or NULL if they do not wish to be pinged + pingif INTEGER, + -- Amount of stasis this person has (stasis prevents them from joining games while active) + -- each time a game is started, this is decremented by 1, to a minimum of 0 + stasis_amount INTEGER NOT NULL DEFAULT 0, + -- When the given stasis expires, represented in 'YYYY-MM-DD HH:MM:SS' format + stasis_expires DATETIME +); + +-- A person can have multiple attached players, however each player can be attached +-- to only exactly one person +CREATE TABLE IF NOT EXISTS person_player ( + person INTEGER NOT NULL REFERENCES person(id), + player INTEGER NOT NULL UNIQUE REFERENCES player(id) +); + +CREATE INDEX IF NOT EXISTS person_player_idx ON person_player (person); + +-- Sometimes people are bad, this keeps track of that for the purpose of automatically applying +-- various sanctions and viewing the past history of someone. Outside of specifically-marked +-- fields, records are never modified or deleted from this table once inserted. +CREATE TABLE IF NOT EXISTS warning ( + id INTEGER PRIMARY KEY, + -- The target (recipient) of the warning + target INTEGER NOT NULL REFERENCES person(id), + -- The person who gave out the warning, or NULL if it was automatically generated + sender INTEGER REFERENCES person(id), + -- Number of warning points + amount INTEGER NOT NULL, + -- When the warning was issued ('YYYY-MM-DD HH:MM:SS') + issued DATETIME NOT NULL, + -- When the warning expires ('YYYY-MM-DD HH:MM:SS') or NULL if it never expires + expires DATETIME, + -- Reason for the warning (shown to the target) + -- Can be edited after the warning is issued + reason TEXT NOT NULL, + -- Optonal notes for the warning (only visible to admins) + -- Can be edited after the warning is issued + notes TEXT, + -- Set to 1 if the warning was acknowledged by the target + acknowledged BOOLEAN NOT NULL DEFAULT 0, + -- Set to 1 if the warning was rescinded by an admin before it expired + deleted BOOLEAN NOT NULL DEFAULT 0, + -- If the warning was rescinded, this tracks by whom + deleted_by INTEGER REFERENCES person(id), + -- If the warning was rescinded, this tracks when that happened ('YYYY-MM-DD HH:MM:SS') + deleted_on DATETIME +); + +CREATE INDEX IF NOT EXISTS warning_idx ON warning (target, deleted, issued); + +-- In addition to giving warning points, a warning may have specific sanctions attached +-- that apply until the warning expires; for example preventing a user from joining deadchat +-- or denying them access to a particular command (such as !goat). +CREATE TABLE IF NOT EXISTS warning_sanction ( + -- The warning this sanction is attached to + warning INTEGER NOT NULL REFERENCES warning(id), + -- The type of sanction this is + sanction TEXT NOT NULL, + -- If the sanction type has additional data attached, it is listed here + data TEXT +); + +-- A running tally of all games played, game stats are aggregated from this table +-- This shouldn't be too horribly slow, but if it is some strategies can be employed to speed it up: +-- On startup, aggregate everything from this table and store in-memory, then increment those in-memory +-- counts as games are played. +CREATE TABLE IF NOT EXISTS game ( + id INTEGER PRIMARY KEY, + -- The gamemode played + gamemode TEXT NOT NULL, + -- Game options (role reveal, stats type, etc.), stored as JSON string + -- The json1 extension can be loaded into sqlite to allow for easy querying of these values + -- lykos itself does not make use of this field when calculating stats at this time + options TEXT, + -- When the game was started + started DATETIME NOT NULL, + -- When the game was finished + finished DATETIME NOT NULL, + -- Game size (at game start) + gamesize INTEGER NOT NULL, + -- Winning team (NULL if no winner) + winner TEXT +); + +CREATE INDEX IF NOT EXISTS game_idx ON game (gamemode, gamesize); + +-- List of people who played in each game +CREATE TABLE IF NOT EXISTS game_player ( + id INTEGER PRIMARY KEY, + game INTEGER NOT NULL REFERENCES game(id), + player INTEGER NOT NULL REFERENCES player(id), + -- 1 if the player has a team win for this game + team_win BOOLEAN NOT NULL, + -- 1 if the player has an individual win for this game + indiv_win BOOLEAN NOT NULL, + -- 1 if the player died due to a dc (kick, quit, idled out) + dced BOOLEAN NOT NULL +); + +CREATE INDEX IF NOT EXISTS game_player_game_idx ON game_player (game); +CREATE INDEX IF NOT EXISTS game_player_player_idx ON game_player (player); + +-- List of all roles and other special qualities (e.g. lover, entranced, etc.) the player had in game +CREATE TABLE IF NOT EXISTS game_player_role ( + game_player INTEGER NOT NULL REFERENCES game_player(id), + -- Name of the role or other quality recorded + role TEXT NOT NULL, + -- 1 if role is a special quality instead of an actual role/template name + special BOOLEAN NOT NULL +); + +CREATE INDEX IF NOT EXISTS game_player_role_idx ON game_player_role (game_player); + +-- Access templates; instead of manually specifying flags, a template can be used to add a group of +-- flags simultaneously. +CREATE TABLE IF NOT EXISTS access_template ( + id INTEGER PRIMARY KEY, + -- Template name, for display purposes + name TEXT NOT NULL, + -- Flags this template grants + flags TEXT +); + +-- Access control, owners still need to be specified in botconfig, but everyone else goes here +CREATE TABLE IF NOT EXISTS access ( + person INTEGER NOT NULL PRIMARY KEY REFERENCES person(id), + -- Template to base this person's access on, or NULL if it is not based on a template + template INTEGER REFERENCES access_template(id), + -- If template is NULL, this is the list of flags that will be used + -- Has no effect if template is not NULL + flags TEXT +); + +-- Used to hold state between restarts +CREATE TABLE IF NOT EXISTS pre_restart_state ( + -- List of players to ping after the bot comes back online + players TEXT +); diff --git a/src/decorators.py b/src/decorators.py index aefc26a..4d253d0 100644 --- a/src/decorators.py +++ b/src/decorators.py @@ -10,7 +10,7 @@ from oyoyo.parse import parse_nick import botconfig import src.settings as var from src.utilities import * -from src import logger +from src import logger, db from src.messages import messages adminlog = logger("audit.log") @@ -66,12 +66,12 @@ class handle_error: cli.msg(botconfig.DEV_CHANNEL, " ".join((msg, url))) class cmd: - def __init__(self, *cmds, raw_nick=False, admin_only=False, owner_only=False, + def __init__(self, *cmds, raw_nick=False, flag=None, owner_only=False, chan=True, pm=False, playing=False, silenced=False, phases=(), roles=()): self.cmds = cmds self.raw_nick = raw_nick - self.admin_only = admin_only + self.flag = flag self.owner_only = owner_only self.chan = chan self.pm = pm @@ -88,7 +88,7 @@ class cmd: for name in cmds: for func in COMMANDS[name]: if (func.owner_only != owner_only or - func.admin_only != admin_only): + func.flag != flag): raise ValueError("unmatching protection levels for " + func.name) COMMANDS[name].append(self) @@ -125,7 +125,7 @@ class cmd: if not self.chan and chan != nick: return # channel command, not allowed - if chan.startswith("#") and chan != botconfig.CHANNEL and not (self.admin_only or self.owner_only): + if chan.startswith("#") and chan != botconfig.CHANNEL and not (self.flag or self.owner_only): if "" in self.cmds: return # don't have empty commands triggering in other channels for command in self.cmds: @@ -138,6 +138,7 @@ class cmd: acc = var.USERS[nick]["account"] else: acc = None + hostmask = nick + "!" + ident + "@" + host if "" in self.cmds: return self.func(*largs) @@ -175,8 +176,9 @@ class cmd: forced_owner_only = True break + is_owner = var.is_owner(nick, ident, host) if self.owner_only or forced_owner_only: - if var.is_owner(nick, ident, host): + if is_owner: adminlog(chan, rawnick, self.name, rest) return self.func(*largs) @@ -186,49 +188,26 @@ class cmd: cli.notice(nick, messages["not_owner"]) return - if var.is_admin(nick, ident, host): - if self.admin_only: - adminlog(chan, rawnick, self.name, rest) + flags = var.FLAGS[hostmask] + var.FLAGS_ACCS[acc] + is_full_admin = var.is_admin(nick, ident, host) + if self.flag and (is_full_admin or is_owner): + adminlog(chan, rawnick, self.name, rest) return self.func(*largs) - if not var.DISABLE_ACCOUNTS and acc: - if acc in var.DENY_ACCOUNTS: - for command in self.cmds: - if command in var.DENY_ACCOUNTS[acc]: - if chan == nick: - pm(cli, nick, messages["invalid_permissions"]) - else: - cli.notice(nick, messages["invalid_permissions"]) - return + denied_cmds = var.DENY[hostmask] | var.DENY_ACCS[acc] + for command in self.cmds: + if command in denied_cmds: + if chan == nick: + pm(cli, nick, messages["invalid_permissions"]) + else: + cli.notice(nick, messages["invalid_permissions"]) + return - if acc in var.ALLOW_ACCOUNTS: - for command in self.cmds: - if command in var.ALLOW_ACCOUNTS[acc]: - if self.admin_only: - adminlog(chan, rawnick, self.name, rest) - return self.func(*largs) - - if host: - for pattern in var.DENY: - if var.match_hostmask(pattern, nick, ident, host): - for command in self.cmds: - if command in var.DENY[pattern]: - if chan == nick: - pm(cli, nick, messages["invalid_permissions"]) - else: - cli.notice(nick, messages["invalid_permissions"]) - return - - for pattern in var.ALLOW: - if var.match_hostmask(pattern, nick, ident, host): - for command in self.cmds: - if command in var.ALLOW[pattern]: - if self.admin_only: - adminlog(chan, rawnick, self.name, rest) - return self.func(*largs) - - if self.admin_only: - if chan == nick: + if self.flag: + if self.flag in flags: + adminlog(chan, rawnick, self.name, rest) + return self.func(*largs) + elif chan == nick: pm(cli, nick, messages["not_an_admin"]) else: cli.notice(nick, messages["not_an_admin"]) @@ -264,3 +243,5 @@ class hook: HOOKS[each].remove(inner) if not HOOKS[each]: del HOOKS[each] + +# vim: set sw=4 expandtab: diff --git a/src/migrate.sql b/src/migrate.sql new file mode 100644 index 0000000..d422dda --- /dev/null +++ b/src/migrate.sql @@ -0,0 +1,214 @@ +-- First, create our player entries +INSERT INTO player ( + account, + hostmask, + active +) +SELECT DISTINCT + account, + NULL, + 1 +FROM ( + SELECT player AS account FROM rolestats + UNION ALL + SELECT acc AS account FROM allowed_accs + UNION ALL + SELECT user AS account FROM deadchat_prefs WHERE is_account = 1 + UNION ALL + SELECT acc AS account FROM denied_accs + UNION ALL + SELECT user AS account FROM pingif_prefs WHERE is_account = 1 + UNION ALL + SELECT acc AS account FROM prefer_notice_acc + UNION ALL + SELECT acc AS account FROM simple_role_accs + UNION ALL + SELECT acc AS account FROM stasised_accs +) t1 +UNION ALL +SELECT DISTINCT + NULL, + hostmask, + 1 +FROM ( + SELECT cloak AS hostmask FROM allowed + UNION ALL + SELECT user AS hostmask FROM deadchat_prefs WHERE is_account = 0 + UNION ALL + SELECT cloak AS hostmask FROM denied + UNION ALL + SELECT user AS hostmask FROM pingif_prefs WHERE is_account = 0 + UNION ALL + SELECT cloak AS hostmask FROM prefer_notice + UNION ALL + SELECT cloak AS hostmask FROM simple_role_notify + UNION ALL + SELECT cloak AS hostmask FROM stasised +) t2; + +-- Create our person entries (we assume a 1:1 person:player mapping for migration) +INSERT INTO person ( + primary_player, + notice, + simple, + deadchat, + pingif, + stasis_amount, + stasis_expires +) +SELECT + pl.id, + EXISTS(SELECT 1 FROM prefer_notice_acc pna WHERE pna.acc = pl.account) + OR EXISTS(SELECT 1 FROM prefer_notice pn WHERE pn.cloak = pl.hostmask), + EXISTS(SELECT 1 FROM simple_role_accs sra WHERE sra.acc = pl.account) + OR EXISTS(SELECT 1 FROM simple_role_notify srn WHERE srn.cloak = pl.hostmask), + EXISTS(SELECT 1 FROM deadchat_prefs dp + WHERE dp.user = COALESCE(pl.account, pl.hostmask) + AND dp.is_account = CASE WHEN pl.account IS NOT NULL THEN 1 ELSE 0 END), + pi.players, + COALESCE(sa.games, sh.games, 0), + CASE WHEN COALESCE(sa.games, sh.games) IS NOT NULL + THEN DATETIME('now', '+' || COALESCE(sa.games, sh.games) || ' hours') + ELSE NULL END +FROM player pl +LEFT JOIN pingif_prefs pi + ON pi.user = COALESCE(pl.account, pl.hostmask) + AND pi.is_account = CASE WHEN pl.account IS NOT NULL THEN 1 ELSE 0 END +LEFT JOIN stasised sh + ON sh.cloak = pl.hostmask +LEFT JOIN stasised_accs sa + ON sa.acc = pl.account; + +INSERT INTO person_player (person, player) +SELECT id, primary_player FROM person; + +-- Port allowed/denied stuff to the new format +-- (allowed to access entries, denied to warnings) +CREATE TEMPORARY TABLE access_flags_map ( + command TEXT NOT NULL, + flag TEXT NOT NULL +); +INSERT INTO access_flags_map +(command, flag) +VALUES +-- uppercase = dangerous to give out, lowercase = more ok to give out +-- F = full admin commands +('fallow', 'F'), +('fdeny', 'F'), +('fsend', 'F'), +-- s = speak commands +('fsay', 's'), +('fact', 's'), +-- d = debug commands +('fday', 'd'), +('fnight', 'd'), +('force', 'd'), +('rforce', 'd'), +('frole', 'd'), +('fgame', 'd'), +-- D = Dangerous commands +('fdie', 'D'), +('frestart', 'D'), +('fpull', 'D'), +('faftergame', 'D'), +('flastgame', 'D'), +-- A = administration commands +('fjoin', 'A'), +('fleave', 'A'), +('fstasis', 'A'), +('fstart', 'A'), +('fstop', 'A'), +('fwait', 'A'), +('fspectate', 'A'), +-- a = auspex commands +('revealroles', 'a'), +-- j = joke commands +('fgoat', 'j'), +-- m = management commands +('fsync', 'm'); + +INSERT INTO access (person, flags) +SELECT pe.id, GROUP_CONCAT(t.flag, '') +FROM ( + SELECT DISTINCT pl.id AS player, afm.flag AS flag + FROM allowed a + JOIN player pl + ON pl.hostmask = a.cloak + JOIN access_flags_map afm + ON afm.command = a.command + UNION + SELECT DISTINCT pl.id AS player, afm.flag AS flag + FROM allowed_accs a + JOIN player pl + ON pl.account = a.acc + JOIN access_flags_map afm + ON afm.command = a.command +) t +JOIN person pe + ON pe.primary_player = t.player +GROUP BY pe.id; + +INSERT INTO warning ( + target, + amount, + issued, + reason, + notes +) +SELECT + pe.id, + 0, + DATETIME('now'), + 'Unknown', + 'Automatically generated warning from migration' +FROM ( + SELECT DISTINCT pl.id AS player + FROM denied d + JOIN player pl + ON pl.hostmask = d.cloak + UNION + SELECT DISTINCT pl.id AS player + FROM denied_accs d + JOIN player pl + ON pl.account = d.acc +) t +JOIN person pe + ON pe.primary_player = t.player; + +INSERT INTO warning_sanction ( + warning, + sanction, + data +) +SELECT DISTINCT + w.id, + 'deny command', + COALESCE(dh.command, da.command) +FROM warning w +JOIN person pe + ON pe.id = w.target +JOIN player pl + ON pl.id = pe.primary_player +LEFT JOIN denied dh + ON dh.cloak = pl.hostmask +LEFT JOIN denied_accs da + ON da.acc = pl.account; + +DROP TABLE access_flags_map; + +-- Finally, clean up old tables +-- gamestats and rolestats are kept for posterity since that data is not migrated +-- pre_restart_state is kept because it is still used in the new schema +DROP TABLE allowed; +DROP TABLE allowed_accs; +DROP TABLE deadchat_prefs; +DROP TABLE denied; +DROP TABLE denied_accs; +DROP TABLE pingif_prefs; +DROP TABLE prefer_notice; +DROP TABLE prefer_notice_acc; +DROP TABLE roles; +DROP TABLE simple_role_accs; +DROP TABLE simple_role_notify; +DROP TABLE stasised; +DROP TABLE stasised_accs; diff --git a/src/settings.py b/src/settings.py index adc07d3..c1820a4 100644 --- a/src/settings.py +++ b/src/settings.py @@ -1,6 +1,6 @@ import fnmatch -import sqlite3 import re +import threading from collections import defaultdict, OrderedDict import botconfig @@ -52,10 +52,6 @@ START_QUIT_DELAY = 10 MAX_PRIVMSG_TARGETS = 4 # how many mode values can be specified at once; used only as fallback MODELIMIT = 3 -LEAVE_STASIS_PENALTY = 1 -IDLE_STASIS_PENALTY = 1 -PART_STASIS_PENALTY = 1 -ACC_STASIS_PENALTY = 1 QUIET_DEAD_PLAYERS = False DEVOICE_DURING_NIGHT = False ALWAYS_PM_ROLE = False @@ -64,6 +60,34 @@ QUIET_PREFIX = "" # "" or "~q:" # The bot will automatically toggle those modes of people joining AUTO_TOGGLE_MODES = "" +DEFAULT_EXPIRY = "30d" +LEAVE_PENALTY = 1 +LEAVE_EXPIRY = "30d" +IDLE_PENALTY = 1 +IDLE_EXPIRY = "30d" +PART_PENALTY = 1 +PART_EXPIRY = "30d" +ACC_PENALTY = 1 +ACC_EXPIRY = "30d" + +# The formatting of this sucks, sorry. This is used to automatically apply sanctions to warning levels +# When a user crosses from below the min threshold to min or above points, the listed sanctions apply +# Sanctions also apply while moving within the same threshold bracket (such as from min to max) +# Valid sanctions are deny, stasis, scalestasis, and tempban +# Scalestasis applies stasis equal to the formula ax^2 + bx + c, where x is the number of warning points +# Tempban number can either be a duration (ending in d, h, or m) or a number meaning it expires when +# warning points fall below that threshold. +# Tempban is currently not implemented and does nothing right now. +AUTO_SANCTION = ( + #min max sanctions + (1, 4, {"ack": True}), + (5, 9, {"stasis": 1}), + (10, 10, {"ack": True, "stasis": 3}), + (11, 14, {"stasis": 3}), + (15, 24, {"scalestasis": (0, 1, -10)}), + (25, 25, {"tempban": 15}) + ) + # The following is a bitfield, and they can be mixed together # Defaults to none of these, can be changed on a per-game-mode basis @@ -174,10 +198,6 @@ TOTEM_CHANCES = { "death": ( 1 , 1 , 0 GAME_MODES = {} GAME_PHASES = ("night", "day") # all phases that constitute "in game", game modes can extend this with custom phases -SIMPLE_NOTIFY = set() # cloaks of people who !simple, who don't want detailed instructions -SIMPLE_NOTIFY_ACCS = set() # same as above, except accounts. takes precedence -PREFER_NOTICE = set() # cloaks of people who !notice, who want everything /notice'd -PREFER_NOTICE_ACCS = set() # Same as above, except accounts. takes precedence ACCOUNTS_ONLY = False # If True, will use only accounts for everything DISABLE_ACCOUNTS = False # If True, all account-related features are disabled. Automatically set if we discover we do not have proper ircd support for accounts @@ -191,9 +211,6 @@ NICKSERV_REGAIN_COMMAND = "REGAIN {nick}" CHANSERV = "ChanServ" CHANSERV_OP_COMMAND = "OP {channel}" -STASISED = defaultdict(int) -STASISED_ACCS = defaultdict(int) - # TODO: move this to a game mode called "fixed" once we implement a way to randomize roles (and have that game mode be called "random") DEFAULT_ROLE = "villager" ROLE_INDEX = ( 4 , 6 , 7 , 8 , 9 , 10 , 11 , 12 , 13 , 15 , 16 , 18 , 20 , 21 , 23 , 24 ) @@ -304,24 +321,15 @@ DISABLED_ROLES = frozenset() GIF_CHANCE = 1/50 FORTUNE_CHANCE = 1/25 +ALL_FLAGS = frozenset("AaDdFjms") RULES = (botconfig.CHANNEL + " channel rules: http://wolf.xnrand.com/rules") -DENY = {} -ALLOW = {} -DENY_ACCOUNTS = {} -ALLOW_ACCOUNTS = {} +GRAVEYARD_LOCK = threading.RLock() +WARNING_LOCK = threading.RLock() +WAIT_TB_LOCK = threading.RLock() -# pingif-related mappings - -PING_IF_PREFS = {} -PING_IF_PREFS_ACCS = {} - -PING_IF_NUMS = {} -PING_IF_NUMS_ACCS = {} - -DEADCHAT_PREFS = set() -DEADCHAT_PREFS_ACCS = set() +#TODO: move all of these to util.py or other files, as they are certainly NOT settings! is_role = lambda plyr, rol: rol in ROLES and plyr in ROLES[rol] @@ -336,43 +344,60 @@ def match_hostmask(hostmask, nick, ident, host): return False - -def check_priv(priv): - assert priv in ("owner", "admin") - - # Owners can do everything +def is_owner(nick, ident=None, host=None, acc=None): hosts = set(botconfig.OWNERS) accounts = set(botconfig.OWNERS_ACCOUNTS) + if nick in USERS: + if not ident: + ident = USERS[nick]["ident"] + if not host: + host = USERS[nick]["host"] + if not acc: + acc = USERS[nick]["account"] - if priv == "admin": - hosts.update(botconfig.ADMINS) - accounts.update(botconfig.ADMINS_ACCOUNTS) + if not DISABLE_ACCOUNTS and acc and acc != "*": + for pattern in accounts: + if fnmatch.fnmatch(acc.lower(), pattern.lower()): + return True - def do_check(nick, ident=None, host=None, acc=None): - if nick in USERS.keys(): - if not ident: - ident = USERS[nick]["ident"] - if not host: - host = USERS[nick]["host"] - if not acc: - acc = USERS[nick]["account"] + if host: + for hostmask in hosts: + if match_hostmask(hostmask, nick, ident, host): + return True - if not DISABLE_ACCOUNTS and acc and acc != "*": - for pattern in accounts: - if fnmatch.fnmatch(acc.lower(), pattern.lower()): - return True + return False - if host: - for hostmask in hosts: - if match_hostmask(hostmask, nick, ident, host): - return True +def is_admin(nick, ident=None, host=None, acc=None): + if nick in USERS: + if not ident: + ident = USERS[nick]["ident"] + if not host: + host = USERS[nick]["host"] + if not acc: + acc = USERS[nick]["account"] + hostmask = nick + "!" + ident + "@" + host + flags = FLAGS[hostmask] + FLAGS_ACCS[acc] - return False + if not "F" in flags: + try: + hosts = set(botconfig.ADMINS) + accounts = set(botconfig.ADMINS_ACCOUNTS) - return do_check + if not DISABLE_ACCOUNTS and acc and acc != "*": + for pattern in accounts: + if fnmatch.fnmatch(acc.lower(), pattern.lower()): + return True -is_admin = check_priv("admin") -is_owner = check_priv("owner") + if host: + for hostmask in hosts: + if match_hostmask(hostmask, nick, ident, host): + return True + except AttributeError: + pass + + return is_owner(nick, ident, host, acc) + + return True def irc_lower(nick): mapping = { @@ -393,7 +418,6 @@ def irc_equals(nick1, nick2): return irc_lower(nick1) == irc_lower(nick2) def plural(role, count=2): - # TODO: use the "inflect" pip package, pass part-of-speech as a kwarg if count == 1: return role bits = role.split() @@ -408,6 +432,19 @@ def plural(role, count=2): "child": "children"}.get(bits[-1], bits[-1] + "s") return " ".join(bits) +def singular(plural): + # converse of plural above (kinda) + # this is used to map plural team names back to singular, + # so we don't need to worry about stuff like possessives + # Note that this is currently only ever called on team names, + # and will require adjustment if one wishes to use it on roles. + conv = {"wolves": "wolf", + "succubi": "succubus"} + if plural in conv: + return conv[plural] + # otherwise we just added an s on the end + return plural[:-1] + def list_players(roles = None): if roles is None: roles = ROLES.keys() @@ -501,362 +538,4 @@ def break_long_message(phrases, joinstr = " "): class InvalidModeException(Exception): pass -# Persistence - -conn = sqlite3.connect("data.sqlite3", check_same_thread = False) -c = conn.cursor() - -def init_db(): - with conn: - - c.execute('CREATE TABLE IF NOT EXISTS simple_role_notify (cloak TEXT)') # people who understand each role (hostmasks - backup) - - c.execute('CREATE TABLE IF NOT EXISTS simple_role_accs (acc TEXT)') # people who understand each role (accounts - primary) - - c.execute('CREATE TABLE IF NOT EXISTS prefer_notice (cloak TEXT)') # people who prefer /notice (hostmasks - backup) - - c.execute('CREATE TABLE IF NOT EXISTS prefer_notice_acc (acc TEXT)') # people who prefer /notice (accounts - primary) - - c.execute('CREATE TABLE IF NOT EXISTS stasised (cloak TEXT, games INTEGER, UNIQUE(cloak))') # stasised people (cloaks) - - c.execute('CREATE TABLE IF NOT EXISTS stasised_accs (acc TEXT, games INTEGER, UNIQUE(acc))') # stasised people (accounts - takes precedence) - - c.execute('CREATE TABLE IF NOT EXISTS denied (cloak TEXT, command TEXT, UNIQUE(cloak, command))') # DENY - - c.execute('CREATE TABLE IF NOT EXISTS denied_accs (acc TEXT, command TEXT, UNIQUE(acc, command))') # DENY_ACCOUNTS - - c.execute('CREATE TABLE IF NOT EXISTS allowed (cloak TEXT, command TEXT, UNIQUE(cloak, command))') # ALLOW - - c.execute('CREATE TABLE IF NOT EXISTS allowed_accs (acc TEXT, command TEXT, UNIQUE(acc, command))') # ALLOW_ACCOUNTS - - c.execute('CREATE TABLE IF NOT EXISTS pingif_prefs (user TEXT, is_account BOOLEAN, players INTEGER, PRIMARY KEY(user, is_account))') # pingif player count preferences - c.execute('CREATE INDEX IF NOT EXISTS ix_ping_prefs_pingif ON pingif_prefs (players ASC)') # index apparently makes it faster - - c.execute('CREATE TABLE IF NOT EXISTS deadchat_prefs (user TEXT, is_account BOOLEAN)') # deadcht preferences - - c.execute('PRAGMA table_info(pre_restart_state)') - try: - next(c) - except StopIteration: - c.execute('CREATE TABLE pre_restart_state (players TEXT)') - c.execute('INSERT INTO pre_restart_state (players) VALUES (NULL)') - - c.execute('SELECT * FROM simple_role_notify') - for row in c: - SIMPLE_NOTIFY.add(row[0]) - - c.execute('SELECT * FROM simple_role_accs') - for row in c: - SIMPLE_NOTIFY_ACCS.add(row[0]) - - c.execute('SELECT * FROM prefer_notice') - for row in c: - PREFER_NOTICE.add(row[0]) - - c.execute('SELECT * FROM prefer_notice_acc') - for row in c: - PREFER_NOTICE_ACCS.add(row[0]) - - c.execute('SELECT * FROM stasised') - for row in c: - STASISED[row[0]] = row[1] - - c.execute('SELECT * FROM stasised_accs') - for row in c: - STASISED_ACCS[row[0]] = row[1] - - c.execute('SELECT * FROM denied') - for row in c: - if row[0] not in DENY: - DENY[row[0]] = set() - DENY[row[0]].add(row[1]) - - c.execute('SELECT * FROM denied_accs') - for row in c: - if row[0] not in DENY_ACCOUNTS: - DENY_ACCOUNTS[row[0]] = set() - DENY_ACCOUNTS[row[0]].add(row[1]) - - c.execute('SELECT * FROM allowed') - for row in c: - if row[0] not in ALLOW: - ALLOW[row[0]] = set() - ALLOW[row[0]].add(row[1]) - - c.execute('SELECT * FROM allowed_accs') - for row in c: - if row[0] not in ALLOW_ACCOUNTS: - ALLOW_ACCOUNTS[row[0]] = set() - ALLOW_ACCOUNTS[row[0]].add(row[1]) - - c.execute('SELECT * FROM pingif_prefs') - for row in c: - # is an account - if row[1]: - if row[0] not in PING_IF_PREFS_ACCS: - PING_IF_PREFS_ACCS[row[0]] = row[2] - if row[2] not in PING_IF_NUMS_ACCS: - PING_IF_NUMS_ACCS[row[2]] = set() - PING_IF_NUMS_ACCS[row[2]].add(row[0]) - # is a host - else: - if row[0] not in PING_IF_PREFS: - PING_IF_PREFS[row[0]] = row[2] - if row[2] not in PING_IF_NUMS: - PING_IF_NUMS[row[2]] = set() - PING_IF_NUMS[row[2]].add(row[0]) - - c.execute('SELECT * FROM deadchat_prefs') - for user, is_acc in c: - if is_acc: - DEADCHAT_PREFS_ACCS.add(user) - else: - DEADCHAT_PREFS.add(user) - - # populate the roles table - c.execute('DROP TABLE IF EXISTS roles') - c.execute('CREATE TABLE roles (id INTEGER PRIMARY KEY AUTOINCREMENT, role TEXT)') - - for x in list(ROLE_GUIDE): - c.execute("INSERT OR REPLACE INTO roles (role) VALUES (?)", (x,)) - - - c.execute(('CREATE TABLE IF NOT EXISTS rolestats (player TEXT, role TEXT, '+ - 'teamwins SMALLINT, individualwins SMALLINT, totalgames SMALLINT, '+ - 'UNIQUE(player, role))')) - - - c.execute(('CREATE TABLE IF NOT EXISTS gamestats (gamemode TEXT, size SMALLINT, villagewins SMALLINT, ' + - 'wolfwins SMALLINT, monsterwins SMALLINT, foolwins SMALLINT, piperwins SMALLINT, succubuswins SMALLINT, ' + - 'demoniacwins SMALLINT, totalgames SMALLINT, UNIQUE(gamemode, size))')) - try: - # Check if table has been updated with new stats - c.execute('SELECT succubuswins from gamestats') - for row in c: - # Read all the very important data - pass - except sqlite3.OperationalError: - c.execute('ALTER TABLE gamestats RENAME TO gamestatsold') - c.execute('CREATE TABLE gamestats (gamemode TEXT, size SMALLINT, villagewins SMALLINT, wolfwins SMALLINT, ' + - 'monsterwins SMALLINT, foolwins SMALLINT, piperwins SMALLINT,succubuswins SMALLINT, ' + - 'demoniacwins SMALLINT, totalgames SMALLINT, UNIQUE(gamemode, size))') - c.execute('INSERT into gamestats (gamemode, size, villagewins, wolfwins, monsterwins, foolwins, piperwins, succubuswins, demoniacwins, totalgames) ' + - 'SELECT gamemode, size, villagewins, wolfwins, monsterwins, foolwins, piperwins, 0, 0, totalgames FROM gamestatsold') - c.execute('DROP TABLE gamestatsold') - - -def remove_simple_rolemsg(clk): - with conn: - c.execute('DELETE from simple_role_notify where cloak=?', (clk,)) - -def add_simple_rolemsg(clk): - with conn: - c.execute('INSERT into simple_role_notify VALUES (?)', (clk,)) - -def remove_simple_rolemsg_acc(acc): - with conn: - c.execute('DELETE from simple_role_accs where acc=?', (acc,)) - -def add_simple_rolemsg_acc(acc): - with conn: - c.execute('INSERT into simple_role_accs VALUES (?)', (acc,)) - -def remove_prefer_notice(clk): - with conn: - c.execute('DELETE from prefer_notice where cloak=?', (clk,)) - -def add_prefer_notice(clk): - with conn: - c.execute('INSERT into prefer_notice VALUES (?)', (clk,)) - -def remove_prefer_notice_acc(acc): - with conn: - c.execute('DELETE from prefer_notice_acc where acc=?', (acc,)) - -def add_prefer_notice_acc(acc): - with conn: - c.execute('INSERT into prefer_notice_acc VALUES (?)', (acc,)) - -def set_stasis(clk, games): - with conn: - if games <= 0: - c.execute('DELETE FROM stasised WHERE cloak=?', (clk,)) - else: - c.execute('INSERT OR REPLACE INTO stasised VALUES (?,?)', (clk, games)) - -def set_stasis_acc(acc, games): - with conn: - if games <= 0: - c.execute('DELETE FROM stasised_accs WHERE acc=?', (acc,)) - else: - c.execute('INSERT OR REPLACE INTO stasised_accs VALUES (?,?)', (acc, games)) - -def add_deny(clk, command): - with conn: - c.execute('INSERT OR IGNORE INTO denied VALUES (?,?)', (clk, command)) - -def remove_deny(clk, command): - with conn: - c.execute('DELETE FROM denied WHERE cloak=? AND command=?', (clk, command)) - -def add_deny_acc(acc, command): - with conn: - c.execute('INSERT OR IGNORE INTO denied_accs VALUES (?,?)', (acc, command)) - -def remove_deny_acc(acc, command): - with conn: - c.execute('DELETE FROM denied_accs WHERE acc=? AND command=?', (acc, command)) - -def add_allow(clk, command): - with conn: - c.execute('INSERT OR IGNORE INTO allowed VALUES (?,?)', (clk, command)) - -def remove_allow(clk, command): - with conn: - c.execute('DELETE FROM allowed WHERE cloak=? AND command=?', (clk, command)) - -def add_allow_acc(acc, command): - with conn: - c.execute('INSERT OR IGNORE INTO allowed_accs VALUES (?,?)', (acc, command)) - -def remove_allow_acc(acc, command): - with conn: - c.execute('DELETE FROM allowed_accs WHERE acc=? AND command=?', (acc, command)) - -def set_pingif_status(user, is_account, players): - with conn: - c.execute('DELETE FROM pingif_prefs WHERE user=? AND is_account=?', (user, is_account)) - if players != 0: - c.execute('INSERT OR REPLACE INTO pingif_prefs VALUES (?,?,?)', (user, is_account, players)) - -def add_deadchat_pref(user, is_account): - with conn: - c.execute('INSERT OR REPLACE INTO deadchat_prefs VALUES (?,?)', (user, is_account)) - -def remove_deadchat_pref(user, is_account): - with conn: - c.execute('DELETE FROM deadchat_prefs WHERE user=? AND is_account=?', (user, is_account)) - -def update_role_stats(acc, role, won, iwon): - with conn: - wins, iwins, total = 0, 0, 0 - - c.execute(("SELECT teamwins, individualwins, totalgames FROM rolestats "+ - "WHERE player=? AND role=?"), (acc, role)) - row = c.fetchone() - if row: - wins, iwins, total = row - - if won: - wins += 1 - if iwon: - iwins += 1 - total += 1 - - c.execute("INSERT OR REPLACE INTO rolestats VALUES (?,?,?,?,?)", - (acc, role, wins, iwins, total)) - -def update_game_stats(gamemode, size, winner): - with conn: - vwins, wwins, mwins, fwins, pwins, swins, dwins, total = 0, 0, 0, 0, 0, 0, 0, 0 - - c.execute("SELECT villagewins, wolfwins, monsterwins, foolwins, piperwins, succubuswins, " - "demoniacwins, totalgames FROM gamestats WHERE gamemode=? AND size=?", (gamemode, size)) - row = c.fetchone() - if row: - vwins, wwins, mwins, fwins, pwins, swins, dwins, total = row - - if winner == "wolves": - wwins += 1 - elif winner == "villagers": - vwins += 1 - elif winner == "monsters": - mwins += 1 - elif winner == "pipers": - pwins += 1 - elif winner == "succubi": - swins += 1 - elif winner == "demoniacs": - dwins += 1 - elif winner.startswith("@"): - fwins += 1 - total += 1 - - c.execute("INSERT OR REPLACE INTO gamestats VALUES (?,?,?,?,?,?,?,?,?,?)", - (gamemode, size, vwins, wwins, mwins, fwins, pwins, swins, dwins, total)) - -def get_player_stats(acc, role): - if role.lower() not in [k.lower() for k in ROLE_GUIDE.keys()] and role != "lover": - return "No such role: {0}".format(role) - with conn: - c.execute("SELECT player FROM rolestats WHERE player=? COLLATE NOCASE", (acc,)) - player = c.fetchone() - if player: - for row in c.execute("SELECT * FROM rolestats WHERE player=? COLLATE NOCASE AND role=? COLLATE NOCASE", (acc, role)): - msg = "\u0002{0}\u0002 as \u0002{1}\u0002 | Team wins: {2} (%d%%), Individual wins: {3} (%d%%), Total games: {4}".format(*row) - return msg % (round(row[2]/row[4] * 100), round(row[3]/row[4] * 100)) - else: - return "No stats for {0} as {1}.".format(player[0], role) - return "{0} has not played any games.".format(acc) - -def get_player_totals(acc): - role_totals = [] - with conn: - c.execute("SELECT player FROM rolestats WHERE player=? COLLATE NOCASE", (acc,)) - player = c.fetchone() - if player: - c.execute("SELECT role, totalgames FROM rolestats WHERE player=? COLLATE NOCASE ORDER BY totalgames DESC", (acc,)) - role_tmp = defaultdict(int) - totalgames = 0 - while True: - row = c.fetchone() - if row: - role_tmp[row[0]] += row[1] - if row[0] not in TEMPLATE_RESTRICTIONS and row[0] != "lover": - totalgames += row[1] - else: - break - order = role_order() - #ordered role stats - role_totals = ["\u0002{0}\u0002: {1}".format(role, role_tmp[role]) for role in order if role in role_tmp] - #lover or any other special stats - role_totals += ["\u0002{0}\u0002: {1}".format(role, count) for role, count in role_tmp.items() if role not in order] - return "\u0002{0}\u0002's totals | \u0002{1}\u0002 games | {2}".format(player[0], totalgames, break_long_message(role_totals, ", ")) - else: - return "\u0002{0}\u0002 has not played any games.".format(acc) - -def get_game_stats(gamemode, size): - with conn: - for row in c.execute("SELECT * FROM gamestats WHERE gamemode=? AND size=?", (gamemode, size)): - msg = "\u0002%d\u0002 player games | Village wins: %d (%d%%), Wolf wins: %d (%d%%)" % (row[1], row[2], round(row[2]/row[9] * 100), row[3], round(row[3]/row[9] * 100)) - if row[4] > 0: - msg += ", Monster wins: %d (%d%%)" % (row[4], round(row[4]/row[9] * 100)) - if row[5] > 0: - msg += ", Fool wins: %d (%d%%)" % (row[5], round(row[5]/row[9] * 100)) - if row[6] > 0: - msg += ", Piper wins: %d (%d%%)" % (row[6], round(row[6]/row[9] * 100)) - if row[7] > 0: - msg += ", Succubus wins: %d (%d%%)" % (row[7], round(row[7]/row[9] * 100)) - if row[8] > 0: - msg += ", Demoniac wins: %d (%d%%)" % (row[8], round(row[8]/row[9] * 100)) - return msg + ", Total games: {0}".format(row[9]) - else: - return "No stats for \u0002{0}\u0002 player games.".format(size) - -def get_game_totals(gamemode): - size_totals = [] - total = 0 - with conn: - for size in range(MIN_PLAYERS, MAX_PLAYERS + 1): - c.execute("SELECT size, totalgames FROM gamestats WHERE gamemode=? AND size=?", (gamemode, size)) - row = c.fetchone() - if row: - size_totals.append("\u0002{0}p\u0002: {1}".format(*row)) - total += row[1] - - if len(size_totals) == 0: - return "No games have been played in the {0} game mode.".format(gamemode) - else: - return "Total games ({0}) | {1}".format(total, ", ".join(size_totals)) - # vim: set sw=4 expandtab: diff --git a/src/wolfgame.py b/src/wolfgame.py index 37589cb..c9ff64b 100644 --- a/src/wolfgame.py +++ b/src/wolfgame.py @@ -45,7 +45,7 @@ from oyoyo.parse import parse_nick import botconfig import src.settings as var from src.utilities import * -from src import decorators, events, logger, proxy, debuglog, errlog, plog +from src import db, decorators, events, logger, proxy, debuglog, errlog, plog from src.messages import messages # done this way so that events is accessible in !eval (useful for debugging) @@ -89,9 +89,6 @@ var.LAST_SAID_TIME = {} var.GAME_START_TIME = datetime.now() # for idle checker only var.CAN_START_TIME = 0 -var.GRAVEYARD_LOCK = threading.RLock() -var.WARNING_LOCK = threading.RLock() -var.WAIT_TB_LOCK = threading.RLock() var.STARTED_DAY_PLAYERS = 0 var.DISCONNECTED = {} # players who got disconnected @@ -361,6 +358,15 @@ def get_victim(cli, nick, victim, in_chan, self_in_list=False, bot_in_list=False return return pl[pll.index(tempvictim)] #convert back to normal casing +# wrapper around complete_match() used for any nick on the channel +def get_nick(cli, nick): + ul = [x for x in var.USERS] + ull = [x.lower() for x in var.USERS] + lnick, num_matches = complete_match(nick.lower(), ull) + if not lnick: + return None + return ul[ull.index(lnick)] + def get_roles(*roles): all_roles = [] for role in roles: @@ -429,36 +435,7 @@ def reset(): reset() -def make_stasis(nick, penalty): - if nick in var.USERS: - ident = var.USERS[nick]["ident"] - host = var.USERS[nick]["host"] - acc = var.USERS[nick]["account"] - else: - return # Can't do it - if not acc or acc == "*": - acc = None - if not host and not acc: - return # Can't do it, either - if acc: - if penalty == 0: - if acc in var.STASISED_ACCS: - del var.STASISED_ACCS[acc] - var.set_stasis_acc(acc, 0) - else: - var.STASISED_ACCS[acc] += penalty - var.set_stasis_acc(acc, var.STASISED_ACCS[acc]) - if (not var.ACCOUNTS_ONLY or not acc) and host: - hostmask = ident + "@" + host - if penalty == 0: - if hostmask in var.STASISED: - del var.STASISED[hostmask] - var.set_stasis(hostmask, 0) - else: - var.STASISED[hostmask] += penalty - var.set_stasis(hostmask, var.STASISED[hostmask]) - -@cmd("fsync", admin_only=True, pm=True) +@cmd("fsync", flag="m", pm=True) def fsync(cli, nick, chan, rest): """Makes the bot apply the currently appropriate channel modes.""" sync_modes(cli) @@ -478,8 +455,12 @@ def sync_modes(cli): mass_mode(cli, voices, other) +@cmd("refreshdb", flag="m", pm=True) +def refreshdb(cli, nick, chan, rest): + """Updates our tracking vars to the current db state.""" + db.init_vars() -@cmd("fdie", "fbye", admin_only=True, pm=True) +@cmd("fdie", "fbye", flag="D", pm=True) def forced_exit(cli, nick, chan, rest): """Forces the bot to close.""" @@ -540,7 +521,7 @@ def _restart_program(cli, mode=None): os.execl(python, python, *sys.argv) -@cmd("frestart", admin_only=True, pm=True) +@cmd("frestart", flag="D", pm=True) def restart_program(cli, nick, chan, rest): """Restarts the bot.""" @@ -657,20 +638,20 @@ def mark_simple_notify(cli, nick, chan, rest): if acc: # Prioritize account if acc in var.SIMPLE_NOTIFY_ACCS: var.SIMPLE_NOTIFY_ACCS.remove(acc) - var.remove_simple_rolemsg_acc(acc) + db.toggle_simple(acc, None) if host in var.SIMPLE_NOTIFY: var.SIMPLE_NOTIFY.remove(host) - var.remove_simple_rolemsg(host) + db.toggle_simple(None, host) fullmask = ident + "@" + host if fullmask in var.SIMPLE_NOTIFY: var.SIMPLE_NOTIFY.remove(fullmask) - var.remove_simple_rolemsg(fullmask) + db.toggle_simple(None, fullmask) cli.notice(nick, messages["simple_off"]) return var.SIMPLE_NOTIFY_ACCS.add(acc) - var.add_simple_rolemsg_acc(acc) + db.toggle_simple(acc, None) elif var.ACCOUNTS_ONLY: cli.notice(nick, messages["not_logged_in"]) return @@ -678,7 +659,7 @@ def mark_simple_notify(cli, nick, chan, rest): else: # Not logged in, fall back to ident@hostmask if host in var.SIMPLE_NOTIFY: var.SIMPLE_NOTIFY.remove(host) - var.remove_simple_rolemsg(host) + db.toggle_simple(None, host) cli.notice(nick, messages["simple_off"]) return @@ -686,13 +667,13 @@ def mark_simple_notify(cli, nick, chan, rest): fullmask = ident + "@" + host if fullmask in var.SIMPLE_NOTIFY: var.SIMPLE_NOTIFY.remove(fullmask) - var.remove_simple_rolemsg(fullmask) + db.toggle_simple(None, fullmask) cli.notice(nick, messages["simple_off"]) return var.SIMPLE_NOTIFY.add(fullmask) - var.add_simple_rolemsg(fullmask) + db.toggle_simple(None, fullmask) cli.notice(nick, messages["simple_on"]) @@ -713,20 +694,20 @@ def mark_prefer_notice(cli, nick, chan, rest): if acc and not var.DISABLE_ACCOUNTS: # Do things by account if logged in if acc in var.PREFER_NOTICE_ACCS: var.PREFER_NOTICE_ACCS.remove(acc) - var.remove_prefer_notice_acc(acc) + db.toggle_notice(acc, None) if host in var.PREFER_NOTICE: var.PREFER_NOTICE.remove(host) - var.remove_prefer_notice(host) + db.toggle_notice(None, host) fullmask = ident + "@" + host if fullmask in var.PREFER_NOTICE: var.PREFER_NOTICE.remove(fullmask) - var.remove_prefer_notice(fullmask) + db.toggle_notice(None, fullmask) cli.notice(nick, messages["notice_off"]) return var.PREFER_NOTICE_ACCS.add(acc) - var.add_prefer_notice_acc(acc) + db.toggle_notice(acc, None) elif var.ACCOUNTS_ONLY: cli.notice(nick, messages["not_logged_in"]) return @@ -734,20 +715,20 @@ def mark_prefer_notice(cli, nick, chan, rest): else: # Not logged in if host in var.PREFER_NOTICE: var.PREFER_NOTICE.remove(host) - var.remove_prefer_notice(host) + db.toggle_notice(None, host) cli.notice(nick, messages["notice_off"]) return fullmask = ident + "@" + host if fullmask in var.PREFER_NOTICE: var.PREFER_NOTICE.remove(fullmask) - var.remove_prefer_notice(fullmask) + db.toggle_notice(None, fullmask) cli.notice(nick, messages["notice_off"]) return var.PREFER_NOTICE.add(fullmask) - var.add_prefer_notice(fullmask) + db.toggle_notice(None, fullmask) cli.notice(nick, messages["notice_on"]) @@ -914,7 +895,7 @@ def toggle_altpinged_status(nick, value, old=None): if not var.DISABLE_ACCOUNTS and acc and acc != "*": if acc in var.PING_IF_PREFS_ACCS: del var.PING_IF_PREFS_ACCS[acc] - var.set_pingif_status(acc, True, 0) + db.set_pingif(0, acc, None) if old is not None: with var.WARNING_LOCK: if old in var.PING_IF_NUMS_ACCS: @@ -923,7 +904,7 @@ def toggle_altpinged_status(nick, value, old=None): for hostmask in list(var.PING_IF_PREFS.keys()): if var.match_hostmask(hostmask, nick, ident, host): del var.PING_IF_PREFS[hostmask] - var.set_pingif_status(hostmask, False, 0) + db.set_pingif(0, None, hostmask) if old is not None: with var.WARNING_LOCK: if old in var.PING_IF_NUMS.keys(): @@ -932,7 +913,7 @@ def toggle_altpinged_status(nick, value, old=None): else: if not var.DISABLE_ACCOUNTS and acc and acc != "*": var.PING_IF_PREFS_ACCS[acc] = value - var.set_pingif_status(acc, True, value) + db.set_pingif(value, acc, None) with var.WARNING_LOCK: if value not in var.PING_IF_NUMS_ACCS: var.PING_IF_NUMS_ACCS[value] = set() @@ -943,7 +924,7 @@ def toggle_altpinged_status(nick, value, old=None): elif not var.ACCOUNTS_ONLY: hostmask = ident + "@" + host var.PING_IF_PREFS[hostmask] = value - var.set_pingif_status(hostmask, False, value) + db.set_pingif(value, None, hostmask) with var.WARNING_LOCK: if value not in var.PING_IF_NUMS.keys(): var.PING_IF_NUMS[value] = set() @@ -1139,12 +1120,12 @@ def deadchat_pref(cli, nick, chan, rest): if value in variable: msg = messages["chat_on_death"] variable.remove(value) - var.remove_deadchat_pref(value, value == acc) + db.toggle_deadchat(acc, host) else: msg = messages["no_chat_on_death"] variable.add(value) - var.add_deadchat_pref(value, value == acc) + db.toggle_deadchat(acc, host) reply(cli, nick, chan, msg, private=True) @@ -1191,11 +1172,13 @@ def join_player(cli, player, chan, who=None, forced=False, *, sanity=True): ident = var.USERS[player]["ident"] host = var.USERS[player]["host"] acc = var.USERS[player]["account"] + hostmask = player + "!" + ident + "@" + host elif is_fake_nick(player) and botconfig.DEBUG_MODE: # fakenick ident = None host = None acc = None + hostmask = None else: return False # Not normal if not acc or acc == "*" or var.DISABLE_ACCOUNTS: @@ -1205,19 +1188,18 @@ def join_player(cli, player, chan, who=None, forced=False, *, sanity=True): if stasis > 0: if forced and stasis == 1: - for hostmask in list(var.STASISED.keys()): - if var.match_hostmask(hostmask, player, ident, host): - var.set_stasis(hostmask, 0) - del var.STASISED[hostmask] - if not var.DISABLE_ACCOUNTS and acc in var.STASISED_ACCS: - var.set_stasis_acc(acc, 0) - del var.STASISED_ACCS[acc] + decrement_stasis(player) else: cli.notice(who, messages["stasis"].format( "you are" if player == who else player + " is", stasis, "s" if stasis != 1 else "")) return False + # don't check unacked warnings on fjoin + if who == player and db.has_unacknowledged_warnings(acc, hostmask): + cli.notice(player, messages["warn_unacked"]) + return False + cmodes = [("+v", player)] if var.PHASE == "none": if var.AUTO_TOGGLE_MODES and player in var.USERS and var.USERS[player]["modes"]: @@ -1332,12 +1314,15 @@ def kill_join(cli, chan): reset() cli.msg(chan, msg) cli.msg(chan, messages["game_idle_cancel"]) + # use this opportunity to expire pending stasis + db.expire_stasis() + db.init_vars() if var.AFTER_FLASTGAME is not None: var.AFTER_FLASTGAME() var.AFTER_FLASTGAME = None -@cmd("fjoin", admin_only=True) +@cmd("fjoin", flag="A") def fjoin(cli, nick, chan, rest): """Forces someone to join a game.""" # keep this and the event in def join() in sync @@ -1395,7 +1380,7 @@ def fjoin(cli, nick, chan, rest): if fake: cli.msg(chan, messages["fjoin_success"].format(nick, len(var.list_players()))) -@cmd("fleave", "fquit", admin_only=True, pm=True, phases=("join", "day", "night")) +@cmd("fleave", "fquit", flag="A", pm=True, phases=("join", "day", "night")) def fleave(cli, nick, chan, rest): """Forces someone to leave the game.""" @@ -1428,7 +1413,7 @@ def fleave(cli, nick, chan, rest): if a in rset: var.ORIGINAL_ROLES[r].remove(a) var.ORIGINAL_ROLES[r].add("(dced)"+a) - make_stasis(a, var.LEAVE_STASIS_PENALTY) + add_warning(a, var.LEAVE_PENALTY, botconfig.NICK, messages["leave_warning"]) if a in var.PLAYERS: var.DCED_PLAYERS[a] = var.PLAYERS.pop(a) @@ -1445,7 +1430,7 @@ def fleave(cli, nick, chan, rest): cli.msg(chan, messages["not_playing"].format(a)) return -@cmd("fstart", admin_only=True, phases=("join",)) +@cmd("fstart", flag="A", phases=("join",)) def fstart(cli, nick, chan, rest): """Forces the game to start immediately.""" cli.msg(botconfig.CHANNEL, messages["fstart_success"].format(nick)) @@ -2129,7 +2114,7 @@ def hurry_up(cli, gameid, change): -@cmd("fnight", admin_only=True) +@cmd("fnight", flag="d") def fnight(cli, nick, chan, rest): """Forces the day to end and night to begin.""" if var.PHASE != "day": @@ -2138,7 +2123,7 @@ def fnight(cli, nick, chan, rest): hurry_up(cli, 0, True) -@cmd("fday", admin_only=True) +@cmd("fday", flag="d") def fday(cli, nick, chan, rest): """Forces the night to end and the next day to begin.""" if var.PHASE != "night": @@ -2536,14 +2521,19 @@ def stop_game(cli, winner = "", abort = False, additional_winners = None): # Only update if someone actually won, "" indicates everyone died or abnormal game stop if winner != "": plrl = {} + pltp = defaultdict(list) winners = [] + player_list = [] if additional_winners is not None: winners.extend(additional_winners) for role,ppl in var.ORIGINAL_ROLES.items(): if role in var.TEMPLATE_RESTRICTIONS.keys(): + for x in ppl: + if x is not None: + pltp[x].append(role) continue for x in ppl: - if x != None: + if x is not None: if x in var.FINAL_ROLES: plrl[x] = var.FINAL_ROLES[x] else: @@ -2551,24 +2541,42 @@ def stop_game(cli, winner = "", abort = False, additional_winners = None): for plr, rol in plrl.items(): orol = rol # original role, since we overwrite rol in case of clone splr = plr # plr stripped of the (dced) bit at the front, since other dicts don't have that - # TODO: figure out how player stats should work when var.DISABLE_ACCOUNTS is True; likely track by nick + pentry = {"nick": None, + "account": None, + "ident": None, + "host": None, + "role": None, + "templates": [], + "special": [], + "won": False, + "iwon": False, + "dced": False} if plr.startswith("(dced)"): + pentry["dced"] = True splr = plr[6:] - if var.DISABLE_ACCOUNTS: - acc = splr - elif splr in var.DCED_PLAYERS.keys(): - acc = var.DCED_PLAYERS[splr]["account"] - elif splr in var.PLAYERS.keys(): - acc = var.PLAYERS[splr]["account"] - else: - acc = "*" - elif plr in var.PLAYERS.keys(): - if var.DISABLE_ACCOUNTS: - acc = plr - else: - acc = var.PLAYERS[plr]["account"] - else: - acc = "*" #probably fjoin'd fake + if splr in var.USERS: + if not var.DISABLE_ACCOUNTS: + pentry["account"] = var.USERS[splr]["account"] + pentry["nick"] = splr + pentry["ident"] = var.USERS[splr]["ident"] + pentry["host"] = var.USERS[splr]["host"] + elif plr in var.USERS: + if not var.DISABLE_ACCOUNTS: + pentry["account"] = var.USERS[plr]["account"] + pentry["nick"] = plr + pentry["ident"] = var.USERS[plr]["ident"] + pentry["host"] = var.USERS[plr]["host"] + + pentry["role"] = rol + pentry["templates"] = pltp[plr] + if splr in var.LOVERS: + pentry["special"].append("lover") + if splr in var.ENTRANCED: + pentry["special"].append("entranced") + if splr in var.VENGEFUL_GHOSTS: + pentry["special"].append("vg activated") + if var.VENGEFUL_GHOSTS[splr][0] == "!": + pentry["special"].append("vg driven off") won = False iwon = False @@ -2668,18 +2676,31 @@ def stop_game(cli, winner = "", abort = False, additional_winners = None): elif not iwon: iwon = won and splr in survived # survived, team won = individual win - if acc != "*": - var.update_role_stats(acc, orol, won, iwon) - for role in var.TEMPLATE_RESTRICTIONS.keys(): - if plr in var.ORIGINAL_ROLES[role]: - var.update_role_stats(acc, role, won, iwon) - if splr in var.LOVERS: - var.update_role_stats(acc, "lover", won, iwon) + pentry["won"] = won + pentry["iwon"] = iwon if won or iwon: winners.append(splr) - var.update_game_stats(var.CURRENT_GAMEMODE.name, len(survived) + len(var.DEAD), winner) + if pentry["nick"] is not None: + # don't record fjoined fakes + player_list.append(pentry) + + game_options = {"role reveal": var.ROLE_REVEAL, + "stats": var.STATS_TYPE, + "abstain": "on" if var.ABSTAIN_ENABLED and not var.LIMIT_ABSTAIN else "restricted" if var.ABSTAIN_ENABLED else "off", + "roles": {}} + for role,pl in var.ORIGINAL_ROLES.items(): + if len(pl) > 0: + game_options["roles"][role] = len(pl) + + db.add_game(var.CURRENT_GAMEMODE.name, + len(survived) + len(var.DEAD), + time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(var.GAME_ID)), + time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()), + winner, + player_list, + game_options) # spit out the list of winners winners.sort() @@ -3379,7 +3400,7 @@ def reaper(cli, gameid): if nck in rlist: var.ORIGINAL_ROLES[r].remove(nck) var.ORIGINAL_ROLES[r].add("(dced)"+nck) - make_stasis(nck, var.IDLE_STASIS_PENALTY) + add_warning(nck, var.IDLE_PENALTY, botconfig.NICK, messages["idle_warning"]) del_player(cli, nck, end_game = False, death_triggers = False) chk_win(cli) pl = var.list_players() @@ -3396,7 +3417,7 @@ def reaper(cli, gameid): else: cli.msg(chan, messages["quit_death_no_reveal"].format(dcedplayer)) if var.PHASE != "join": - make_stasis(dcedplayer, var.PART_STASIS_PENALTY) + add_warning(dcedplayer, var.PART_PENALTY, botconfig.NICK, messages["part_warning"]) if not del_player(cli, dcedplayer, devoice = False, death_triggers = False): return elif what == "part" and (datetime.now() - timeofdc) > timedelta(seconds=var.PART_GRACE_TIME): @@ -3405,7 +3426,7 @@ def reaper(cli, gameid): else: cli.msg(chan, messages["part_death_no_reveal"].format(dcedplayer)) if var.PHASE != "join": - make_stasis(dcedplayer, var.PART_STASIS_PENALTY) + add_warning(dcedplayer, var.PART_PENALTY, botconfig.NICK, messages["part_warning"]) if not del_player(cli, dcedplayer, devoice = False, death_triggers = False): return elif what == "account" and (datetime.now() - timeofdc) > timedelta(seconds=var.ACC_GRACE_TIME): @@ -3414,7 +3435,7 @@ def reaper(cli, gameid): else: cli.msg(chan, messages["account_death_no_reveal"].format(dcedplayer)) if var.PHASE != "join": - make_stasis(dcedplayer, var.ACC_STASIS_PENALTY) + add_warning(dcedplayer, var.ACC_PENALTY, botconfig.NICK, messages["acc_warning"]) if not del_player(cli, dcedplayer, devoice = False, death_triggers = False): return time.sleep(10) @@ -3514,7 +3535,7 @@ def goat(cli, nick, chan, rest): var.GOATED = True -@cmd("fgoat", admin_only=True) +@cmd("fgoat", flag="j") def fgoat(cli, nick, chan, rest): """Forces a goat to interact with anyone or anything, without limitations.""" nick_ = rest.split(' ')[0].strip() @@ -3842,7 +3863,7 @@ def leave(cli, what, nick, why=""): msg = (messages["leave_death"] + "{2}").format(nick, var.get_reveal_role(nick), population) else: msg = (messages["leave_death_no_reveal"] + "{1}").format(nick, population) - make_stasis(nick, var.LEAVE_STASIS_PENALTY) + add_warning(nick, var.LEAVE_PENALTY, botconfig.NICK, messages["leave_warning"]) cli.msg(botconfig.CHANNEL, msg) var.SPECTATING_WOLFCHAT.discard(nick) var.SPECTATING_DEADCHAT.discard(nick) @@ -3907,7 +3928,7 @@ def leave_game(cli, nick, chan, rest): if nick in rset: var.ORIGINAL_ROLES[r].remove(nick) var.ORIGINAL_ROLES[r].add("(dced)"+nick) - make_stasis(nick, var.LEAVE_STASIS_PENALTY) + add_warning(nick, var.LEAVE_PENALTY, botconfig.NICK, messages["leave_warning"]) if nick in var.PLAYERS: var.DCED_PLAYERS[nick] = var.PLAYERS.pop(nick) @@ -7866,18 +7887,7 @@ def start(cli, nick, chan, forced = False, restart = ""): var.GAMEPHASE = "day" transition_day(cli) - for hostmask in list(var.STASISED.keys()): - var.STASISED[hostmask] -= 1 - var.set_stasis(hostmask, var.STASISED[hostmask]) - if var.STASISED[hostmask] <= 0: - del var.STASISED[hostmask] - - if not var.DISABLE_ACCOUNTS: - for acc in list(var.STASISED_ACCS.keys()): - var.STASISED_ACCS[acc] -= 1 - var.set_stasis_acc(acc, var.STASISED_ACCS[acc]) - if var.STASISED_ACCS[acc] <= 0: - del var.STASISED_ACCS[acc] + decrement_stasis() if not botconfig.DEBUG_MODE or not var.DISABLE_DEBUG_MODE_REAPER: # DEATH TO IDLERS! @@ -7885,8 +7895,6 @@ def start(cli, nick, chan, forced = False, restart = ""): reapertimer.daemon = True reapertimer.start() - - @hook("error") def on_error(cli, pfx, msg): if var.RESTARTING or msg.endswith("(Excess Flood)"): @@ -7894,144 +7902,6 @@ def on_error(cli, pfx, msg): elif msg.startswith("Closing Link:"): raise SystemExit -@cmd("stasis", chan=True, pm=True) -def stasis(cli, nick, chan, rest): - st = is_user_stasised(nick) - - if st: - msg = messages["your_current_stasis"].format(st, "" if st == 1 else "s") - else: - msg = messages["you_not_in_stasis"] - - reply(cli, nick, chan, msg, prefix_nick=True) - -@cmd("fstasis", admin_only=True, pm=True) -def fstasis(cli, nick, chan, rest): - """Removes or sets stasis penalties.""" - - data = rest.split() - msg = None - - if data: - lusers = {k.lower(): v for k, v in var.USERS.items()} - user = data[0] - - if user.lower() in lusers: - ident = lusers[user.lower()]["ident"] - host = lusers[user.lower()]["host"] - acc = lusers[user.lower()]["account"] - hostmask = ident + "@" + host - else: - hostmask = user - acc = None - if var.ACCOUNTS_ONLY and acc == "*": - acc = None - hostmask = None - msg = messages["account_not_logged_in"].format(user) - if not acc and user in var.STASISED_ACCS: - acc = user - - err_msg = messages["stasis_non_negative"] - if (not var.ACCOUNTS_ONLY or not acc) and hostmask: - if len(data) == 1: - if hostmask in var.STASISED: - plural = "" if var.STASISED[hostmask] == 1 else "s" - msg = messages["hostmask_in_stasis"].format(data[0], hostmask, var.STASISED[hostmask], plural) - else: - msg = messages["hostmask_not_in_stasis"].format(data[0], hostmask) - else: - try: - amt = int(data[1]) - except ValueError: - if chan == nick: - pm(cli, nick, err_msg) - else: - cli.notice(nick, err_msg) - - return - - if amt < 0: - if chan == nick: - pm(cli, nick, err_msg) - else: - cli.notice(nick, err_msg) - - return - elif amt > 2**31-1: - amt = 2**31-1 - - if amt > 0: - var.STASISED[hostmask] = amt - var.set_stasis(hostmask, amt) - plural = "" if amt == 1 else "s" - msg = messages["fstasis_hostmask_add"].format(data[0], hostmask, amt, plural) - elif amt == 0: - if hostmask in var.STASISED: - del var.STASISED[hostmask] - var.set_stasis(hostmask, 0) - msg = messages["fstasis_hostmask_remove"].format(data[0], hostmask) - else: - msg = messages["hostmask_not_in_stasis"].format(data[0], hostmask) - if not var.DISABLE_ACCOUNTS and acc: - if len(data) == 1: - if acc in var.STASISED_ACCS: - plural = "" if var.STASISED_ACCS[acc] == 1 else "s" - msg = messages["account_in_stasis"].format(data[0], acc, var.STASISED_ACCS[acc], plural) - else: - msg = messages["account_not_in_stasis"].format(data[0], acc) - else: - try: - amt = int(data[1]) - except ValueError: - if chan == nick: - pm(cli, nick, err_msg) - else: - cli.notice(nick, err_msg) - return - - if amt < 0: - if chan == nick: - pm(cli, nick, err_msg) - else: - cli.notice(nick, err_msg) - return - elif amt > 2**31-1: - amt = 2**31-1 - - if amt > 0: - var.STASISED_ACCS[acc] = amt - var.set_stasis_acc(acc, amt) - plural = "" if amt == 1 else "s" - msg = messages["fstasis_account_add"].format(data[0], acc, amt, plural) - elif amt == 0: - if acc in var.STASISED_ACCS: - del var.STASISED_ACCS[acc] - var.set_stasis_acc(acc, 0) - msg = messages["fstasis_account_remove"].format(data[0], acc) - else: - msg = messages["account_not_in_stasis"].format(data[0], acc) - elif var.STASISED or var.STASISED_ACCS: - stasised = {} - for hostmask in var.STASISED: - if var.DISABLE_ACCOUNTS: - stasised[hostmask] = var.STASISED[hostmask] - else: - stasised[hostmask+" (Host)"] = var.STASISED[hostmask] - if not var.DISABLE_ACCOUNTS: - for acc in var.STASISED_ACCS: - stasised[acc+" (Account)"] = var.STASISED_ACCS[acc] - msg = messages["currently_stasised"].format(", ".join( - "\u0002{0}\u0002 ({1})".format(usr, number) - for usr, number in stasised.items())) - else: - msg = messages["noone_stasised"] - - if msg: - if chan == nick: - pm(cli, nick, msg) - else: - cli.msg(chan, msg) - def is_user_stasised(nick): """Checks if a user is in stasis. Returns a number of games in stasis.""" @@ -8050,271 +7920,867 @@ def is_user_stasised(nick): amount = max(amount, var.STASISED[hostmask]) return amount -def allow_deny(cli, nick, chan, rest, mode): +def decrement_stasis(nick=None): + if nick and nick in var.USERS: + ident = var.USERS[nick]["ident"] + host = var.USERS[nick]["host"] + acc = var.USERS[nick]["account"] + # decrement account stasis even if accounts are disabled + if acc in var.STASISED_ACCS: + db.decrement_stasis(acc=acc) + for hostmask in var.STASISED: + if var.match_hostmask(hostmask, nick, ident, host): + db.decrement_stasis(hostmask=hostmask) + else: + db.decrement_stasis() + # Also expire any expired stasis and update our tracking vars + db.expire_stasis() + db.init_vars() + +def parse_warning_target(target): + if target[0] == "=": + if var.DISABLE_ACCOUNTS: + return (None, None) + tacc = target[1:] + thm = None + elif target in var.USERS: + tacc = var.USERS[target]["account"] + thm = target + "!" + var.USERS[target]["ident"] + "@" + var.USERS[target]["host"] + elif "@" in target: + tacc = None + thm = target + elif not var.DISABLE_ACCOUNTS: + tacc = target + thm = None + else: + return (None, None) + return (tacc, thm) + +def add_warning(target, amount, actor, reason, notes=None, expires=None, need_ack=False, sanctions={}): + tacc, thm = parse_warning_target(target) + if tacc is None and thm is None: + return False + + if actor not in var.USERS and actor != botconfig.NICK: + return False + sacc = None + shm = None + if actor in var.USERS: + sacc = var.USERS[actor]["account"] + shm = actor + "!" + var.USERS[actor]["ident"] + "@" + var.USERS[actor]["host"] + + # determine if we need to automatically add any sanctions + prev = db.get_warning_points(tacc, thm) + cur = prev + amount + for (mn, mx, sanc) in var.AUTO_SANCTION: + if (prev < mn and cur >= mn) or (prev >= mn and prev <= mx and cur <= mx): + if "ack" in sanc: + need_ack = True + if "stasis" in sanc: + if "stasis" not in sanctions: + sanctions["stasis"] = sanc["stasis"] + else: + sanctions["stasis"] = max(sanctions["stasis"], sanc["stasis"]) + if "scalestasis" in sanc: + (a, b, c) = sanc["scalestasis"] + amt = (a * cur * cur) + (b * cur) + c + if "stasis" not in sanctions: + sanctions["stasis"] = amt + else: + sanctions["stasis"] = max(sanctions["stasis"], amt) + if "deny" in sanc: + if "deny" not in sanctions: + sanctions["deny"] = set(sanc["deny"]) + else: + sanctions["deny"].update(sanc["deny"]) + if "tempban" in sanc: + # XXX: need to do this somehow, leaving here as a reminder for later + pass + + sid = db.add_warning(tacc, thm, sacc, shm, amount, reason, notes, expires, need_ack) + if "stasis" in sanctions: + db.add_warning_sanction(sid, "stasis", sanctions["stasis"]) + if "deny" in sanctions: + for cmd in sanctions["deny"]: + db.add_warning_sanction(sid, "deny command", cmd) + + # Update any tracking vars that may have changed due to this + db.init_vars() + + return sid + +@cmd("stasis", chan=True, pm=True) +def stasis(cli, nick, chan, rest): + st = is_user_stasised(nick) + if st: + msg = messages["your_current_stasis"].format(st, "" if st == 1 else "s") + else: + msg = messages["you_not_in_stasis"] + + reply(cli, nick, chan, msg, prefix_nick=True) + +@cmd("fstasis", flag="A", chan=True, pm=True) +def fstasis(cli, nick, chan, rest): + """Removes or views stasis penalties.""" + data = rest.split() msg = None - modes = ("allow", "deny") - assert mode in modes, "mode not in {!r}".format(modes) - - opts = defaultdict(bool) - - if data and data[0].startswith("-"): - if data[0] == "-cmds": - opts["cmds"] = True - elif data[0] == "-cmd": - if len(data) < 2: - if chan == nick: - pm(cli, nick, messages["no_command_specified"]) - else: - cli.notice(nick, messages["no_command_specified"]) + if data: + lusers = {k.lower(): v for k, v in var.USERS.items()} + acc, hostmask = parse_warning_target(data[0]) + cur = max(var.STASISED[hostmask], var.STASISED_ACCS[acc]) + if len(data) == 1: + if acc is not None and var.STASISED_ACCS[acc] == cur: + plural = "" if cur == 1 else "s" + reply(cli, nick, chan, messages["account_in_stasis"].format(data[0], acc, cur, plural)) + elif hostmask is not None and var.STASISED[hostmask] == cur: + plural = "" if cur == 1 else "s" + reply(cli, nick, chan, messages["hostmask_in_stasis"].format(data[0], hostmask, cur, plural)) + elif acc is not None: + reply(cli, nick, chan, messages["account_not_in_stasis"].format(data[0], acc)) + else: + reply(cli, nick, chan, messages["hostmask_not_in_stasis"].format(data[0], hostmask)) + else: + try: + amt = int(data[1]) + except ValueError: + reply(cli, nick, chan, messages["stasis_not_negative"]) return - opts["cmd"] = data[1] - data = data[1:] - elif data[0] == "-acc" or data[0] == "-account": - opts["acc"] = True - elif data[0] == "-host": - opts["host"] = True - else: - if chan == nick: - pm(cli, nick, messages["invalid_option"].format(data[0][1:])) - else: - cli.notice(nick, messages["invalid_option"].format(data[0][1:])) + if amt < 0: + reply(cli, nick, chan, messages["stasis_not_negative"]) + return + elif amt > cur: + reply(cli, nick, chan, messages["stasis_cannot_increase"]) + return + elif cur == 0: + if acc is not None: + reply(cli, nick, chan, messages["account_not_in_stasis"].format(data[0], acc)) + return + else: + reply(cli, nick, chan, messages["hostmask_not_in_stasis"].format(data[0], hostmask)) + return + db.decrease_stasis(amt, acc, hostmask) + db.init_vars() + if amt > 0: + plural = "" if amt == 1 else "s" + if acc is not None: + reply(cli, nick, chan, messages["fstasis_account_add"].format(data[0], acc, amt, plural)) + else: + reply(cli, nick, chan, messages["fstasis_hostmask_add"].format(data[0], hostmask, amt, plural)) + elif acc is not None: + reply(cli, nick, chan, messages["fstasis_account_remove"].format(data[0], acc)) + else: + reply(cli, nick, chan, messages["fstasis_hostmask_remove"].format(data[0], hostmask)) + elif var.STASISED or var.STASISED_ACCS: + stasised = {} + for hostmask in var.STASISED: + if var.DISABLE_ACCOUNTS: + stasised[hostmask] = var.STASISED[hostmask] + else: + stasised[hostmask+" (Host)"] = var.STASISED[hostmask] + if not var.DISABLE_ACCOUNTS: + for acc in var.STASISED_ACCS: + stasised[acc+" (Account)"] = var.STASISED_ACCS[acc] + msg = messages["currently_stasised"].format(", ".join( + "\u0002{0}\u0002 ({1})".format(usr, number) + for usr, number in stasised.items())) + reply(cli, nick, chan, msg) + else: + reply(cli, nick, chan, messages["noone_stasised"]) + +@cmd("warn", pm=True) +def warn(cli, nick, chan, rest): + """View and acknowledge your warnings.""" + # !warn list [-all] [page] - lists all active warnings, or all warnings if all passed + # !warn view - views details on warning id + # !warn ack - acknowledges warning id + # Default if only !warn is given is to do !warn list. + params = re.split(" +", rest) + + try: + command = params.pop(0) + if command == "": + command = "list" + except IndexError: + command = "list" + + if command not in ("list", "view", "ack", "help"): + reply(cli, nick, chan, messages["warn_usage"]) + return + + if command == "help": + try: + subcommand = params.pop(0) + except IndexError: + reply(cli, nick, chan, messages["warn_help_syntax"]) + return + if subcommand not in ("list", "view", "ack", "help"): + reply(cli, nick, chan, messages["warn_usage"]) + return + reply(cli, nick, chan, messages["warn_{0}_syntax".format(subcommand)]) + return + + if command == "list": + list_all = False + page = 1 + try: + list_all = params.pop(0) + target = params.pop(0) + page = int(params.pop(0)) + except IndexError: + pass + except ValueError: + reply(cli, nick, chan, messages["fwarn_page_invalid"]) return - data = data[1:] + try: + if list_all and list_all != "-all": + page = int(list_all) + list_all = False + elif list_all == "-all": + list_all = True + except ValueError: + reply(cli, nick, chan, messages["fwarn_page_invalid"]) + return - if data and not opts["cmd"]: - lusers = {k.lower(): v for k, v in var.USERS.items()} - user = data[0] + acc, hm = parse_warning_target(nick) + warnings = db.list_warnings(acc, hm, expired=list_all, skip=(page-1)*10, show=11) + points = db.get_warning_points(acc, hm) + cli.notice(nick, messages["warn_list_header"].format(points)) - if opts["acc"] and user != "*": - hostmask = None - acc = user - elif not opts["host"] and user.lower() in lusers: - ident = lusers[user.lower()]["ident"] - host = lusers[user.lower()]["host"] - acc = lusers[user.lower()]["account"] - hostmask = ident + "@" + host + i = 0 + for warn in warnings: + i += 1 + if (i == 11): + parts = [] + if list_all: + parts.append("-all") + parts.append(str(page + 1)) + cli.notice(nick, messages["warn_list_footer"].format(" ".join(parts))) + break + start = "" + end = "" + ack = "" + if warn["expires"] is not None: + if warn["expired"]: + expires = messages["fwarn_list_expired"].format(warn["expires"]) + else: + expires = messages["fwarn_view_expires"].format(warn["expires"]) + else: + expires = messages["fwarn_never_expires"] + if warn["expired"]: + start = "\u000314" + end = " [\u00037{0}\u000314]\u0003".format(messages["fwarn_expired"]) + if not warn["ack"]: + ack = "\u0002!\u0002 " + cli.notice(nick, messages["warn_list"].format( + start, ack, warn["id"], warn["issued"], warn["reason"], warn["amount"], expires, end)) + if i == 0: + cli.notice(nick, messages["fwarn_list_empty"]) + return + + if command == "view": + try: + warn_id = int(params.pop(0)) + except (IndexError, ValueError): + reply(cli, nick, chan, messages["warn_view_syntax"]) + return + + acc, hm = parse_warning_target(nick) + warning = db.get_warning(warn_id, acc, hm) + if warning is None: + reply(cli, nick, chan, messages["fwarn_invalid_warning"]) + return + + if warning["expired"]: + expires = messages["fwarn_view_expired"].format(warning["expires"]) + elif warning["expires"] is None: + expires = messages["fwarn_view_active"].format(messages["fwarn_never_expires"]) else: - hostmask = user - m = re.match('(?:(?:(.*?)!)?(.*)@)?(.*)', hostmask) - user = m.group(1) or "" - ident = m.group(2) or "" - host = m.group(3) - acc = None + expires = messages["fwarn_view_active"].format(messages["fwarn_view_expires"].format(warning["expires"])) - if user == "*": - opts["host"] = True + cli.notice(nick, messages["warn_view_header"].format( + warning["id"], warning["issued"], warning["amount"], expires)) + cli.notice(nick, warning["reason"]) - if not var.DISABLE_ACCOUNTS and acc: - if mode == "allow": - variable = var.ALLOW_ACCOUNTS - noaccvar = var.ALLOW - else: - variable = var.DENY_ACCOUNTS - noaccvar = var.DENY - if len(data) == 1: - cmds = set() - if acc in variable: - cmds |= set(variable[acc]) - - if hostmask and not opts["acc"]: - for mask in noaccvar: - if var.match_hostmask(mask, user, ident, host): - cmds |= set(noaccvar[mask]) - - if cmds: - msg = "\u0002{0}\u0002 (Account: {1}) is {2} the following {3}commands: {4}.".format( - data[0], acc, "allowed" if mode == "allow" else "denied", "special " if mode == "allow" else "", ", ".join(cmds)) + sanctions = [] + if not warning["ack"]: + sanctions.append(messages["warn_view_ack"].format(warning["id"])) + if warning["sanctions"]: + sanctions.append(messages["fwarn_view_sanctions"]) + if "stasis" in warning["sanctions"]: + if warning["sanctions"]["stasis"] != 1: + sanctions.append(messages["fwarn_view_stasis_plural"].format(warning["sanctions"]["stasis"])) else: - msg = "\u0002{0}\u0002 (Account: {1}) is not {2} commands.".format(data[0], acc, "allowed any special" if mode == "allow" else "denied any") - else: - if acc not in variable: - variable[acc] = set() - commands = data[1:] - for command in commands: # Add or remove commands one at a time to a specific account - if "-*" in commands: # Remove all - for cmd in variable[acc]: - if mode == "allow": - var.remove_allow_acc(acc, cmd) - else: - var.remove_deny_acc(acc, cmd) - del variable[acc] - break - if command[0] == "-": # Starting with - (to remove) - rem = True - command = command[1:] - else: - rem = False - if command.startswith(botconfig.CMD_CHAR): # ignore command prefix - command = command[len(botconfig.CMD_CHAR):] + sanctions.append(messages["fwarn_view_stasis_sing"]) + if "deny" in warning["sanctions"]: + sanctions.append(messages["fwarn_view_deny"].format(", ".join(warning["sanctions"]["deny"]))) + if sanctions: + cli.notice(nick, " ".join(sanctions)) + return - if not rem: - if command in COMMANDS and command not in ("fdeny", "fallow", "fsend", "exec", "eval") and command not in variable[acc]: - variable[acc].add(command) - if mode == "allow": - var.add_allow_acc(acc, command) - else: - var.add_deny_acc(acc, command) - elif command in variable[acc]: - variable[acc].remove(command) - if mode == "allow": - var.remove_allow_acc(acc, command) - else: - var.remove_deny_acc(acc, command) - if acc in variable and variable[acc]: - msg = "\u0002{0}\u0002 (Account: {1}) is now {2} the following {3}commands: {4}{5}.".format( - data[0], acc, "allowed" if mode == "allow" else "denied", "special " if mode == "allow" else "", botconfig.CMD_CHAR, ", {0}".format(botconfig.CMD_CHAR).join(variable[acc])) - else: - if acc in variable: - del variable[acc] - msg = "\u0002{0}\u0002 (Account: {1}) is no longer {2} commands.".format(data[0], acc, "allowed any special" if mode == 'allow' else "denied any") - elif var.ACCOUNTS_ONLY and not opts["host"]: - msg = "Error: \u0002{0}\u0002 is not logged in to NickServ.".format(data[0]) + if command == "ack": + try: + warn_id = int(params.pop(0)) + except (IndexError, ValueError): + reply(cli, nick, chan, messages["warn_ack_syntax"]) + return + + acc, hm = parse_warning_target(nick) + warning = db.get_warning(warn_id, acc, hm) + if warning is None: + reply(cli, nick, chan, messages["fwarn_invalid_warning"]) + return + + db.acknowledge_warning(warn_id) + reply(cli, nick, chan, messages["fwarn_done"]) + return + +@cmd("fwarn", flag="A", pm=True) +def fwarn(cli, nick, chan, rest): + """Issues a warning to someone or views warnings.""" + # !fwarn list [-all] [nick] [page] + # -all => Shows all warnings, if omitted only shows active (non-expired and non-deleted) ones. + # nick => nick to view warnings for. Can also be a hostmask in nick!user@host form. If nick + # is not online, interpreted as an account name. To specify an account if nick is online, + # use =account. If not specified, shows all warnings on the bot. + # !fwarn view - views details on warning id + # !fwarn del - deletes warning id + # !fwarn set [~expiry] [reason] [| notes] + # !fwarn add [@] [~expiry] [sanctions] <:reason> [| notes] + # e.g. !fwarn add lykos @1 ~30d deny=goat,gstats stasis=5 :Spamming | I secretly just hate him + # nick => nick to warn. Can also be a hostmask in nick!user@host form. If nick is not online, + # interpreted as an account name. To specify an account if nick is online, use =account. + # @ => warning requires acknowledgement before user can !join again + # points => Warning points, must be above 0 + # ~expiry => Expiration time, must be suffixed with d (days), h (hours), or m (minutes) + # sanctions => list of sanctions. Valid sanctions are: + # deny: denies access to the listed commands + # stasis: gives the user stasis + # :reason => Reason, required. Must be prefixed with : + # |notes => Secret notes, not shown to the user (only shown if viewing the warning in PM) + # If specified, must be prefixed with |. This means | is not a valid character for use + # in reasons (no escaping is performed). + + params = re.split(" +", rest) + target = None + points = None + need_ack = False + expires = None + sanctions = {} + reason = None + notes = None + + try: + command = params.pop(0) + except IndexError: + reply(cli, nick, chan, messages["fwarn_usage"]) + return + + if command not in ("list", "view", "add", "del", "set", "help"): + reply(cli, nick, chan, messages["fwarn_usage"]) + return + + if command == "help": + try: + subcommand = params.pop(0) + except IndexError: + reply(cli, nick, chan, messages["fwarn_help_syntax"]) + return + if subcommand not in ("list", "view", "add", "del", "set", "help"): + reply(cli, nick, chan, messages["fwarn_usage"]) + return + reply(cli, nick, chan, messages["fwarn_{0}_syntax".format(subcommand)]) + return + + if command == "list": + list_all = False + page = 1 + try: + list_all = params.pop(0) + target = params.pop(0) + page = int(params.pop(0)) + except IndexError: + pass + except ValueError: + reply(cli, nick, chan, messages["fwarn_page_invalid"]) + return + + try: + if list_all and list_all != "-all": + if target is not None: + page = int(target) + target = list_all + list_all = False + elif list_all == "-all": + list_all = True + except ValueError: + reply(cli, nick, chan, messages["fwarn_page_invalid"]) + return + + try: + page = int(target) + target = None + except (TypeError, ValueError): + pass + + if target is not None: + acc, hm = parse_warning_target(target) + if acc is None and hm is None: + reply(cli, nick, chan, messages["fwarn_nick_invalid"]) + return + warnings = db.list_warnings(acc, hm, list_all=list_all, skip=(page-1)*10, show=11) + points = db.get_warning_points(acc, hm) + cli.notice(nick, messages["fwarn_list_header"].format(target, points)) else: - if mode == "allow": - variable = var.ALLOW - else: - variable = var.DENY - if len(data) == 1: # List commands for a specific hostmask - cmds = [] - for mask in variable: - if var.match_hostmask(mask, user, ident, host): - cmds.extend(variable[mask]) + warnings = db.list_all_warnings(list_all=list_all, skip=(page-1)*10, show=11) - if cmds: - msg = "\u0002{0}\u0002 (Host: {1}) is {2} the following {3}commands: {4}.".format( - data[0], hostmask, "allowed" if mode == "allow" else "denied", "special " if mode == "allow" else "", ", ".join(cmds)) + i = 0 + for warn in warnings: + i += 1 + if (i == 11): + parts = [] + if list_all: + parts.append("-all") + if target is not None: + parts.append(target) + parts.append(str(page + 1)) + cli.notice(nick, messages["fwarn_list_footer"].format(" ".join(parts))) + break + start = "" + end = "" + ack = "" + if warn["expires"] is not None: + if warn["expired"]: + expires = messages["fwarn_list_expired"].format(warn["expires"]) else: - msg = "\u0002{0}\u0002 (Host: {1}) is not {2} commands.".format(data[0], hostmask, "allowed any special" if mode == "allow" else "denied any") + expires = messages["fwarn_view_expires"].format(warn["expires"]) else: - if hostmask not in variable: - variable[hostmask] = set() - commands = data[1:] - for command in commands: #add or remove commands one at a time to a specific hostmask - if "-*" in commands: # Remove all - for cmd in variable[hostmask]: - if mode == "allow": - var.remove_allow(hostmask, cmd) - else: - var.remove_deny(hostmask, cmd) - del variable[hostmask] - break - if command[0] == '-': #starting with - removes - rem = True - command = command[1:] - else: - rem = False - if command.startswith(botconfig.CMD_CHAR): #ignore command prefix - command = command[len(botconfig.CMD_CHAR):] + expires = messages["fwarn_never_expires"] + if warn["deleted"]: + start = "\u000314" + end = " [\u00034{0}\u000314]\u0003".format(messages["fwarn_deleted"]) + elif warn["expired"]: + start = "\u000314" + end = " [\u00037{0}\u000314]\u0003".format(messages["fwarn_expired"]) + if not warn["ack"]: + ack = "\u0002!\u0002 " + cli.notice(nick, messages["fwarn_list"].format( + start, ack, warn["id"], warn["issued"], warn["target"], + warn["sender"], warn["reason"], warn["amount"], expires, end)) + if i == 0: + cli.notice(nick, messages["fwarn_list_empty"]) + return - if not rem: - if command in COMMANDS and command not in ("fdeny", "fallow", "fsend", "exec", "eval") and command not in variable[hostmask]: - variable[hostmask].add(command) - if mode == "allow": - var.add_allow(hostmask, command) - else: - var.add_deny(hostmask, command) - elif command in variable[hostmask]: - variable[hostmask].remove(command) - if mode == "allow": - var.remove_allow(hostmask, command) - else: - var.remove_deny(hostmask, command) + if command == "view": + try: + warn_id = int(params.pop(0)) + except (IndexError, ValueError): + reply(cli, nick, chan, messages["fwarn_view_syntax"]) + return - if hostmask in variable and variable[hostmask]: - msg = "\u0002{0}\u0002 (Host: {1}) is now {2} the following {3}commands: {4}{5}.".format( - data[0], hostmask, "allowed" if mode == "allow" else "denied", "special " if mode == "allow" else "", botconfig.CMD_CHAR, ", {0}".format(botconfig.CMD_CHAR).join(variable[hostmask])) + warning = db.get_warning(warn_id) + if warning is None: + reply(cli, nick, chan, messages["fwarn_invalid_warning"]) + return + + if warning["deleted"]: + expires = messages["fwarn_view_deleted"].format(warning["deleted_on"], warning["deleted_by"]) + elif warning["expired"]: + expires = messages["fwarn_view_expired"].format(warning["expires"]) + elif warning["expires"] is None: + expires = messages["fwarn_view_active"].format(messages["fwarn_never_expires"]) + else: + expires = messages["fwarn_view_active"].format(messages["fwarn_view_expires"].format(warning["expires"])) + + cli.notice(nick, messages["fwarn_view_header"].format( + warning["id"], warning["target"], warning["issued"], warning["sender"], + warning["amount"], expires)) + + reason = [warning["reason"]] + if warning["notes"] is not None: + reason.append(warning["notes"]) + cli.notice(nick, " | ".join(reason)) + + sanctions = [] + if not warning["ack"]: + sanctions.append(messages["fwarn_view_ack"]) + if warning["sanctions"]: + sanctions.append(messages["fwarn_view_sanctions"]) + if "stasis" in warning["sanctions"]: + if warning["sanctions"]["stasis"] != 1: + sanctions.append(messages["fwarn_view_stasis_plural"].format(warning["sanctions"]["stasis"])) else: - if hostmask in variable: - del variable[hostmask] - msg = "\u0002{0}\u0002 (Host: {1}) is no longer {2} commands.".format(data[0], hostmask, "allowed any special" if mode == "allow" else "denied any") + sanctions.append(messages["fwarn_view_stasis_sing"]) + if "deny" in warning["sanctions"]: + sanctions.append(messages["fwarn_view_deny"].format(", ".join(warning["sanctions"]["deny"]))) + if sanctions: + cli.notice(nick, " ".join(sanctions)) + return + if command == "del": + try: + warn_id = int(params.pop(0)) + except (IndexError, ValueError): + reply(cli, nick, chan, messages["fwarn_del_syntax"]) + return + + warning = db.get_warning(warn_id) + if warning is None: + reply(cli, nick, chan, messages["fwarn_invalid_warning"]) + return + + acc, hm = parse_warning_target(nick) + db.del_warning(warn_id, acc, hm) + reply(cli, nick, chan, messages["fwarn_done"]) + return + + if command == "set": + try: + warn_id = int(params.pop(0)) + except (IndexError, ValueError): + reply(cli, nick, chan, messages["fwarn_set_syntax"]) + return + + warning = db.get_warning(warn_id) + if warning is None: + reply(cli, nick, chan, messages["fwarn_invalid_warning"]) + return + + rsp = " ".join(params).split("|", 1) + if len(rsp) == 1: + rsp.append(None) + reason, notes = rsp + reason = reason.strip() + + # check for modified expiry + expires = warning["expires"] + rsp = reason.split(" ", 1) + if rsp[0] and rsp[0][0] == "~": + if len(rsp) == 1: + rsp.append("") + expires, reason = rsp + expires = expires[1:] + reason = reason.strip() + + if expires in messages["never_aliases"]: + expires = None + else: + suffix = expires[-1] + try: + amount = int(expires[:-1]) + except ValueError: + reply(cli, nick, chan, messages["fwarn_expiry_invalid"]) + return + + if amount <= 0: + reply(cli, nick, chan, messages["fwarn_expiry_invalid"]) + return + + issued = datetime.strptime(warning["issued"], "%Y-%m-%d %H:%M:%S") + if suffix == "d": + expires = issued + timedelta(days=amount) + elif suffix == "h": + expires = issued + timedelta(hours=amount) + elif suffix == "m": + expires = issued + timedelta(minutes=amount) + else: + reply(cli, nick, chan, messages["fwarn_expiry_invalid_suffix"]) + return + + # maintain existing reason if none was specified + if not reason: + reason = warning["reason"] + + # maintain existing notes if none were specified + if notes is not None: + notes = notes.strip() + if not notes: + notes = None + else: + notes = warning["notes"] + + db.set_warning(warn_id, expires, reason, notes) + reply(cli, nick, chan, messages["fwarn_done"]) + return + + # command == "add" + while params: + p = params.pop(0) + if target is None: + # figuring out what target actually is is handled in add_warning + target = p + elif points is None: + points = p + if points[0] == "@": + points = points[1:] + need_ack = True + try: + points = int(points) + except ValueError: + reply(cli, nick, chan, messages["fwarn_points_invalid"]) + return + if points < 1: + reply(cli, nick, chan, messages["fwarn_points_invalid"]) + return + elif notes is not None: + notes += " " + p + elif reason is not None: + rsp = p.split("|", 1) + if len(rsp) > 1: + notes = rsp[1] + reason += " " + rsp[0] + elif p[0] == ":": + if p == ":": + reason = "" + else: + reason = p[1:] + elif p[0] == "~": + if p == "~": + reply(cli, nick, chan, messages["fwarn_syntax"]) + return + expires = p[1:] + else: + # sanctions are the only thing left here + sanc = p.split("=", 1) + if sanc[0] == "deny": + try: + cmds = sanc[1].split(",") + normalized_cmds = set() + for cmd in cmds: + normalized = None + for obj in COMMANDS[cmd]: + # do not allow denying in-game commands (vote, see, etc.) + # this technically traps goat too, so special case that, as we want + # goat to be deny-able. Furthermore, the warn command cannot be denied. + if (not obj.playing and not obj.roles) or obj.name == "goat": + normalized = obj.name + if normalized == "warn": + normalized = None + if normalized is None: + reply(cli, nick, chan, messages["fwarn_deny_invalid_command"].format(cmd)) + return + normalized_cmds.add(normalized) + sanctions["deny"] = normalized_cmds + except IndexError: + reply(cli, nick, chan, messages["fwarn_deny_invalid"]) + return + elif sanc[0] == "stasis": + try: + sanctions["stasis"] = int(sanc[1]) + except (IndexError, ValueError): + reply(cli, nick, messages["fwarn_stasis_invalid"]) + return + else: + reply(cli, nick, chan, messages["fwarn_sanction_invalid"]) + return + + if target is None or points is None or reason is None: + reply(cli, nick, chan, messages["fwarn_add_syntax"]) + return + + reason = reason.strip() + if notes is not None: + notes = notes.strip() + + # convert expires into a proper datetime + if expires is None: + expires = var.DEFAULT_EXPIRY + + if expires.lower() in messages["never_aliases"]: + expires = None else: - users_to_cmds = {} - if not var.DISABLE_ACCOUNTS and not opts["host"]: - if mode == "allow": - variable = var.ALLOW_ACCOUNTS - noaccvar = var.ALLOW - else: - variable = var.DENY_ACCOUNTS - noaccvar = var.DENY + suffix = expires[-1] + try: + amount = int(expires[:-1]) + except ValueError: + reply(cli, nick, chan, messages["fwarn_expiry_invalid"]) + return - if variable: - for acc, varied in variable.items(): - if opts["acc"] or (var.ACCOUNTS_ONLY and not noaccvar): - users_to_cmds[acc] = sorted(varied, key=str.lower) - else: - users_to_cmds[acc+" (Account)"] = sorted(varied, key=str.lower) - if not opts["acc"]: - if mode == "allow": - variable = var.ALLOW - else: - variable = var.DENY - if variable: - for hostmask, varied in variable.items(): - if var.DISABLE_ACCOUNTS or opts["host"]: - users_to_cmds[hostmask] = sorted(varied, key=str.lower) - else: - users_to_cmds[hostmask+" (Host)"] = sorted(varied, key=str.lower) + if amount <= 0: + reply(cli, nick, chan, messages["fwarn_expiry_invalid"]) + return - - if not users_to_cmds: # Deny or Allow list is empty - msg = "Nobody is {0} commands.".format("allowed any special" if mode == "allow" else "denied any") + if suffix == "d": + expires = datetime.now() + timedelta(days=amount) + elif suffix == "h": + expires = datetime.now() + timedelta(hours=amount) + elif suffix == "m": + expires = datetime.now() + timedelta(minutes=amount) else: - if opts["cmds"] or opts["cmd"]: - cmds_to_users = defaultdict(list) + reply(cli, nick, chan, messages["fwarn_expiry_invalid_suffix"]) + return - for user in sorted(users_to_cmds, key=str.lower): - for cmd in users_to_cmds[user]: - cmds_to_users[cmd].append(user) + warn_id = add_warning(target, points, nick, reason, notes, expires, need_ack, sanctions) + if warn_id is False: + reply(cli, nick, chan, messages["fwarn_cannot_add"]) + else: + reply(cli, nick, chan, messages["fwarn_added"].format(warn_id)) - if opts["cmd"]: - cmd = opts["cmd"] - users = cmds_to_users[cmd] +@cmd("ftemplate", "F", pm=True) +def ftemplate(cli, nick, chan, rest): + params = re.split(" +", rest) - if cmd not in COMMANDS: - if chan == nick: - pm(cli, nick, messages["command_does_not_exist"]) - else: - cli.notice(nick, messages["command_does_not_exist"]) + if params[0] == "": + # display a list of all templates + tpls = db.get_templates() + if not tpls: + reply(cli, nick, chan, messages["no_templates"]) + else: + tpls = ["{0} (+{1})".format(name, "".join(sorted(flags))) for name, flags in tpls] + reply(cli, nick, chan, var.break_long_message(tpls, ", ")) + elif len(params) == 1: + reply(cli, nick, chan, messages["not_enough_parameters"]) + else: + name = params[0].upper() + flags = params[1] + tid, cur_flags = db.get_template(name) - return - - if users: - msg = "\u0002{0}{1}\u0002 is {2} to the following people: {3}".format( - botconfig.CMD_CHAR, opts["cmd"], "allowed" if mode == "allow" else "denied", ", ".join(users)) + if flags[0] != "+" and flags[0] != "-": + # flags is a template name + tpl_name = flags.upper() + tpl_id, tpl_flags = db.get_template(tpl_name) + if tpl_id is None: + reply(cli, nick, chan, messages["template_not_found"].format(tpl_name)) + return + tpl_flags = "".join(sorted(tpl_flags)) + db.update_template(name, tpl_flags) + reply(cli, nick, chan, messages["template_set"].format(name, tpl_flags)) + else: + adding = True + for flag in flags: + if flag == "+": + adding = True + continue + elif flag == "-": + adding = False + continue + elif flag == "*": + if adding: + cur_flags = cur_flags | (var.ALL_FLAGS - {"F"}) else: - msg = "\u0002{0}{1}\u0002 is not {2} to any special people.".format( - botconfig.CMD_CHAR, opts["cmd"], "allowed" if mode == "allow" else "denied") + cur_flags = set() + continue + elif flag not in var.ALL_FLAGS: + reply(cli, nick, chan, messages["invalid_flag"].format(flag, "".join(sorted(var.ALL_FLAGS)))) + return + elif adding: + cur_flags.add(flag) else: - msg = "{0}: {1}".format("Allowed" if mode == "allow" else "Denied", "; ".join("\u0002{0}\u0002 ({1})".format( - cmd, ", ".join(users)) for cmd, users in sorted(cmds_to_users.items(), key=lambda t: t[0].lower()))) + cur_flags.discard(flag) + if cur_flags: + tpl_flags = "".join(sorted(cur_flags)) + db.update_template(name, tpl_flags) + reply(cli, nick, chan, messages["template_set"].format(name, tpl_flags)) + elif tid is None: + reply(cli, nick, chan, messages["template_not_found"].format(name)) else: - msg = "{0}: {1}".format("Allowed" if mode == "allow" else "Denied", "; ".join("\u0002{0}\u0002 ({1})".format( - user, ", ".join(cmds)) for user, cmds in sorted(users_to_cmds.items(), key=lambda t: t[0].lower()))) + db.delete_template(name) + reply(cli, nick, chan, messages["template_deleted"].format(name)) - if msg: - msg = var.break_long_message(msg.split("; "), "; ") + # re-init var.FLAGS and var.FLAGS_ACCS since they may have changed + db.init_vars() - if chan == nick: - pm(cli, nick, msg) +@cmd("fflags", flag="F", pm=True) +def fflags(cli, nick, chan, rest): + params = re.split(" +", rest) + + if params[0] == "": + # display a list of all access + parts = [] + for acc, flags in var.FLAGS_ACCS.items(): + if not flags: + continue + if var.ACCOUNTS_ONLY: + parts.append("{0} (+{1})".format(acc, "".join(sorted(flags)))) + else: + parts.append("{0} (Account) (+{1})".format(acc, "".join(sorted(flags)))) + for hm, flags in var.FLAGS.items(): + if not flags: + continue + if var.DISABLE_ACCOUNTS: + parts.append("{0} (+{1})".format(hm, "".join(sorted(flags)))) + else: + parts.append("{0} (Host) (+{1})".format(hm, "".join(sorted(flags)))) + if not parts: + reply(cli, nick, chan, messages["no_access"]) else: - cli.msg(chan, msg) + reply(cli, nick, chan, var.break_long_message(parts, ", ")) + elif len(params) == 1: + # display access for the given user + acc, hm = parse_warning_target(params[0]) + if acc is not None: + if not var.FLAGS_ACCS[acc]: + msg = messages["no_access_account"].format(acc) + else: + msg = messages["access_account"].format(acc, "".join(sorted(var.FLAGS_ACCS[acc]))) + elif hm is not None: + if not var.FLAGS[hm]: + msg = messages["no_access_host"].format(hm) + else: + msg = messages["access_host"].format(acc, "".join(sorted(var.FLAGS[hm]))) + reply(cli, nick, chan, msg) + else: + acc, hm = parse_warning_target(params[0]) + flags = params[1] + cur_flags = set(var.FLAGS_ACCS[acc] + var.FLAGS[hm]) -@cmd("fallow", admin_only=True, pm=True) -def fallow(cli, nick, chan, rest): - """Allow someone to use an admin command.""" - allow_deny(cli, nick, chan, rest, "allow") + if flags[0] != "+" and flags[0] != "-": + # flags is a template name + tpl_name = flags.upper() + tpl_id, tpl_flags = db.get_template(tpl_name) + if tpl_id is None: + reply(cli, nick, chan, messages["template_not_found"].format(tpl_name)) + return + tpl_flags = "".join(sorted(tpl_flags)) + db.set_access(acc, hm, tid=tpl_id) + if acc is not None: + reply(cli, nick, chan, messages["access_set_account"].format(acc, tpl_flags)) + else: + reply(cli, nick, chan, messages["access_set_host"].format(hm, tpl_flags)) + else: + adding = True + for flag in flags: + if flag == "+": + adding = True + continue + elif flag == "-": + adding = False + continue + elif flag == "*": + if adding: + cur_flags = cur_flags | (var.ALL_FLAGS - {"F"}) + else: + cur_flags = set() + continue + elif flag not in var.ALL_FLAGS: + reply(cli, nick, chan, messages["invalid_flag"].format(flag, "".join(sorted(var.ALL_FLAGS)))) + return + elif adding: + cur_flags.add(flag) + else: + cur_flags.discard(flag) + if cur_flags: + flags = "".join(sorted(cur_flags)) + db.set_access(acc, hm, flags=flags) + if acc is not None: + reply(cli, nick, chan, messages["access_set_account"].format(acc, flags)) + else: + reply(cli, nick, chan, messages["access_set_host"].format(hm, flags)) + else: + db.set_access(acc, hm, flags=None) + if acc is not None: + reply(cli, nick, chan, messages["access_deleted_account"].format(acc)) + else: + reply(cli, nick, chan, messages["access_deleted_host"].format(hm)) + + # re-init var.FLAGS and var.FLAGS_ACCS since they may have changed + db.init_vars() -@cmd("fdeny", admin_only=True, pm=True) -def fdeny(cli, nick, chan, rest): - """Deny someone from using a command.""" - allow_deny(cli, nick, chan, rest, "deny") @cmd("wait", "w", playing=True, phases=("join",)) def wait(cli, nick, chan, rest): @@ -8347,7 +8813,7 @@ def wait(cli, nick, chan, rest): cli.msg(chan, messages["wait_time_increase"].format(nick, var.EXTRA_WAIT)) -@cmd("fwait", admin_only=True, phases=("join",)) +@cmd("fwait", flag="A", phases=("join",)) def fwait(cli, nick, chan, rest): """Forces an increase (or decrease) in wait time. Can be used with a number of seconds to wait.""" @@ -8374,7 +8840,7 @@ def fwait(cli, nick, chan, rest): cli.msg(chan, messages["forced_wait_time_decrease"].format(nick, abs(extra), "s" if extra != -1 else "")) -@cmd("fstop", admin_only=True, phases=("join", "day", "night")) +@cmd("fstop", flag="A", phases=("join", "day", "night")) def reset_game(cli, nick, chan, rest): """Forces the game to stop.""" if nick == "": @@ -8442,13 +8908,13 @@ def get_help(cli, rnick, chan, rest): # if command was not found, or if no command was given: for name, fn in COMMANDS.items(): - if (name and not fn[0].admin_only and not fn[0].owner_only and + if (name and not fn[0].flag and not fn[0].owner_only and name not in fn[0].aliases and fn[0].chan): fns.append("{0}{1}{0}".format("\u0002", name)) afns = [] if is_admin(nick, ident, host): for name, fn in COMMANDS.items(): - if fn[0].admin_only and name not in fn[0].aliases: + if fn[0].flag and name not in fn[0].aliases: afns.append("{0}{1}{0}".format("\u0002", name)) fns.sort() # Output commands in alphabetical order if chan == nick: @@ -8511,7 +8977,7 @@ def on_invite(cli, raw_nick, something, chan): else: pm(cli, parse_nick(nick)[0], messages["not_an_admin"]) -@cmd("fpart", raw_nick=True, admin_only=True, pm=True) +@cmd("fpart", raw_nick=True, flag="A", pm=True) def fpart(cli, rnick, chan, rest): """Makes the bot forcibly leave a channel.""" nick = parse_nick(rnick)[0] @@ -8831,7 +9297,7 @@ def myrole(cli, nick, chan, rest): message += "." pm(cli, nick, message) -@cmd("faftergame", admin_only=True, raw_nick=True, pm=True) +@cmd("faftergame", flag="D", raw_nick=True, pm=True) def aftergame(cli, rawnick, chan, rest): """Schedule a command to be run after the current game.""" nick = parse_nick(rawnick)[0] @@ -8865,7 +9331,7 @@ def aftergame(cli, rawnick, chan, rest): var.AFTER_FLASTGAME = do_action -@cmd("flastgame", admin_only=True, raw_nick=True, pm=True) +@cmd("flastgame", flag="D", raw_nick=True, pm=True) def flastgame(cli, rawnick, chan, rest): """Disables starting or joining a game, and optionally schedules a command to run after the current game ends.""" nick, _, ident, host = parse_nick(rawnick) @@ -8917,10 +9383,10 @@ def game_stats(cli, nick, chan, rest): # List all games sizes and totals if no size is given if not gamesize: - reply(cli, nick, chan, var.get_game_totals(gamemode)) + reply(cli, nick, chan, db.get_game_totals(gamemode)) else: # Attempt to find game stats for the given game size - reply(cli, nick, chan, var.get_game_stats(gamemode, gamesize)) + reply(cli, nick, chan, db.get_game_stats(gamemode, gamesize)) @cmd("playerstats", "pstats", "player", "p", pm=True) def player_stats(cli, nick, chan, rest): @@ -8949,21 +9415,25 @@ def player_stats(cli, nick, chan, rest): # Find the player's account if possible luser = user.lower() lusers = {k.lower(): v for k, v in var.USERS.items()} - if luser in lusers and not var.DISABLE_ACCOUNTS: + if luser in lusers: acc = lusers[luser]["account"] - if acc == "*": + hostmask = luser + "!" + lusers[luser]["ident"] + "@" + lusers[luser]["host"] + if acc == "*" and var.ACCOUNTS_ONLY: if luser == nick.lower(): cli.notice(nick, messages["not_logged_in"]) else: cli.notice(nick, messages["account_not_logged_in"].format(user)) - return + elif "@" in user: + acc = None + hostmask = user else: acc = user + hostmask = None # List the player's total games for all roles if no role is given if len(params) < 2: - reply(cli, nick, chan, var.get_player_totals(acc), private=True) + reply(cli, nick, chan, db.get_player_totals(acc, hostmask), private=True) else: role = " ".join(params[1:]) if role not in var.ROLE_GUIDE.keys(): @@ -8973,7 +9443,7 @@ def player_stats(cli, nick, chan, rest): return role = match # Attempt to find the player's stats - reply(cli, nick, chan, var.get_player_stats(acc, role)) + reply(cli, nick, chan, db.get_player_stats(acc, hostmask, role)) @cmd("mystats", "m", pm=True) def my_stats(cli, nick, chan, rest): @@ -9041,7 +9511,7 @@ def vote(cli, nick, chan, rest): else: return show_votes.caller(cli, nick, chan, rest) -@cmd("fpull", admin_only=True, pm=True) +@cmd("fpull", flag="D", pm=True) def fpull(cli, nick, chan, rest): """Pulls from the repository to update the bot.""" @@ -9073,7 +9543,7 @@ def fpull(cli, nick, chan, rest): else: pm(cli, nick, messages["process_exited"] % (command, cause, ret)) -@cmd("fsend", admin_only=True, pm=True) +@cmd("fsend", flag="F", pm=True) def fsend(cli, nick, chan, rest): """Forcibly send raw IRC commands to the server.""" cli.send(rest) @@ -9108,12 +9578,12 @@ def _say(cli, raw_nick, rest, command, action=False): cli.send("PRIVMSG {0} :{1}".format(target, message)) -@cmd("fsay", admin_only=True, raw_nick=True, pm=True) +@cmd("fsay", flag="s", raw_nick=True, pm=True) def fsay(cli, raw_nick, chan, rest): """Talk through the bot as a normal message.""" _say(cli, raw_nick, rest, "fsay") -@cmd("fact", "fdo", "fme", admin_only=True, raw_nick=True, pm=True) +@cmd("fact", "fdo", "fme", flag="s", raw_nick=True, pm=True) def fact(cli, raw_nick, chan, rest): """Act through the bot as an action.""" _say(cli, raw_nick, rest, "fact", action=True) @@ -9140,7 +9610,7 @@ def can_run_restricted_cmd(nick): return True -@cmd("fspectate", admin_only=True, pm=True, phases=("day", "night")) +@cmd("fspectate", flag="A", pm=True, phases=("day", "night")) def fspectate(cli, nick, chan, rest): """Spectate wolfchat or deadchat.""" if not can_run_restricted_cmd(nick): @@ -9206,7 +9676,7 @@ if botconfig.DEBUG_MODE or botconfig.ALLOWED_NORMAL_MODE_COMMANDS: except Exception as e: cli.msg(chan, str(type(e))+":"+str(e)) - @cmd("revealroles", admin_only=True, pm=True, phases=("day", "night")) + @cmd("revealroles", flag="a", pm=True, phases=("day", "night")) def revealroles(cli, nick, chan, rest): """Reveal role information.""" @@ -9317,7 +9787,7 @@ if botconfig.DEBUG_MODE or botconfig.ALLOWED_NORMAL_MODE_COMMANDS: cli.notice(nick, var.break_long_message(output, " | ")) - @cmd("fgame", admin_only=True, raw_nick=True, phases=("join",)) + @cmd("fgame", flag="d", raw_nick=True, phases=("join",)) def fgame(cli, nick, chan, rest): """Force a certain game mode to be picked. Disable voting for game modes upon use.""" nick = parse_nick(nick)[0] @@ -9366,7 +9836,7 @@ if botconfig.DEBUG_MODE or botconfig.ALLOWED_NORMAL_MODE_COMMANDS: # DO NOT MAKE THIS A PMCOMMAND ALSO - @cmd("force", admin_only=True) + @cmd("force", flag="d") def force(cli, nick, chan, rest): """Force a certain player to use a specific command.""" rst = re.split(" +",rest) @@ -9395,7 +9865,7 @@ if botconfig.DEBUG_MODE or botconfig.ALLOWED_NORMAL_MODE_COMMANDS: for fn in COMMANDS[comm]: if fn.owner_only: continue - if fn.admin_only and nick in var.USERS and not is_admin(nick): + if fn.flag and nick in var.USERS and not is_admin(nick): # Not a full admin cli.notice(nick, messages["admin_only_force"]) continue @@ -9409,7 +9879,7 @@ if botconfig.DEBUG_MODE or botconfig.ALLOWED_NORMAL_MODE_COMMANDS: cli.msg(chan, messages["command_not_found"]) - @cmd("rforce", admin_only=True) + @cmd("rforce", flag="d") def rforce(cli, nick, chan, rest): """Force all players of a given role to perform a certain action.""" rst = re.split(" +",rest) @@ -9435,7 +9905,7 @@ if botconfig.DEBUG_MODE or botconfig.ALLOWED_NORMAL_MODE_COMMANDS: for fn in COMMANDS[comm]: if fn.owner_only: continue - if fn.admin_only and nick in var.USERS and not is_admin(nick): + if fn.flag and nick in var.USERS and not is_admin(nick): # Not a full admin cli.notice(nick, messages["admin_only_force"]) continue @@ -9450,7 +9920,7 @@ if botconfig.DEBUG_MODE or botconfig.ALLOWED_NORMAL_MODE_COMMANDS: - @cmd("frole", admin_only=True, phases=("day", "night")) + @cmd("frole", flag="d", phases=("day", "night")) def frole(cli, nick, chan, rest): """Change the role or template of a player.""" rst = re.split(" +",rest)