Merge pull request #225 from lykoss/newdb

- new schema, including stats tracking
- fwarn/warn commands to view and manipulate warnings
- fstasis can now only decrease stasis, not add to it
- refreshdb command can sync bot game state with what is in the db
  (including expiring any unexpired stasis or warnings)
- stasis now expires
- tempban is still not implemented and will not be implemented as
  part of the PR (it will come later, if ever)
- sanctions can be automatically applied after warnings cross a
  certain threshold; some defaults are configured
- fflags/ftemplate for permissions revamp
This commit is contained in:
Ryan Schmidt 2016-06-21 13:28:18 -07:00 committed by GitHub
commit c08cd3efbc
9 changed files with 2350 additions and 971 deletions

View File

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

View File

@ -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 <subcommand> for more details.",
"warn_usage": "Usage: warn list|view|ack|help. See warn help <subcommand> for more details.",
"fwarn_list_syntax": "Usage: fwarn list [-all] [nick[!user@host]|=account] [page]",
"fwarn_view_syntax": "Usage: fwarn view <id>",
"fwarn_del_syntax": "Usage: fwarn del <id>",
"fwarn_set_syntax": "Usage: fwarn set <id> [~expiry] [reason] [| notes]",
"fwarn_help_syntax": "Usage: fwarn help <subcommand>",
"warn_list_syntax": "Usage: warn list [-all] [page]",
"warn_view_syntax": "Usage: warn view <id>",
"warn_ack_syntax": "Usage: warn ack <id>",
"warn_help_syntax": "Uwage: warn help <subcommand>",
"fwarn_add_syntax": "Usage: fwarn add <nick[!user@host]|=account> [@]<points> [~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 <id>\" 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:"
}

View File

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

807
src/db.py Normal file
View File

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

171
src/db.sql Normal file
View File

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

View File

@ -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:
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:
denied_cmds = var.DENY[hostmask] | var.DENY_ACCS[acc]
for command in self.cmds:
if command in var.DENY_ACCOUNTS[acc]:
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:
if self.flag:
if self.flag in flags:
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:
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:

214
src/migrate.sql Normal file
View File

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

View File

@ -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,20 +344,10 @@ 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 priv == "admin":
hosts.update(botconfig.ADMINS)
accounts.update(botconfig.ADMINS_ACCOUNTS)
def do_check(nick, ident=None, host=None, acc=None):
if nick in USERS.keys():
if nick in USERS:
if not ident:
ident = USERS[nick]["ident"]
if not host:
@ -369,10 +367,37 @@ def check_priv(priv):
return False
return do_check
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]
is_admin = check_priv("admin")
is_owner = check_priv("owner")
if not "F" in flags:
try:
hosts = set(botconfig.ADMINS)
accounts = set(botconfig.ADMINS_ACCOUNTS)
if not DISABLE_ACCOUNTS and acc and acc != "*":
for pattern in accounts:
if fnmatch.fnmatch(acc.lower(), pattern.lower()):
return True
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:

File diff suppressed because it is too large Load Diff