Make potato an alias for villager (#290)
Also allow prefixing commands by their role name to remove ambiguity should a person be multiple roles. For example, "seer see foo" and "augur see foo" will now work if a person is both seer and augur (whereas normal see foo would be ambiguous). A player will be directed to use the unambiguous prefixed version if we detect that a role command will fire multiple times for them (note: coming soon). For sanity reasons, these role prefixes are implemented as exclusive commands, meaning no other commands or command aliases may use the same name. Clone needs to be special-cased in this regard, as clone is both a role name and a command name.
This commit is contained in:
parent
0df627fcca
commit
1cc38e54b2
@ -417,6 +417,7 @@
|
|||||||
"curse_success_wolfchat": "\u0002{0}\u0002 has cast a curse on \u0002{1}\u0002.",
|
"curse_success_wolfchat": "\u0002{0}\u0002 has cast a curse on \u0002{1}\u0002.",
|
||||||
"already_cloned": "You have already chosen to clone someone.",
|
"already_cloned": "You have already chosen to clone someone.",
|
||||||
"clone_target_success": "You have chosen to clone \u0002{0}\u0002.",
|
"clone_target_success": "You have chosen to clone \u0002{0}\u0002.",
|
||||||
|
"clone_clone_clone": "Ambiguous command; if you would like to clone someone whose name is or starts with \"clone\", please use \"clone clone clone\".",
|
||||||
"must_charm_multiple": "You must choose two different people.",
|
"must_charm_multiple": "You must choose two different people.",
|
||||||
"no_charm_self": "You may not charm yourself.",
|
"no_charm_self": "You may not charm yourself.",
|
||||||
"targets_already_charmed": "\u0002{0}\u0002 and \u0002{1}\u0002 are already charmed!",
|
"targets_already_charmed": "\u0002{0}\u0002 and \u0002{1}\u0002 are already charmed!",
|
||||||
@ -867,6 +868,7 @@
|
|||||||
"error_pastebin": "(Unable to pastebin traceback; please check the console)",
|
"error_pastebin": "(Unable to pastebin traceback; please check the console)",
|
||||||
"channel_rules": "{0} channel rules: {1}",
|
"channel_rules": "{0} channel rules: {1}",
|
||||||
"no_channel_rules": "No rules are defined for {0}. Set RULES in botconfig.py to configure this.",
|
"no_channel_rules": "No rules are defined for {0}. Set RULES in botconfig.py to configure this.",
|
||||||
|
"ambiguous_command": "Ambiguous command; more than one role you belong to has a \"{0}\" command. Please prefix this command with a role name, for example \"{1} {0} ...\" or \"{2} {0} ...\".",
|
||||||
|
|
||||||
"_": " vim: set sw=4 expandtab:"
|
"_": " vim: set sw=4 expandtab:"
|
||||||
}
|
}
|
||||||
|
@ -203,7 +203,8 @@ class handle_error:
|
|||||||
|
|
||||||
class command:
|
class command:
|
||||||
def __init__(self, *commands, flag=None, owner_only=False, chan=True, pm=False,
|
def __init__(self, *commands, flag=None, owner_only=False, chan=True, pm=False,
|
||||||
playing=False, silenced=False, phases=(), roles=(), users=None):
|
playing=False, silenced=False, phases=(), roles=(), users=None,
|
||||||
|
exclusive=False):
|
||||||
|
|
||||||
self.commands = frozenset(commands)
|
self.commands = frozenset(commands)
|
||||||
self.flag = flag
|
self.flag = flag
|
||||||
@ -219,13 +220,19 @@ class command:
|
|||||||
self.aftergame = False
|
self.aftergame = False
|
||||||
self.name = commands[0]
|
self.name = commands[0]
|
||||||
self.alt_allowed = bool(flag or owner_only)
|
self.alt_allowed = bool(flag or owner_only)
|
||||||
|
self.exclusive = exclusive
|
||||||
|
|
||||||
alias = False
|
alias = False
|
||||||
self.aliases = []
|
self.aliases = []
|
||||||
for name in commands:
|
for name in commands:
|
||||||
|
if exclusive and name in COMMANDS:
|
||||||
|
raise ValueError("exclusive command already exists for {0}".format(name))
|
||||||
|
|
||||||
for func in COMMANDS[name]:
|
for func in COMMANDS[name]:
|
||||||
if func.owner_only != owner_only or func.flag != flag:
|
if func.owner_only != owner_only or func.flag != flag:
|
||||||
raise ValueError("unmatching access levels for {0}".format(func.name))
|
raise ValueError("unmatching access levels for {0}".format(func.name))
|
||||||
|
if func.exclusive:
|
||||||
|
raise ValueError("exclusive command already exists for {0}".format(name))
|
||||||
|
|
||||||
COMMANDS[name].append(self)
|
COMMANDS[name].append(self)
|
||||||
if name in botconfig.ALLOWED_ALT_CHANNELS_COMMANDS:
|
if name in botconfig.ALLOWED_ALT_CHANNELS_COMMANDS:
|
||||||
@ -339,6 +346,7 @@ class cmd:
|
|||||||
self.func = None
|
self.func = None
|
||||||
self.aftergame = False
|
self.aftergame = False
|
||||||
self.name = cmds[0]
|
self.name = cmds[0]
|
||||||
|
self.exclusive = False # for compatibility with new command API
|
||||||
|
|
||||||
alias = False
|
alias = False
|
||||||
self.aliases = []
|
self.aliases = []
|
||||||
@ -347,6 +355,8 @@ class cmd:
|
|||||||
if (func.owner_only != owner_only or
|
if (func.owner_only != owner_only or
|
||||||
func.flag != flag):
|
func.flag != flag):
|
||||||
raise ValueError("unmatching protection levels for " + func.name)
|
raise ValueError("unmatching protection levels for " + func.name)
|
||||||
|
if func.exclusive:
|
||||||
|
raise ValueError("exclusive command already exists for {0}".format(name))
|
||||||
|
|
||||||
COMMANDS[name].append(self)
|
COMMANDS[name].append(self)
|
||||||
if alias:
|
if alias:
|
||||||
|
108
src/handler.py
108
src/handler.py
@ -12,39 +12,115 @@ import botconfig
|
|||||||
import src.settings as var
|
import src.settings as var
|
||||||
from src import decorators, wolfgame, events, channels, hooks, users, errlog as log, stream_handler as alog
|
from src import decorators, wolfgame, events, channels, hooks, users, errlog as log, stream_handler as alog
|
||||||
from src.messages import messages
|
from src.messages import messages
|
||||||
from src.utilities import reply
|
from src.utilities import reply, list_participants, get_role, get_templates
|
||||||
|
from src.dispatcher import MessageDispatcher
|
||||||
|
from src.decorators import handle_error
|
||||||
|
|
||||||
cmd = decorators.cmd
|
cmd = decorators.cmd
|
||||||
hook = decorators.hook
|
hook = decorators.hook
|
||||||
|
|
||||||
def on_privmsg(cli, rawnick, chan, msg, *, notice=False):
|
@handle_error
|
||||||
|
def on_privmsg(cli, rawnick, chan, msg, *, notice=False, force_role=None):
|
||||||
if notice and "!" not in rawnick or not rawnick: # server notice; we don't care about those
|
if notice and "!" not in rawnick or not rawnick: # server notice; we don't care about those
|
||||||
return
|
return
|
||||||
|
|
||||||
if not users.equals(chan, users.Bot.nick) and botconfig.IGNORE_HIDDEN_COMMANDS and not chan.startswith(tuple(hooks.Features["CHANTYPES"])):
|
user = users._get(rawnick, allow_none=True) # FIXME
|
||||||
|
|
||||||
|
if users.equals(chan, users.Bot.nick): # PM
|
||||||
|
target = users.Bot
|
||||||
|
else:
|
||||||
|
target = channels.get(chan, allow_none=True)
|
||||||
|
|
||||||
|
if user is None or target is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if (notice and ((not users.equals(chan, users.Bot.nick) and not botconfig.ALLOW_NOTICE_COMMANDS) or
|
wrapper = MessageDispatcher(user, target)
|
||||||
(users.equals(chan, users.Bot.nick) and not botconfig.ALLOW_PRIVATE_NOTICE_COMMANDS))):
|
|
||||||
|
if wrapper.public and botconfig.IGNORE_HIDDEN_COMMANDS and not chan.startswith(tuple(hooks.Features["CHANTYPES"])):
|
||||||
|
return
|
||||||
|
|
||||||
|
if (notice and ((wrapper.public and not botconfig.ALLOW_NOTICE_COMMANDS) or
|
||||||
|
(wrapper.private and not botconfig.ALLOW_PRIVATE_NOTICE_COMMANDS))):
|
||||||
return # not allowed in settings
|
return # not allowed in settings
|
||||||
|
|
||||||
|
if force_role is None: # if force_role isn't None, that indicates recursion; don't fire these off twice
|
||||||
for fn in decorators.COMMANDS[""]:
|
for fn in decorators.COMMANDS[""]:
|
||||||
fn.caller(cli, rawnick, chan, msg)
|
fn.caller(cli, rawnick, chan, msg)
|
||||||
|
|
||||||
phase = var.PHASE
|
parts = msg.split(sep=" ", maxsplit=1)
|
||||||
for x in list(decorators.COMMANDS.keys()):
|
key = parts[0].lower()
|
||||||
if not users.equals(chan, users.Bot.nick) and not msg.lower().startswith(botconfig.CMD_CHAR):
|
if len(parts) > 1:
|
||||||
break # channel message but no prefix; ignore
|
message = parts[1].lstrip()
|
||||||
if msg.lower().startswith(botconfig.CMD_CHAR+x):
|
|
||||||
h = msg[len(x)+len(botconfig.CMD_CHAR):]
|
|
||||||
elif not x or msg.lower().startswith(x):
|
|
||||||
h = msg[len(x):]
|
|
||||||
else:
|
else:
|
||||||
|
message = ""
|
||||||
|
|
||||||
|
if wrapper.public and not key.startswith(botconfig.CMD_CHAR):
|
||||||
|
return # channel message but no prefix; ignore
|
||||||
|
|
||||||
|
if key.startswith(botconfig.CMD_CHAR):
|
||||||
|
key = key[len(botconfig.CMD_CHAR):]
|
||||||
|
|
||||||
|
if not key: # empty key ("") already handled above
|
||||||
|
return
|
||||||
|
|
||||||
|
# Don't change this into decorators.COMMANDS[key] even though it's a defaultdict,
|
||||||
|
# as we don't want to insert bogus command keys into the dict.
|
||||||
|
cmds = []
|
||||||
|
phase = var.PHASE
|
||||||
|
if user.nick in list_participants():
|
||||||
|
roles = {get_role(user.nick)} | set(get_templates(user.nick))
|
||||||
|
if force_role is not None:
|
||||||
|
roles &= {force_role} # only fire off role commands for the forced role
|
||||||
|
|
||||||
|
common_roles = set(roles) # roles shared by every eligible role command
|
||||||
|
have_role_cmd = False
|
||||||
|
for fn in decorators.COMMANDS.get(key, []):
|
||||||
|
if not fn.roles:
|
||||||
|
cmds.append(fn)
|
||||||
continue
|
continue
|
||||||
if not h or h[0] == " ":
|
if roles.intersection(fn.roles):
|
||||||
for fn in decorators.COMMANDS.get(x, []):
|
have_role_cmd = True
|
||||||
|
cmds.append(fn)
|
||||||
|
common_roles.intersection_update(fn.roles)
|
||||||
|
|
||||||
|
if force_role is not None and not have_role_cmd:
|
||||||
|
# Trying to force a non-role command with a role.
|
||||||
|
# We allow non-role commands to execute if a role is forced if a role
|
||||||
|
# command is also executed, as this would allow (for example) a bot admin
|
||||||
|
# to add extra effects to all "kill" commands without needing to continually
|
||||||
|
# update the list of roles which can use "kill". However, we don't want to
|
||||||
|
# allow things like "wolf pstats" because that just doesn't make sense.
|
||||||
|
return
|
||||||
|
|
||||||
|
if not common_roles:
|
||||||
|
# getting here means that at least one of the role_cmds is disjoint
|
||||||
|
# from the others. For example, augur see vs seer see when a bare see
|
||||||
|
# is executed. In this event, display a helpful error message instructing
|
||||||
|
# the user to resolve the ambiguity.
|
||||||
|
common_roles = set(roles)
|
||||||
|
info = [0,0]
|
||||||
|
for fn in cmds:
|
||||||
|
fn_roles = roles.intersection(fn.roles)
|
||||||
|
if not fn_roles:
|
||||||
|
continue
|
||||||
|
for role1 in common_roles:
|
||||||
|
info[0] = role1
|
||||||
|
break
|
||||||
|
for role2 in fn_roles:
|
||||||
|
info[1] = role2
|
||||||
|
break
|
||||||
|
common_roles &= fn_roles
|
||||||
|
if not common_roles:
|
||||||
|
break
|
||||||
|
wrapper.pm(messages["ambiguous_command"].format(key, info[0], info[1]))
|
||||||
|
return
|
||||||
|
elif force_role is None:
|
||||||
|
cmds = decorators.COMMANDS.get(key, [])
|
||||||
|
|
||||||
|
for fn in cmds:
|
||||||
if phase == var.PHASE:
|
if phase == var.PHASE:
|
||||||
fn.caller(cli, rawnick, chan, h.lstrip())
|
# FIXME: pass in var, wrapper, message instead of cli, rawnick, chan, message
|
||||||
|
fn.caller(cli, rawnick, chan, message)
|
||||||
|
|
||||||
def unhandled(cli, prefix, cmd, *args):
|
def unhandled(cli, prefix, cmd, *args):
|
||||||
for fn in decorators.HOOKS.get(cmd, []):
|
for fn in decorators.HOOKS.get(cmd, []):
|
||||||
|
@ -208,6 +208,15 @@ TRACEBACK_VERBOSITY = 2 # 0 = no locals at all, 1 = innermost frame's locals, 2
|
|||||||
# How often to ping the server (in seconds) to detect unclean disconnection
|
# How often to ping the server (in seconds) to detect unclean disconnection
|
||||||
SERVER_PING_INTERVAL = 120
|
SERVER_PING_INTERVAL = 120
|
||||||
|
|
||||||
|
# Shorthand for naming roles, used to set up command aliases as well as be valid targets when
|
||||||
|
# specifying role names for things (such as !pstats or prophet's !pray)
|
||||||
|
ROLE_ALIASES = {
|
||||||
|
"ga": "guardian angel",
|
||||||
|
"drunk": "village drunk",
|
||||||
|
"cs": "crazed shaman",
|
||||||
|
"potato": "villager",
|
||||||
|
}
|
||||||
|
|
||||||
# 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")
|
# 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"
|
DEFAULT_ROLE = "villager"
|
||||||
ROLE_INDEX = ( 4 , 6 , 7 , 8 , 9 , 10 , 11 , 12 , 13 , 15 , 16 , 18 , 20 , 21 , 23 , 24 )
|
ROLE_INDEX = ( 4 , 6 , 7 , 8 , 9 , 10 , 11 , 12 , 13 , 15 , 16 , 18 , 20 , 21 , 23 , 24 )
|
||||||
@ -322,6 +331,12 @@ DISABLED_ROLES = frozenset()
|
|||||||
# Game modes that cannot be randomly picked or voted for
|
# Game modes that cannot be randomly picked or voted for
|
||||||
DISABLED_GAMEMODES = frozenset()
|
DISABLED_GAMEMODES = frozenset()
|
||||||
|
|
||||||
|
# Roles which have a command equivalent to the role name need to implement special handling for being
|
||||||
|
# passed their command again as a prefix and strip it out. For example, both !clone foo and !clone clone foo
|
||||||
|
# should be valid. Failure to add such a command to this set will result in the bot not starting
|
||||||
|
# with the error "ValueError: exclusive command already exists for ..."
|
||||||
|
ROLE_COMMAND_EXCEPTIONS = set()
|
||||||
|
|
||||||
GIF_CHANCE = 1/50
|
GIF_CHANCE = 1/50
|
||||||
FORTUNE_CHANCE = 1/25
|
FORTUNE_CHANCE = 1/25
|
||||||
|
|
||||||
|
@ -22,6 +22,7 @@
|
|||||||
import copy
|
import copy
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import itertools
|
import itertools
|
||||||
|
import functools
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
@ -2875,6 +2876,32 @@ def update_last_said(cli, nick, chan, rest):
|
|||||||
|
|
||||||
fullstring = "".join(rest)
|
fullstring = "".join(rest)
|
||||||
|
|
||||||
|
def dispatch_role_prefix(var, wrapper, message, *, role):
|
||||||
|
from src import handler
|
||||||
|
_ignore_locals_ = True
|
||||||
|
handler.on_privmsg(wrapper.client, wrapper.source.rawnick, wrapper.target.name, message, force_role=role)
|
||||||
|
|
||||||
|
def setup_role_commands(evt):
|
||||||
|
aliases = defaultdict(set)
|
||||||
|
for alias, role in var.ROLE_ALIASES.items():
|
||||||
|
aliases[role].add(alias)
|
||||||
|
for role in var.ROLE_GUIDE.keys() - var.ROLE_COMMAND_EXCEPTIONS:
|
||||||
|
keys = ["".join(c for c in role if c.isalpha())]
|
||||||
|
keys.extend(aliases[role])
|
||||||
|
fn = functools.partial(dispatch_role_prefix, role=role)
|
||||||
|
fn.__doc__ = "Execute {0} command".format(role)
|
||||||
|
# don't allow these in-channel, as it could be used to prove that someone is a particular role
|
||||||
|
# (there are no examples of this right now, but it could be possible in the future). For example,
|
||||||
|
# if !shoot was rewritten so that there was a "gunner" and "sharpshooter" template, one could
|
||||||
|
# prove they are sharpshooter -- and therefore prove should they miss that the target is werekitten,
|
||||||
|
# as opposed to the possiblity of them being a wolf with 1 bullet who stole the gun from a dead gunner --
|
||||||
|
# by using !sharpshooter shoot target.
|
||||||
|
command(*keys, exclusive=True, pm=True, chan=False, playing=True)(fn)
|
||||||
|
|
||||||
|
# event_listener decorator wraps callback in handle_error, which we don't want for the init event
|
||||||
|
# (as no IRC connection exists at this point)
|
||||||
|
events.add_listener("init", setup_role_commands, priority=10000)
|
||||||
|
|
||||||
@hook("join")
|
@hook("join")
|
||||||
def on_join(cli, raw_nick, chan, acc="*", rname=""):
|
def on_join(cli, raw_nick, chan, acc="*", rname=""):
|
||||||
nick, _, ident, host = parse_nick(raw_nick)
|
nick, _, ident, host = parse_nick(raw_nick)
|
||||||
@ -4524,6 +4551,9 @@ def pray(cli, nick, chan, rest):
|
|||||||
# complete this as a match with other roles (so "cursed" can match "cursed villager" for instance)
|
# complete this as a match with other roles (so "cursed" can match "cursed villager" for instance)
|
||||||
role = complete_one_match(what.lower(), var.ROLE_GUIDE.keys())
|
role = complete_one_match(what.lower(), var.ROLE_GUIDE.keys())
|
||||||
if role is None:
|
if role is None:
|
||||||
|
if what.lower() in var.ROLE_ALIASES:
|
||||||
|
role = var.ROLE_ALIASES[what.lower()]
|
||||||
|
else:
|
||||||
# typo, let them fix it
|
# typo, let them fix it
|
||||||
pm(cli, nick, messages["specific_invalid_role"].format(what))
|
pm(cli, nick, messages["specific_invalid_role"].format(what))
|
||||||
return
|
return
|
||||||
@ -4920,11 +4950,24 @@ def clone(cli, nick, chan, rest):
|
|||||||
if nick in var.CLONED.keys():
|
if nick in var.CLONED.keys():
|
||||||
pm(cli, nick, messages["already_cloned"])
|
pm(cli, nick, messages["already_cloned"])
|
||||||
return
|
return
|
||||||
|
|
||||||
|
params = re.split(" +", rest)
|
||||||
|
# allow for role-prefixed command such as !clone clone target
|
||||||
|
# if we get !clone clone (with no 3rd arg), we give preference to prefixed version;
|
||||||
|
# meaning if the person wants to clone someone named clone, they must type !clone clone clone
|
||||||
|
# (or just !clone clon, !clone clo, etc. assuming thos would be unambiguous matches)
|
||||||
|
if params[0] == "clone":
|
||||||
|
if len(params) > 1:
|
||||||
|
del params[0]
|
||||||
|
else:
|
||||||
|
pm(cli, nick, messages["clone_clone_clone"])
|
||||||
|
return
|
||||||
|
|
||||||
# no var.SILENCED check for night 1 only roles; silence should only apply for the night after
|
# no var.SILENCED check for night 1 only roles; silence should only apply for the night after
|
||||||
# but just in case, it also sucks if the one night you're allowed to act is when you are
|
# but just in case, it also sucks if the one night you're allowed to act is when you are
|
||||||
# silenced, so we ignore it here anyway.
|
# silenced, so we ignore it here anyway.
|
||||||
|
|
||||||
victim = get_victim(cli, nick, re.split(" +",rest)[0], False)
|
victim = get_victim(cli, nick, params[0], False)
|
||||||
if not victim:
|
if not victim:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -4938,6 +4981,8 @@ def clone(cli, nick, chan, rest):
|
|||||||
debuglog("{0} ({1}) CLONE: {2} ({3})".format(nick, get_role(nick), victim, get_role(victim)))
|
debuglog("{0} ({1}) CLONE: {2} ({3})".format(nick, get_role(nick), victim, get_role(victim)))
|
||||||
chk_nightdone(cli)
|
chk_nightdone(cli)
|
||||||
|
|
||||||
|
var.ROLE_COMMAND_EXCEPTIONS.add("clone")
|
||||||
|
|
||||||
@cmd("charm", chan=False, pm=True, playing=True, silenced=True, phases=("night",), roles=("piper",))
|
@cmd("charm", chan=False, pm=True, playing=True, silenced=True, phases=("night",), roles=("piper",))
|
||||||
def charm(cli, nick, chan, rest):
|
def charm(cli, nick, chan, rest):
|
||||||
"""Charm a player, slowly leading to your win!"""
|
"""Charm a player, slowly leading to your win!"""
|
||||||
@ -6738,6 +6783,8 @@ def player_stats(cli, nick, chan, rest):
|
|||||||
role = " ".join(params[1:])
|
role = " ".join(params[1:])
|
||||||
if role not in var.ROLE_GUIDE.keys():
|
if role not in var.ROLE_GUIDE.keys():
|
||||||
matches = complete_match(role, var.ROLE_GUIDE.keys() | {"lover"})
|
matches = complete_match(role, var.ROLE_GUIDE.keys() | {"lover"})
|
||||||
|
if not matches and role.lower() in var.ROLE_ALIASES:
|
||||||
|
matches = (var.ROLE_ALIASES[role.lower()],)
|
||||||
if not matches:
|
if not matches:
|
||||||
reply(cli, nick, chan, messages["no_such_role"].format(role))
|
reply(cli, nick, chan, messages["no_such_role"].format(role))
|
||||||
return
|
return
|
||||||
|
@ -48,8 +48,11 @@ from oyoyo.client import IRCClient
|
|||||||
|
|
||||||
import src
|
import src
|
||||||
from src import handler
|
from src import handler
|
||||||
|
from src.events import Event
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
evt = Event("init", {})
|
||||||
|
evt.dispatch()
|
||||||
src.plog("Connecting to {0}:{1}{2}".format(botconfig.HOST, "+" if botconfig.USE_SSL else "", botconfig.PORT))
|
src.plog("Connecting to {0}:{1}{2}".format(botconfig.HOST, "+" if botconfig.USE_SSL else "", botconfig.PORT))
|
||||||
cli = IRCClient(
|
cli = IRCClient(
|
||||||
{"privmsg": lambda *s: None,
|
{"privmsg": lambda *s: None,
|
||||||
|
Loading…
Reference in New Issue
Block a user