Record the reported rows from NAWS negotiation
[mudpy.git] / mudpy / misc.py
index 0e7e561..074f538 100644 (file)
@@ -1,6 +1,6 @@
 """Miscellaneous functions for the mudpy engine."""
 
-# Copyright (c) 2004-2019 mudpy authors. Permission to use, copy,
+# Copyright (c) 2004-2020 mudpy authors. Permission to use, copy,
 # modify, and distribute this software is granted under terms
 # provided in the LICENSE file distributed with this software.
 
@@ -406,7 +406,7 @@ class Universe:
         """Create a new, empty Universe (the Big Bang)."""
         new_universe = Universe()
         for attribute in vars(self).keys():
-            exec("new_universe." + attribute + " = self." + attribute)
+            setattr(new_universe, attribute, getattr(self, attribute))
         new_universe.reload_flag = False
         del self
         return new_universe
@@ -457,10 +457,8 @@ class Universe:
         self.listening_socket.listen(1)
 
         # note that we're now ready for user connections
-        log(
-            "Listening for Telnet connections on: " +
-            host + ":" + str(port)
-        )
+        log("Listening for Telnet connections on %s port %s" % (
+                host, str(port)))
 
     def get_time(self):
         """Convenience method to get the elapsed time counter."""
@@ -490,6 +488,7 @@ class User:
         self.address = ""
         self.authenticated = False
         self.avatar = None
+        self.choice = ""
         self.columns = 79
         self.connection = None
         self.error = ""
@@ -502,8 +501,10 @@ class User:
         self.output_queue = []
         self.partial_input = b""
         self.password_tries = 0
+        self.rows = 23
         self.state = "telopt_negotiation"
         self.telopts = {}
+        self.ttype = None
         self.universe = universe
 
     def quit(self):
@@ -639,14 +640,13 @@ class User:
         """Flag the user as authenticated and disconnect duplicates."""
         if self.state != "authenticated":
             self.authenticated = True
+            log("User %s authenticated for account %s." % (
+                    self, self.account.subkey), 2)
             if ("mudpy.limit" in universe.contents and self.account.subkey in
                     universe.contents["mudpy.limit"].get("admins")):
                 self.account.set("administrator", True)
-                log("Administrator %s authenticated." %
-                    self.account.get("name"), 2)
-            else:
-                log("User %s authenticated for account %s." % (
-                        self, self.account.subkey), 2)
+                log("Account %s is an administrator." % (
+                        self.account.subkey), 2)
 
     def show_menu(self):
         """Send the user their current menu."""
@@ -661,6 +661,30 @@ class User:
             self.error = False
             self.adjust_echoing()
 
+    def prompt(self):
+        """"Generate and return an input prompt."""
+
+        # Start with the user's preference, if one was provided
+        prompt = self.account.get("prompt")
+
+        # If the user has not set a prompt, then immediately return the default
+        # provided for the current state
+        if not prompt:
+            return get_menu_prompt(self.state)
+
+        # Allow including the World clock state
+        if "$_(time)" in prompt:
+            prompt = prompt.replace(
+                "$_(time)",
+                str(universe.groups["internal"]["counters"].get("elapsed")))
+
+        # Append a single space for clear separation from user input
+        if prompt[-1] != " ":
+            prompt = "%s " % prompt
+
+        # Return the cooked prompt
+        return prompt
+
     def adjust_echoing(self):
         """Adjust echoing to match state menu requirements."""
         if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_ECHO,
@@ -723,7 +747,7 @@ class User:
                 if not just_prompt:
                     output += "$(eol)"
                 if add_prompt:
-                    output += self.account.get("prompt", ">") + " "
+                    output += self.prompt()
                     mode = self.avatar.get("mode")
                     if mode:
                         output += "(" + mode + ") "
@@ -783,6 +807,13 @@ class User:
         else:
             self.check_idle()
 
+        # ask the client for their current terminal type (RFC 1091); it's None
+        # if it's not been initialized, the empty string if it has but the
+        # output was indeterminate, "UNKNOWN" if the client specified it has no
+        # terminal types to supply
+        if self.ttype is None:
+            mudpy.telnet.request_ttype(self)
+
         # if output is paused, decrement the counter
         if self.state == "telopt_negotiation":
             if self.negotiation_pause:
