diff --git a/messages/en.json b/messages/en.json index 032decf..9686e32 100644 --- a/messages/en.json +++ b/messages/en.json @@ -417,6 +417,7 @@ "curse_success_wolfchat": "\u0002{0}\u0002 has cast a curse on \u0002{1}\u0002.", "already_cloned": "You have already chosen to clone someone.", "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.", "no_charm_self": "You may not charm yourself.", "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)", "channel_rules": "{0} channel rules: {1}", "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:" } diff --git a/src/decorators.py b/src/decorators.py index aa569b4..8fc0153 100644 --- a/src/decorators.py +++ b/src/decorators.py @@ -203,7 +203,8 @@ class handle_error: class command: 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.flag = flag @@ -219,13 +220,19 @@ class command: self.aftergame = False self.name = commands[0] self.alt_allowed = bool(flag or owner_only) + self.exclusive = exclusive alias = False self.aliases = [] 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]: if func.owner_only != owner_only or func.flag != flag: 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) if name in botconfig.ALLOWED_ALT_CHANNELS_COMMANDS: @@ -339,6 +346,7 @@ class cmd: self.func = None self.aftergame = False self.name = cmds[0] + self.exclusive = False # for compatibility with new command API alias = False self.aliases = [] @@ -347,6 +355,8 @@ class cmd: if (func.owner_only != owner_only or func.flag != flag): 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) if alias: diff --git a/src/handler.py b/src/handler.py index 5b0aa8b..07298d2 100644 --- a/src/handler.py +++ b/src/handler.py @@ -12,39 +12,115 @@ import botconfig import src.settings as var from src import decorators, wolfgame, events, channels, hooks, users, errlog as log, stream_handler as alog 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 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 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 - if (notice and ((not users.equals(chan, users.Bot.nick) and not botconfig.ALLOW_NOTICE_COMMANDS) or - (users.equals(chan, users.Bot.nick) and not botconfig.ALLOW_PRIVATE_NOTICE_COMMANDS))): + wrapper = MessageDispatcher(user, target) + + 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 - for fn in decorators.COMMANDS[""]: - fn.caller(cli, rawnick, chan, msg) + 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[""]: + fn.caller(cli, rawnick, chan, msg) + parts = msg.split(sep=" ", maxsplit=1) + key = parts[0].lower() + if len(parts) > 1: + message = parts[1].lstrip() + 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 - for x in list(decorators.COMMANDS.keys()): - if not users.equals(chan, users.Bot.nick) and not msg.lower().startswith(botconfig.CMD_CHAR): - break # channel message but no prefix; ignore - 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: - continue - if not h or h[0] == " ": - for fn in decorators.COMMANDS.get(x, []): - if phase == var.PHASE: - fn.caller(cli, rawnick, chan, h.lstrip()) + 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 + if roles.intersection(fn.roles): + 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: + # FIXME: pass in var, wrapper, message instead of cli, rawnick, chan, message + fn.caller(cli, rawnick, chan, message) def unhandled(cli, prefix, cmd, *args): for fn in decorators.HOOKS.get(cmd, []): diff --git a/src/settings.py b/src/settings.py index 643b528..dafef99 100644 --- a/src/settings.py +++ b/src/settings.py @@ -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 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") DEFAULT_ROLE = "villager" 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 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 FORTUNE_CHANCE = 1/25 diff --git a/src/wolfgame.py b/src/wolfgame.py index a922cee..d7f0967 100644 --- a/src/wolfgame.py +++ b/src/wolfgame.py @@ -22,6 +22,7 @@ import copy import fnmatch import itertools +import functools import math import os import platform @@ -2875,6 +2876,32 @@ def update_last_said(cli, nick, chan, 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") def on_join(cli, raw_nick, chan, acc="*", rname=""): nick, _, ident, host = parse_nick(raw_nick) @@ -4524,9 +4551,12 @@ def pray(cli, nick, chan, rest): # 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()) if role is None: - # typo, let them fix it - pm(cli, nick, messages["specific_invalid_role"].format(what)) - return + if what.lower() in var.ROLE_ALIASES: + role = var.ROLE_ALIASES[what.lower()] + else: + # typo, let them fix it + pm(cli, nick, messages["specific_invalid_role"].format(what)) + return # get a list of all roles actually in the game, including roles that amnesiacs will be turning into # (amnesiacs are special since they're also listed as amnesiac; that way a prophet can see both who the @@ -4920,11 +4950,24 @@ def clone(cli, nick, chan, rest): if nick in var.CLONED.keys(): pm(cli, nick, messages["already_cloned"]) 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 # 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. - victim = get_victim(cli, nick, re.split(" +",rest)[0], False) + victim = get_victim(cli, nick, params[0], False) if not victim: 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))) chk_nightdone(cli) +var.ROLE_COMMAND_EXCEPTIONS.add("clone") + @cmd("charm", chan=False, pm=True, playing=True, silenced=True, phases=("night",), roles=("piper",)) def charm(cli, nick, chan, rest): """Charm a player, slowly leading to your win!""" @@ -6738,6 +6783,8 @@ def player_stats(cli, nick, chan, rest): role = " ".join(params[1:]) if role not in var.ROLE_GUIDE.keys(): 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: reply(cli, nick, chan, messages["no_such_role"].format(role)) return diff --git a/wolfbot.py b/wolfbot.py index 12e2113..8275ed0 100755 --- a/wolfbot.py +++ b/wolfbot.py @@ -48,8 +48,11 @@ from oyoyo.client import IRCClient import src from src import handler +from src.events import Event def main(): + evt = Event("init", {}) + evt.dispatch() src.plog("Connecting to {0}:{1}{2}".format(botconfig.HOST, "+" if botconfig.USE_SSL else "", botconfig.PORT)) cli = IRCClient( {"privmsg": lambda *s: None,