@@ -828,7 +859,7 @@ class User:
         # check for some input
         try:
             raw_input = self.connection.recv(1024)
-        except (BlockingIOError, OSError):
+        except OSError:
             raw_input = b""
 
         # we got something
@@ -1196,7 +1227,9 @@ def weighted_choice(data):
             expanded.append(key)
 
     # return one at random
-    return random.choice(expanded)
+    # Whitelist the random.randrange() call in bandit since it's not used for
+    # security/cryptographic purposes
+    return random.choice(expanded)  # nosec
 
 
 def random_name():
@@ -1243,7 +1276,9 @@ def random_name():
     name = ""
 
     # create a name of random length from the syllables
-    for _syllable in range(random.randrange(2, 6)):
+    # Whitelist the random.randrange() call in bandit since it's not used for
+    # security/cryptographic purposes
+    for _syllable in range(random.randrange(2, 6)):  # nosec
         name += weighted_choice(syllables)
 
     # strip any leading quotemark, capitalize and return the name
@@ -1410,9 +1445,12 @@ def reload_data():
     """Reload all relevant objects."""
     universe.save()
     old_userlist = universe.userlist[:]
+    old_loglines = universe.loglines[:]
     for element in list(universe.contents.values()):
         element.destroy()
     universe.load()
+    new_loglines = universe.loglines[:]
+    universe.loglines = old_loglines + new_loglines
     for user in old_userlist:
         user.reload()
 
@@ -1450,6 +1488,27 @@ def check_for_connection(listening_socket):
     return user
 
 
+def find_command(command_name):
+    """Try to find a command by name or abbreviation."""
+
+    # lowercase the command
+    command_name = command_name.lower()
+
+    command = None
+    if command_name in universe.groups["command"]:
+        # the command matches a command word for which we have data
+        command = universe.groups["command"][command_name]
+    else:
+        for candidate in sorted(universe.groups["command"]):
+            if candidate.startswith(command_name) and not universe.groups[
+                    "command"][candidate].get("administrative"):
+                # the command matches the start of a command word and is not
+                # restricted to administrators
+                command = universe.groups["command"][candidate]
+                break
+    return command
+
+
 def get_menu(state, error=None, choices=None):
     """Show the correct menu text to a user."""
 
@@ -1539,19 +1598,18 @@ def get_menu_prompt(state):
 
 def get_menu_choices(user):
     """Return a dict of choice:meaning."""
-    menu = universe.groups["menu"][user.state]
-    create_choices = menu.get("create")
+    state = universe.groups["menu"][user.state]
+    create_choices = state.get("create")
     if create_choices:
-        choices = eval(create_choices)
+        choices = call_hook_function(create_choices, (user,))
     else:
         choices = {}
     ignores = []
     options = {}
     creates = {}
-    for facet in menu.facets():
-        if facet.startswith("demand_") and not eval(
-           universe.groups["menu"][user.state].get(facet)
-           ):
+    for facet in state.facets():
+        if facet.startswith("demand_") and not call_hook_function(
+                universe.groups["menu"][user.state].get(facet), (user,)):
             ignores.append(facet.split("_", 2)[1])
         elif facet.startswith("create_"):
             creates[facet] = facet.split("_", 2)[1]
@@ -1559,10 +1617,11 @@ def get_menu_choices(user):
             options[facet] = facet.split("_", 2)[1]
     for facet in creates.keys():
         if not creates[facet] in ignores:
-            choices[creates[facet]] = eval(menu.get(facet))
+            choices[creates[facet]] = call_hook_function(
+                state.get(facet), (user,))
     for facet in options.keys():
         if not options[facet] in ignores:
-            choices[options[facet]] = menu.get(facet)
+            choices[options[facet]] = state.get(facet)
     return choices
 
 
@@ -1596,12 +1655,12 @@ def get_default_branch(state):
     return universe.groups["menu"][state].get("branch")
 
 
-def get_choice_branch(user, choice):
+def get_choice_branch(user):
     """Returns the new state matching the given choice."""
     branches = get_menu_branches(user.state)
-    if choice in branches.keys():
-        return branches[choice]
-    elif choice in user.menu_choices.keys():
+    if user.choice in branches.keys():
+        return branches[user.choice]
+    elif user.choice in user.menu_choices.keys():
         return get_default_branch(user.state)
     else:
         return ""
@@ -1623,17 +1682,39 @@ def get_default_action(state):
     return universe.groups["menu"][state].get("action")
 
 
-def get_choice_action(user, choice):
+def get_choice_action(user):
     """Run any indicated script for the given choice."""
     actions = get_menu_actions(user.state)
-    if choice in actions.keys():
-        return actions[choice]
-    elif choice in user.menu_choices.keys():
+    if user.choice in actions.keys():
+        return actions[user.choice]
+    elif user.choice in user.menu_choices.keys():
         return get_default_action(user.state)
     else:
         return ""
 
 
+def call_hook_function(fname, arglist):
+    """Safely execute named function with supplied arguments, return result."""
+
+    # all functions relative to mudpy package
+    function = mudpy
+
+    for component in fname.split("."):
+        try:
+            function = getattr(function, component)
+        except AttributeError:
+            log('Could not find mudpy.%s() for arguments "%s"'
+                % (fname, arglist), 7)
+            function = None
+            break
+    if function:
+        try:
+            return function(*arglist)
+        except Exception:
+            log('Calling mudpy.%s(%s) raised an exception...\n%s'
+                % (fname, (*arglist,), traceback.format_exc()), 7)
+
+
 def handle_user_input(user):
     """The main handler, branches to a state-specific handler."""
 
@@ -1643,9 +1724,9 @@ def handle_user_input(user):
         user.send("", add_prompt=False, prepend_padding=False)
 
     # check to make sure the state is expected, then call that handler
-    if "handler_" + user.state in globals():
-        exec("handler_" + user.state + "(user)")
-    else:
+    try:
+        globals()["handler_" + user.state](user)
+    except KeyError:
         generic_menu_handler(user)
 
     # since we got input, flag that the menu/prompt needs to be redisplayed
@@ -1660,16 +1741,18 @@ def generic_menu_handler(user):
 
     # get a lower-case representation of the next line of input
     if user.input_queue:
-        choice = user.input_queue.pop(0)
-        if choice:
-            choice = choice.lower()
+        user.choice = user.input_queue.pop(0)
+        if user.choice:
+            user.choice = user.choice.lower()
     else:
-        choice = ""
-    if not choice:
-        choice = get_default_menu_choice(user.state)
-    if choice in user.menu_choices:
-        exec(get_choice_action(user, choice))
-        new_state = get_choice_branch(user, choice)
+        user.choice = ""
+    if not user.choice:
+        user.choice = get_default_menu_choice(user.state)
+    if user.choice in user.menu_choices:
+        action = get_choice_action(user)
+        if action:
+            call_hook_function(action, (user,))
+        new_state = get_choice_branch(user)
         if new_state:
             user.state = new_state
     else:
@@ -1840,21 +1923,18 @@ def handler_active(user):
         else:
             command_name, parameters = first_word(input_data)
 
-        # lowercase the command
-        command_name = command_name.lower()
-
-        # the command matches a command word for which we have data
-        if command_name in universe.groups["command"]:
-            command = universe.groups["command"][command_name]
-        else:
-            command = None
+        # expand to an actual command
+        command = find_command(command_name)
 
         # if it's allowed, do it
+        result = None
         if actor.can_run(command):
-            exec(command.get("action"))
+            action_fname = command.get("action", command.key)
+            if action_fname:
+                result = call_hook_function(action_fname, (actor, parameters))
 
-        # otherwise, give an error
-        elif command_name:
+        # if the command was not run, give an error
+        if not result:
             mudpy.command.error(actor, input_data)
 
     # if no input, just idle back with a prompt
@@ -2020,8 +2100,9 @@ def setup():
     log("Import path: %s" % ", ".join(sys.path), 1)
     log("Installed dependencies: %s" % universe.versions.dependencies_text, 1)
     log("Other python packages: %s" % universe.versions.environment_text, 1)
-    log("Started %s with command line: %s" % (
-        universe.versions.version, " ".join(sys.argv)), 1)
+    log("Running version: %s" % universe.versions.version, 1)
+    log("Initial directory: %s" % universe.startdir, 1)
+    log("Command line: %s" % " ".join(sys.argv), 1)
 
     # pass the initialized universe back
     return universe