Whitelist uses of stdlib random module for bandit
[mudpy.git] / mudpy / misc.py
1 """Miscellaneous functions for the mudpy engine."""
2
3 # Copyright (c) 2004-2019 mudpy authors. Permission to use, copy,
4 # modify, and distribute this software is granted under terms
5 # provided in the LICENSE file distributed with this software.
6
7 import codecs
8 import datetime
9 import os
10 import random
11 import re
12 import signal
13 import socket
14 import sys
15 import syslog
16 import time
17 import traceback
18 import unicodedata
19
20 import mudpy
21
22
23 class Element:
24
25     """An element of the universe."""
26
27     def __init__(self, key, universe, origin=None):
28         """Set up a new element."""
29
30         # keep track of our key name
31         self.key = key
32
33         # keep track of what universe it's loading into
34         self.universe = universe
35
36         # set of facet keys from the universe
37         self.facethash = dict()
38
39         # not owned by a user by default (used for avatars)
40         self.owner = None
41
42         # no contents in here by default
43         self.contents = {}
44
45         if self.key.find(".") > 0:
46             self.group, self.subkey = self.key.split(".")[-2:]
47         else:
48             self.group = "other"
49             self.subkey = self.key
50         if self.group not in self.universe.groups:
51             self.universe.groups[self.group] = {}
52
53         # get an appropriate origin
54         if not origin:
55             self.universe.add_group(self.group)
56             origin = self.universe.files[
57                     self.universe.origins[self.group]["fallback"]]
58
59         # record or reset a pointer to the origin file
60         self.origin = self.universe.files[origin.source]
61
62         # add or replace this element in the universe
63         self.universe.contents[self.key] = self
64         self.universe.groups[self.group][self.subkey] = self
65
66     def reload(self):
67         """Create a new element and replace this one."""
68         args = (self.key, self.universe, self.origin)
69         self.destroy()
70         Element(*args)
71
72     def destroy(self):
73         """Remove an element from the universe and destroy it."""
74         for facet in dict(self.facethash):
75             self.remove_facet(facet)
76         del self.universe.groups[self.group][self.subkey]
77         del self.universe.contents[self.key]
78         del self
79
80     def facets(self):
81         """Return a list of non-inherited facets for this element."""
82         return self.facethash
83
84     def has_facet(self, facet):
85         """Return whether the non-inherited facet exists."""
86         return facet in self.facets()
87
88     def remove_facet(self, facet):
89         """Remove a facet from the element."""
90         if ".".join((self.key, facet)) in self.origin.data:
91             del self.origin.data[".".join((self.key, facet))]
92         if facet in self.facethash:
93             del self.facethash[facet]
94         self.origin.modified = True
95
96     def ancestry(self):
97         """Return a list of the element's inheritance lineage."""
98         if self.has_facet("inherit"):
99             ancestry = self.get("inherit")
100             if not ancestry:
101                 ancestry = []
102             for parent in ancestry[:]:
103                 ancestors = self.universe.contents[parent].ancestry()
104                 for ancestor in ancestors:
105                     if ancestor not in ancestry:
106                         ancestry.append(ancestor)
107             return ancestry
108         else:
109             return []
110
111     def get(self, facet, default=None):
112         """Retrieve values."""
113         if default is None:
114             default = ""
115         try:
116             return self.origin.data[".".join((self.key, facet))]
117         except (KeyError, TypeError):
118             pass
119         if self.has_facet("inherit"):
120             for ancestor in self.ancestry():
121                 if self.universe.contents[ancestor].has_facet(facet):
122                     return self.universe.contents[ancestor].get(facet)
123         else:
124             return default
125
126     def set(self, facet, value):
127         """Set values."""
128         if not self.origin.is_writeable() and not self.universe.loading:
129             # break if there is an attempt to update an element from a
130             # read-only file, unless the universe is in the midst of loading
131             # updated data from files
132             raise PermissionError("Altering elements in read-only files is "
133                                   "disallowed")
134         # Coerce some values to appropriate data types
135         # TODO(fungi) Move these to a separate validation mechanism
136         if facet in ["loglevel"]:
137             value = int(value)
138         elif facet in ["administrator"]:
139             value = bool(value)
140
141         # The canonical node for this facet within its origin
142         node = ".".join((self.key, facet))
143
144         if node not in self.origin.data or self.origin.data[node] != value:
145             # Be careful to only update the origin's contents when required,
146             # since that affects whether the backing file gets written
147             self.origin.data[node] = value
148             self.origin.modified = True
149
150         # Make sure this facet is included in the element's facets
151         self.facethash[facet] = self.origin.data[node]
152
153     def append(self, facet, value):
154         """Append value to a list."""
155         newlist = self.get(facet)
156         if not newlist:
157             newlist = []
158         if type(newlist) is not list:
159             newlist = list(newlist)
160         newlist.append(value)
161         self.set(facet, newlist)
162
163     def send(
164         self,
165         message,
166         eol="$(eol)",
167         raw=False,
168         flush=False,
169         add_prompt=True,
170         just_prompt=False,
171         add_terminator=False,
172         prepend_padding=True
173     ):
174         """Convenience method to pass messages to an owner."""
175         if self.owner:
176             self.owner.send(
177                 message,
178                 eol,
179                 raw,
180                 flush,
181                 add_prompt,
182                 just_prompt,
183                 add_terminator,
184                 prepend_padding
185             )
186
187     def can_run(self, command):
188         """Check if the user can run this command object."""
189
190         # has to be in the commands group
191         if command not in self.universe.groups["command"].values():
192             result = False
193
194         # avatars of administrators can run any command
195         elif self.owner and self.owner.account.get("administrator"):
196             result = True
197
198         # everyone can run non-administrative commands
199         elif not command.get("administrative"):
200             result = True
201
202         # otherwise the command cannot be run by this actor
203         else:
204             result = False
205
206         # pass back the result
207         return result
208
209     def update_location(self):
210         """Make sure the location's contents contain this element."""
211         area = self.get("location")
212         if area in self.universe.contents:
213             self.universe.contents[area].contents[self.key] = self
214
215     def clean_contents(self):
216         """Make sure the element's contents aren't bogus."""
217         for element in self.contents.values():
218             if element.get("location") != self.key:
219                 del self.contents[element.key]
220
221     def go_to(self, area):
222         """Relocate the element to a specific area."""
223         current = self.get("location")
224         if current and self.key in self.universe.contents[current].contents:
225             del universe.contents[current].contents[self.key]
226         if area in self.universe.contents:
227             self.set("location", area)
228         self.universe.contents[area].contents[self.key] = self
229         self.look_at(area)
230
231     def go_home(self):
232         """Relocate the element to its default location."""
233         self.go_to(self.get("default_location"))
234         self.echo_to_location(
235             "You suddenly realize that " + self.get("name") + " is here."
236         )
237
238     def move_direction(self, direction):
239         """Relocate the element in a specified direction."""
240         motion = self.universe.contents["mudpy.movement.%s" % direction]
241         enter_term = motion.get("enter_term")
242         exit_term = motion.get("exit_term")
243         self.echo_to_location("%s exits %s." % (self.get("name"), exit_term))
244         self.send("You exit %s." % exit_term, add_prompt=False)
245         self.go_to(
246             self.universe.contents[
247                 self.get("location")].link_neighbor(direction)
248         )
249         self.echo_to_location("%s arrives from %s." % (
250             self.get("name"), enter_term))
251
252     def look_at(self, key):
253         """Show an element to another element."""
254         if self.owner:
255             element = self.universe.contents[key]
256             message = ""
257             name = element.get("name")
258             if name:
259                 message += "$(cyn)" + name + "$(nrm)$(eol)"
260             description = element.get("description")
261             if description:
262                 message += description + "$(eol)"
263             portal_list = list(element.portals().keys())
264             if portal_list:
265                 portal_list.sort()
266                 message += "$(cyn)[ Exits: " + ", ".join(
267                     portal_list
268                 ) + " ]$(nrm)$(eol)"
269             for element in self.universe.contents[
270                 self.get("location")
271             ].contents.values():
272                 if element.get("is_actor") and element is not self:
273                     message += "$(yel)" + element.get(
274                         "name"
275                     ) + " is here.$(nrm)$(eol)"
276                 elif element is not self:
277                     message += "$(grn)" + element.get(
278                         "impression"
279                     ) + "$(nrm)$(eol)"
280             self.send(message)
281
282     def portals(self):
283         """Map the portal directions for an area to neighbors."""
284         portals = {}
285         if re.match(r"""^area\.-?\d+,-?\d+,-?\d+$""", self.key):
286             coordinates = [(int(x))
287                            for x in self.key.split(".")[-1].split(",")]
288             offsets = dict(
289                 (x,
290                  self.universe.contents["mudpy.movement.%s" % x].get("vector")
291                  ) for x in self.universe.directions)
292             for portal in self.get("gridlinks"):
293                 adjacent = map(lambda c, o: c + o,
294                                coordinates, offsets[portal])
295                 neighbor = "area." + ",".join(
296                     [(str(x)) for x in adjacent]
297                 )
298                 if neighbor in self.universe.contents:
299                     portals[portal] = neighbor
300         for facet in self.facets():
301             if facet.startswith("link_"):
302                 neighbor = self.get(facet)
303                 if neighbor in self.universe.contents:
304                     portal = facet.split("_")[1]
305                     portals[portal] = neighbor
306         return portals
307
308     def link_neighbor(self, direction):
309         """Return the element linked in a given direction."""
310         portals = self.portals()
311         if direction in portals:
312             return portals[direction]
313
314     def echo_to_location(self, message):
315         """Show a message to other elements in the current location."""
316         for element in self.universe.contents[
317             self.get("location")
318         ].contents.values():
319             if element is not self:
320                 element.send(message)
321
322
323 class Universe:
324
325     """The universe."""
326
327     def __init__(self, filename="", load=False):
328         """Initialize the universe."""
329         self.groups = {}
330         self.contents = {}
331         self.directions = set()
332         self.loading = False
333         self.loglines = []
334         self.origins = {}
335         self.reload_flag = False
336         self.setup_loglines = []
337         self.startdir = os.getcwd()
338         self.terminate_flag = False
339         self.userlist = []
340         self.versions = None
341         if not filename:
342             possible_filenames = [
343                 "etc/mudpy.yaml",
344                 "/usr/local/mudpy/etc/mudpy.yaml",
345                 "/usr/local/etc/mudpy.yaml",
346                 "/etc/mudpy/mudpy.yaml",
347                 "/etc/mudpy.yaml"
348             ]
349             for filename in possible_filenames:
350                 if os.access(filename, os.R_OK):
351                     break
352         if not os.path.isabs(filename):
353             filename = os.path.join(self.startdir, filename)
354         self.filename = filename
355         if load:
356             # make sure to preserve any accumulated log entries during load
357             self.setup_loglines += self.load()
358
359     def load(self):
360         """Load universe data from persistent storage."""
361
362         # while loading, it's safe to update elements from read-only files
363         self.loading = True
364
365         # it's possible for this to enter before logging configuration is read
366         pending_loglines = []
367
368         # start populating the (re)files dict from the base config
369         self.files = {}
370         mudpy.data.Data(self.filename, self)
371
372         # load default storage locations for groups
373         if hasattr(self, "contents") and "mudpy.filing" in self.contents:
374             self.origins.update(self.contents["mudpy.filing"].get(
375                 "groups", {}))
376
377         # add some builtin groups we know we'll need
378         for group in ("account", "actor", "internal"):
379             self.add_group(group)
380
381         # make a list of inactive avatars
382         inactive_avatars = []
383         for account in self.groups.get("account", {}).values():
384             for avatar in account.get("avatars"):
385                 try:
386                     inactive_avatars.append(self.contents[avatar])
387                 except KeyError:
388                     pending_loglines.append((
389                         'Missing avatar "%s", possible data corruption' %
390                         avatar, 6))
391         for user in self.userlist:
392             if user.avatar in inactive_avatars:
393                 inactive_avatars.remove(user.avatar)
394
395         # another pass to straighten out all the element contents
396         for element in self.contents.values():
397             element.update_location()
398             element.clean_contents()
399
400         # done loading, so disallow updating elements from read-only files
401         self.loading = False
402
403         return pending_loglines
404
405     def new(self):
406         """Create a new, empty Universe (the Big Bang)."""
407         new_universe = Universe()
408         for attribute in vars(self).keys():
409             setattr(new_universe, attribute, getattr(self, attribute))
410         new_universe.reload_flag = False
411         del self
412         return new_universe
413
414     def save(self):
415         """Save the universe to persistent storage."""
416         for key in self.files:
417             self.files[key].save()
418
419     def initialize_server_socket(self):
420         """Create and open the listening socket."""
421
422         # need to know the local address and port number for the listener
423         host = self.contents["mudpy.network"].get("host")
424         port = self.contents["mudpy.network"].get("port")
425
426         # if no host was specified, bind to all local addresses (preferring
427         # ipv6)
428         if not host:
429             if socket.has_ipv6:
430                 host = "::"
431             else:
432                 host = "0.0.0.0"
433
434         # figure out if this is ipv4 or v6
435         family = socket.getaddrinfo(host, port)[0][0]
436         if family is socket.AF_INET6 and not socket.has_ipv6:
437             log("No support for IPv6 address %s (use IPv4 instead)." % host)
438
439         # create a new stream-type socket object
440         self.listening_socket = socket.socket(family, socket.SOCK_STREAM)
441
442         # set the socket options to allow existing open ones to be
443         # reused (fixes a bug where the server can't bind for a minute
444         # when restarting on linux systems)
445         self.listening_socket.setsockopt(
446             socket.SOL_SOCKET, socket.SO_REUSEADDR, 1
447         )
448
449         # bind the socket to to our desired server ipa and port
450         self.listening_socket.bind((host, port))
451
452         # disable blocking so we can proceed whether or not we can
453         # send/receive
454         self.listening_socket.setblocking(0)
455
456         # start listening on the socket
457         self.listening_socket.listen(1)
458
459         # note that we're now ready for user connections
460         log("Listening for Telnet connections on %s port %s" % (
461                 host, str(port)))
462
463     def get_time(self):
464         """Convenience method to get the elapsed time counter."""
465         return self.groups["internal"]["counters"].get("elapsed")
466
467     def add_group(self, group, fallback=None):
468         """Set up group tracking/metadata."""
469         if group not in self.origins:
470             self.origins[group] = {}
471         if not fallback:
472             fallback = mudpy.data.find_file(
473                     ".".join((group, "yaml")), universe=self)
474         if "fallback" not in self.origins[group]:
475             self.origins[group]["fallback"] = fallback
476         flags = self.origins[group].get("flags", None)
477         if fallback not in self.files:
478             mudpy.data.Data(fallback, self, flags=flags)
479
480
481 class User:
482
483     """This is a connected user."""
484
485     def __init__(self):
486         """Default values for the in-memory user variables."""
487         self.account = None
488         self.address = ""
489         self.authenticated = False
490         self.avatar = None
491         self.choice = ""
492         self.columns = 79
493         self.connection = None
494         self.error = ""
495         self.input_queue = []
496         self.last_address = ""
497         self.last_input = universe.get_time()
498         self.menu_choices = {}
499         self.menu_seen = False
500         self.negotiation_pause = 0
501         self.output_queue = []
502         self.partial_input = b""
503         self.password_tries = 0
504         self.state = "telopt_negotiation"
505         self.telopts = {}
506         self.universe = universe
507
508     def quit(self):
509         """Log, close the connection and remove."""
510         if self.account:
511             name = self.account.get("name", self)
512         else:
513             name = self
514         log("Logging out %s" % name, 2)
515         self.deactivate_avatar()
516         self.connection.close()
517         self.remove()
518
519     def check_idle(self):
520         """Warn or disconnect idle users as appropriate."""
521         idletime = universe.get_time() - self.last_input
522         linkdead_dict = universe.contents[
523             "mudpy.timing.idle.disconnect"].facets()
524         if self.state in linkdead_dict:
525             linkdead_state = self.state
526         else:
527             linkdead_state = "default"
528         if idletime > linkdead_dict[linkdead_state]:
529             self.send(
530                 "$(eol)$(red)You've done nothing for far too long... goodbye!"
531                 + "$(nrm)$(eol)",
532                 flush=True,
533                 add_prompt=False
534             )
535             logline = "Disconnecting "
536             if self.account and self.account.get("name"):
537                 logline += self.account.get("name")
538             else:
539                 logline += "an unknown user"
540             logline += (" after idling too long in the " + self.state
541                         + " state.")
542             log(logline, 2)
543             self.state = "disconnecting"
544             self.menu_seen = False
545         idle_dict = universe.contents["mudpy.timing.idle.warn"].facets()
546         if self.state in idle_dict:
547             idle_state = self.state
548         else:
549             idle_state = "default"
550         if idletime == idle_dict[idle_state]:
551             self.send(
552                 "$(eol)$(red)If you continue to be unproductive, "
553                 + "you'll be shown the door...$(nrm)$(eol)"
554             )
555
556     def reload(self):
557         """Save, load a new user and relocate the connection."""
558
559         # copy old attributes
560         attributes = self.__dict__
561
562         # get out of the list
563         self.remove()
564
565         # get rid of the old user object
566         del(self)
567
568         # create a new user object
569         new_user = User()
570
571         # set everything equivalent
572         new_user.__dict__ = attributes
573
574         # the avatar needs a new owner
575         if new_user.avatar:
576             new_user.account = universe.contents[new_user.account.key]
577             new_user.avatar = universe.contents[new_user.avatar.key]
578             new_user.avatar.owner = new_user
579
580         # add it to the list
581         universe.userlist.append(new_user)
582
583     def replace_old_connections(self):
584         """Disconnect active users with the same name."""
585
586         # the default return value
587         return_value = False
588
589         # iterate over each user in the list
590         for old_user in universe.userlist:
591
592             # the name is the same but it's not us
593             if hasattr(
594                old_user, "account"
595                ) and old_user.account and old_user.account.get(
596                 "name"
597             ) == self.account.get(
598                 "name"
599             ) and old_user is not self:
600
601                 # make a note of it
602                 log(
603                     "User " + self.account.get(
604                         "name"
605                     ) + " reconnected--closing old connection to "
606                     + old_user.address + ".",
607                     2
608                 )
609                 old_user.send(
610                     "$(eol)$(red)New connection from " + self.address
611                     + ". Terminating old connection...$(nrm)$(eol)",
612                     flush=True,
613                     add_prompt=False
614                 )
615
616                 # close the old connection
617                 old_user.connection.close()
618
619                 # replace the old connection with this one
620                 old_user.send(
621                     "$(eol)$(red)Taking over old connection from "
622                     + old_user.address + ".$(nrm)"
623                 )
624                 old_user.connection = self.connection
625                 old_user.last_address = old_user.address
626                 old_user.address = self.address
627
628                 # take this one out of the list and delete
629                 self.remove()
630                 del(self)
631                 return_value = True
632                 break
633
634         # true if an old connection was replaced, false if not
635         return return_value
636
637     def authenticate(self):
638         """Flag the user as authenticated and disconnect duplicates."""
639         if self.state != "authenticated":
640             self.authenticated = True
641             log("User %s authenticated for account %s." % (
642                     self, self.account.subkey), 2)
643             if ("mudpy.limit" in universe.contents and self.account.subkey in
644                     universe.contents["mudpy.limit"].get("admins")):
645                 self.account.set("administrator", True)
646                 log("Account %s is an administrator." % (
647                         self.account.subkey), 2)
648
649     def show_menu(self):
650         """Send the user their current menu."""
651         if not self.menu_seen:
652             self.menu_choices = get_menu_choices(self)
653             self.send(
654                 get_menu(self.state, self.error, self.menu_choices),
655                 "",
656                 add_terminator=True
657             )
658             self.menu_seen = True
659             self.error = False
660             self.adjust_echoing()
661
662     def prompt(self):
663         """"Generate and return an input prompt."""
664
665         # Start with the user's preference, if one was provided
666         prompt = self.account.get("prompt")
667
668         # If the user has not set a prompt, then immediately return the default
669         # provided for the current state
670         if not prompt:
671             return get_menu_prompt(self.state)
672
673         # Allow including the World clock state
674         if "$_(time)" in prompt:
675             prompt = prompt.replace(
676                 "$_(time)",
677                 str(universe.groups["internal"]["counters"].get("elapsed")))
678
679         # Append a single space for clear separation from user input
680         if prompt[-1] != " ":
681             prompt = "%s " % prompt
682
683         # Return the cooked prompt
684         return prompt
685
686     def adjust_echoing(self):
687         """Adjust echoing to match state menu requirements."""
688         if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_ECHO,
689                                    mudpy.telnet.US):
690             if menu_echo_on(self.state):
691                 mudpy.telnet.disable(self, mudpy.telnet.TELOPT_ECHO,
692                                      mudpy.telnet.US)
693         elif not menu_echo_on(self.state):
694             mudpy.telnet.enable(self, mudpy.telnet.TELOPT_ECHO,
695                                 mudpy.telnet.US)
696
697     def remove(self):
698         """Remove a user from the list of connected users."""
699         log("Disconnecting account %s." % self, 0)
700         universe.userlist.remove(self)
701
702     def send(
703         self,
704         output,
705         eol="$(eol)",
706         raw=False,
707         flush=False,
708         add_prompt=True,
709         just_prompt=False,
710         add_terminator=False,
711         prepend_padding=True
712     ):
713         """Send arbitrary text to a connected user."""
714
715         # unless raw mode is on, clean it up all nice and pretty
716         if not raw:
717
718             # strip extra $(eol) off if present
719             while output.startswith("$(eol)"):
720                 output = output[6:]
721             while output.endswith("$(eol)"):
722                 output = output[:-6]
723             extra_lines = output.find("$(eol)$(eol)$(eol)")
724             while extra_lines > -1:
725                 output = output[:extra_lines] + output[extra_lines + 6:]
726                 extra_lines = output.find("$(eol)$(eol)$(eol)")
727
728             # start with a newline, append the message, then end
729             # with the optional eol string passed to this function
730             # and the ansi escape to return to normal text
731             if not just_prompt and prepend_padding:
732                 if (not self.output_queue or not
733                         self.output_queue[-1].endswith(b"\r\n")):
734                     output = "$(eol)" + output
735                 elif not self.output_queue[-1].endswith(
736                     b"\r\n\x1b[0m\r\n"
737                 ) and not self.output_queue[-1].endswith(
738                     b"\r\n\r\n"
739                 ):
740                     output = "$(eol)" + output
741             output += eol + chr(27) + "[0m"
742
743             # tack on a prompt if active
744             if self.state == "active":
745                 if not just_prompt:
746                     output += "$(eol)"
747                 if add_prompt:
748                     output += self.prompt()
749                     mode = self.avatar.get("mode")
750                     if mode:
751                         output += "(" + mode + ") "
752
753             # find and replace macros in the output
754             output = replace_macros(self, output)
755
756             # wrap the text at the client's width (min 40, 0 disables)
757             if self.columns:
758                 if self.columns < 40:
759                     wrap = 40
760                 else:
761                     wrap = self.columns
762                 output = wrap_ansi_text(output, wrap)
763
764             # if supported by the client, encode it utf-8
765             if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
766                                        mudpy.telnet.US):
767                 encoded_output = output.encode("utf-8")
768
769             # otherwise just send ascii
770             else:
771                 encoded_output = output.encode("ascii", "replace")
772
773             # end with a terminator if requested
774             if add_prompt or add_terminator:
775                 if mudpy.telnet.is_enabled(
776                         self, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US):
777                     encoded_output += mudpy.telnet.telnet_proto(
778                         mudpy.telnet.IAC, mudpy.telnet.EOR)
779                 elif not mudpy.telnet.is_enabled(
780                         self, mudpy.telnet.TELOPT_SGA, mudpy.telnet.US):
781                     encoded_output += mudpy.telnet.telnet_proto(
782                         mudpy.telnet.IAC, mudpy.telnet.GA)
783
784             # and tack it onto the queue
785             self.output_queue.append(encoded_output)
786
787             # if this is urgent, flush all pending output
788             if flush:
789                 self.flush()
790
791         # just dump raw bytes as requested
792         else:
793             self.output_queue.append(output)
794             self.flush()
795
796     def pulse(self):
797         """All the things to do to the user per increment."""
798
799         # if the world is terminating, disconnect
800         if universe.terminate_flag:
801             self.state = "disconnecting"
802             self.menu_seen = False
803
804         # check for an idle connection and act appropriately
805         else:
806             self.check_idle()
807
808         # if output is paused, decrement the counter
809         if self.state == "telopt_negotiation":
810             if self.negotiation_pause:
811                 self.negotiation_pause -= 1
812             else:
813                 self.state = "entering_account_name"
814
815         # show the user a menu as needed
816         elif not self.state == "active":
817             self.show_menu()
818
819         # flush any pending output in the queue
820         self.flush()
821
822         # disconnect users with the appropriate state
823         if self.state == "disconnecting":
824             self.quit()
825
826         # check for input and add it to the queue
827         self.enqueue_input()
828
829         # there is input waiting in the queue
830         if self.input_queue:
831             handle_user_input(self)
832
833     def flush(self):
834         """Try to send the last item in the queue and remove it."""
835         if self.output_queue:
836             try:
837                 self.connection.send(self.output_queue[0])
838             except (BrokenPipeError, ConnectionResetError):
839                 if self.account and self.account.get("name"):
840                     account = self.account.get("name")
841                 else:
842                     account = "an unknown user"
843                 self.state = "disconnecting"
844                 log("Disconnected while sending to %s." % account, 7)
845             del self.output_queue[0]
846
847     def enqueue_input(self):
848         """Process and enqueue any new input."""
849
850         # check for some input
851         try:
852             raw_input = self.connection.recv(1024)
853         except (BlockingIOError, OSError):
854             raw_input = b""
855
856         # we got something
857         if raw_input:
858
859             # tack this on to any previous partial
860             self.partial_input += raw_input
861
862             # reply to and remove any IAC negotiation codes
863             mudpy.telnet.negotiate_telnet_options(self)
864
865             # separate multiple input lines
866             new_input_lines = self.partial_input.split(b"\r\0")
867             if len(new_input_lines) == 1:
868                 new_input_lines = new_input_lines[0].split(b"\r\n")
869
870             # if input doesn't end in a newline, replace the
871             # held partial input with the last line of it
872             if not (
873                     self.partial_input.endswith(b"\r\0") or
874                     self.partial_input.endswith(b"\r\n")):
875                 self.partial_input = new_input_lines.pop()
876
877             # otherwise, chop off the extra null input and reset
878             # the held partial input
879             else:
880                 new_input_lines.pop()
881                 self.partial_input = b""
882
883             # iterate over the remaining lines
884             for line in new_input_lines:
885
886                 # strip off extra whitespace
887                 line = line.strip()
888
889                 # log non-printable characters remaining
890                 if not mudpy.telnet.is_enabled(
891                         self, mudpy.telnet.TELOPT_BINARY, mudpy.telnet.HIM):
892                     asciiline = bytes([x for x in line if 32 <= x <= 126])
893                     if line != asciiline:
894                         logline = "Non-ASCII characters from "
895                         if self.account and self.account.get("name"):
896                             logline += self.account.get("name") + ": "
897                         else:
898                             logline += "unknown user: "
899                         logline += repr(line)
900                         log(logline, 4)
901                         line = asciiline
902
903                 try:
904                     line = line.decode("utf-8")
905                 except UnicodeDecodeError:
906                     logline = "Non-UTF-8 sequence from "
907                     if self.account and self.account.get("name"):
908                         logline += self.account.get("name") + ": "
909                     else:
910                         logline += "unknown user: "
911                     logline += repr(line)
912                     log(logline, 4)
913                     return
914
915                 line = unicodedata.normalize("NFKC", line)
916
917                 # put on the end of the queue
918                 self.input_queue.append(line)
919
920     def new_avatar(self):
921         """Instantiate a new, unconfigured avatar for this user."""
922         counter = 0
923         while ("avatar_%s_%s" % (self.account.get("name"), counter)
924                 in universe.groups.get("actor", {}).keys()):
925             counter += 1
926         self.avatar = Element(
927             "actor.avatar_%s_%s" % (self.account.get("name"), counter),
928             universe)
929         self.avatar.append("inherit", "archetype.avatar")
930         self.account.append("avatars", self.avatar.key)
931         log("Created new avatar %s for user %s." % (
932                 self.avatar.key, self.account.get("name")), 0)
933
934     def delete_avatar(self, avatar):
935         """Remove an avatar from the world and from the user's list."""
936         if self.avatar is universe.contents[avatar]:
937             self.avatar = None
938         log("Deleting avatar %s for user %s." % (
939                 avatar, self.account.get("name")), 0)
940         universe.contents[avatar].destroy()
941         avatars = self.account.get("avatars")
942         avatars.remove(avatar)
943         self.account.set("avatars", avatars)
944
945     def activate_avatar_by_index(self, index):
946         """Enter the world with a particular indexed avatar."""
947         self.avatar = universe.contents[
948             self.account.get("avatars")[index]]
949         self.avatar.owner = self
950         self.state = "active"
951         log("Activated avatar %s (%s)." % (
952                 self.avatar.get("name"), self.avatar.key), 0)
953         self.avatar.go_home()
954
955     def deactivate_avatar(self):
956         """Have the active avatar leave the world."""
957         if self.avatar:
958             log("Deactivating avatar %s (%s) for user %s." % (
959                     self.avatar.get("name"), self.avatar.key,
960                     self.account.get("name")), 0)
961             current = self.avatar.get("location")
962             if current:
963                 self.avatar.set("default_location", current)
964                 self.avatar.echo_to_location(
965                     "You suddenly wonder where " + self.avatar.get(
966                         "name"
967                     ) + " went."
968                 )
969                 del universe.contents[current].contents[self.avatar.key]
970                 self.avatar.remove_facet("location")
971             self.avatar.owner = None
972             self.avatar = None
973
974     def destroy(self):
975         """Destroy the user and associated avatars."""
976         for avatar in self.account.get("avatars"):
977             self.delete_avatar(avatar)
978         log("Destroying account %s for user %s." % (
979                 self.account.get("name"), self), 0)
980         self.account.destroy()
981
982     def list_avatar_names(self):
983         """List names of assigned avatars."""
984         avatars = []
985         for avatar in self.account.get("avatars"):
986             try:
987                 avatars.append(universe.contents[avatar].get("name"))
988             except KeyError:
989                 log('Missing avatar "%s", possible data corruption.' %
990                     avatar, 6)
991         return avatars
992
993
994 def broadcast(message, add_prompt=True):
995     """Send a message to all connected users."""
996     for each_user in universe.userlist:
997         each_user.send("$(eol)" + message, add_prompt=add_prompt)
998
999
1000 def log(message, level=0):
1001     """Log a message."""
1002
1003     # a couple references we need
1004     if "mudpy.log" in universe.contents:
1005         file_name = universe.contents["mudpy.log"].get("file", "")
1006         max_log_lines = universe.contents["mudpy.log"].get("lines", 0)
1007         syslog_name = universe.contents["mudpy.log"].get("syslog", "")
1008     else:
1009         file_name = ""
1010         max_log_lines = 0
1011         syslog_name = ""
1012     timestamp = datetime.datetime.now().isoformat(' ')
1013
1014     # turn the message into a list of nonempty lines
1015     lines = [x for x in [(x.rstrip()) for x in message.split("\n")] if x != ""]
1016
1017     # send the timestamp and line to a file
1018     if file_name:
1019         if not os.path.isabs(file_name):
1020             file_name = os.path.join(universe.startdir, file_name)
1021         os.makedirs(os.path.dirname(file_name), exist_ok=True)
1022         file_descriptor = codecs.open(file_name, "a", "utf-8")
1023         for line in lines:
1024             file_descriptor.write(timestamp + " " + line + "\n")
1025         file_descriptor.flush()
1026         file_descriptor.close()
1027
1028     # send the timestamp and line to standard output
1029     if ("mudpy.log" in universe.contents and
1030             universe.contents["mudpy.log"].get("stdout")):
1031         for line in lines:
1032             print(timestamp + " " + line)
1033
1034     # send the line to the system log
1035     if syslog_name:
1036         syslog.openlog(
1037             syslog_name.encode("utf-8"),
1038             syslog.LOG_PID,
1039             syslog.LOG_INFO | syslog.LOG_DAEMON
1040         )
1041         for line in lines:
1042             syslog.syslog(line)
1043         syslog.closelog()
1044
1045     # display to connected administrators
1046     for user in universe.userlist:
1047         if user.state == "active" and user.account.get(
1048            "administrator"
1049            ) and user.account.get("loglevel", 0) <= level:
1050             # iterate over every line in the message
1051             full_message = ""
1052             for line in lines:
1053                 full_message += (
1054                     "$(bld)$(red)" + timestamp + " "
1055                     + line.replace("$(", "$_(") + "$(nrm)$(eol)")
1056             user.send(full_message, flush=True)
1057
1058     # add to the recent log list
1059     for line in lines:
1060         while 0 < len(universe.loglines) >= max_log_lines:
1061             del universe.loglines[0]
1062         universe.loglines.append((level, timestamp + " " + line))
1063
1064
1065 def get_loglines(level, start, stop):
1066     """Return a specific range of loglines filtered by level."""
1067
1068     # filter the log lines
1069     loglines = [x for x in universe.loglines if x[0] >= level]
1070
1071     # we need these in several places
1072     total_count = str(len(universe.loglines))
1073     filtered_count = len(loglines)
1074
1075     # don't proceed if there are no lines
1076     if filtered_count:
1077
1078         # can't start before the begining or at the end
1079         if start > filtered_count:
1080             start = filtered_count
1081         if start < 1:
1082             start = 1
1083
1084         # can't stop before we start
1085         if stop > start:
1086             stop = start
1087         elif stop < 1:
1088             stop = 1
1089
1090         # some preamble
1091         message = "There are " + str(total_count)
1092         message += " log lines in memory and " + str(filtered_count)
1093         message += " at or above level " + str(level) + "."
1094         message += " The matching lines from " + str(stop) + " to "
1095         message += str(start) + " are:$(eol)$(eol)"
1096
1097         # add the text from the selected lines
1098         if stop > 1:
1099             range_lines = loglines[-start:-(stop - 1)]
1100         else:
1101             range_lines = loglines[-start:]
1102         for line in range_lines:
1103             message += "   (" + str(line[0]) + ") " + line[1].replace(
1104                 "$(", "$_("
1105             ) + "$(eol)"
1106
1107     # there were no lines
1108     else:
1109         message = "None of the " + str(total_count)
1110         message += " lines in memory matches your request."
1111
1112     # pass it back
1113     return message
1114
1115
1116 def glyph_columns(character):
1117     """Convenience function to return the column width of a glyph."""
1118     if unicodedata.east_asian_width(character) in "FW":
1119         return 2
1120     else:
1121         return 1
1122
1123
1124 def wrap_ansi_text(text, width):
1125     """Wrap text with arbitrary width while ignoring ANSI colors."""
1126
1127     # the current position in the entire text string, including all
1128     # characters, printable or otherwise
1129     abs_pos = 0
1130
1131     # the current text position relative to the begining of the line,
1132     # ignoring color escape sequences
1133     rel_pos = 0
1134
1135     # the absolute and relative positions of the most recent whitespace
1136     # character
1137     last_abs_whitespace = 0
1138     last_rel_whitespace = 0
1139
1140     # whether the current character is part of a color escape sequence
1141     escape = False
1142
1143     # normalize any potentially composited unicode before we count it
1144     text = unicodedata.normalize("NFKC", text)
1145
1146     # iterate over each character from the begining of the text
1147     for each_character in text:
1148
1149         # the current character is the escape character
1150         if each_character == "\x1b" and not escape:
1151             escape = True
1152             rel_pos -= 1
1153
1154         # the current character is within an escape sequence
1155         elif escape:
1156             rel_pos -= 1
1157             if each_character == "m":
1158                 # the current character is m, which terminates the
1159                 # escape sequence
1160                 escape = False
1161
1162         # the current character is a space
1163         elif each_character == " ":
1164             last_abs_whitespace = abs_pos
1165             last_rel_whitespace = rel_pos
1166
1167         # the current character is a newline, so reset the relative
1168         # position too (start a new line)
1169         elif each_character == "\n":
1170             rel_pos = 0
1171             last_abs_whitespace = abs_pos
1172             last_rel_whitespace = rel_pos
1173
1174         # the current character meets the requested maximum line width, so we
1175         # need to wrap unless the current word is wider than the terminal (in
1176         # which case we let it do the wrapping instead)
1177         if last_rel_whitespace != 0 and (rel_pos > width or (
1178                 rel_pos > width - 1 and glyph_columns(each_character) == 2)):
1179
1180             # insert an eol in place of the last space
1181             text = (text[:last_abs_whitespace] + "\r\n" +
1182                     text[last_abs_whitespace + 1:])
1183
1184             # increase the absolute position because an eol is two
1185             # characters but the space it replaced was only one
1186             abs_pos += 1
1187
1188             # now we're at the begining of a new line, plus the
1189             # number of characters wrapped from the previous line
1190             rel_pos -= last_rel_whitespace
1191             last_rel_whitespace = 0
1192
1193         # as long as the character is not a carriage return and the
1194         # other above conditions haven't been met, count it as a
1195         # printable character
1196         elif each_character != "\r":
1197             rel_pos += glyph_columns(each_character)
1198             if each_character in (" ", "\n"):
1199                 last_abs_whitespace = abs_pos
1200                 last_rel_whitespace = rel_pos
1201
1202         # increase the absolute position for every character
1203         abs_pos += 1
1204
1205     # return the newly-wrapped text
1206     return text
1207
1208
1209 def weighted_choice(data):
1210     """Takes a dict weighted by value and returns a random key."""
1211
1212     # this will hold our expanded list of keys from the data
1213     expanded = []
1214
1215     # create the expanded list of keys
1216     for key in data.keys():
1217         for _count in range(data[key]):
1218             expanded.append(key)
1219
1220     # return one at random
1221     # Whitelist the random.randrange() call in bandit since it's not used for
1222     # security/cryptographic purposes
1223     return random.choice(expanded)  # nosec
1224
1225
1226 def random_name():
1227     """Returns a random character name."""
1228
1229     # the vowels and consonants needed to create romaji syllables
1230     vowels = [
1231         "a",
1232         "i",
1233         "u",
1234         "e",
1235         "o"
1236     ]
1237     consonants = [
1238         "'",
1239         "k",
1240         "z",
1241         "s",
1242         "sh",
1243         "z",
1244         "j",
1245         "t",
1246         "ch",
1247         "ts",
1248         "d",
1249         "n",
1250         "h",
1251         "f",
1252         "m",
1253         "y",
1254         "r",
1255         "w"
1256     ]
1257
1258     # this dict will hold our weighted list of syllables
1259     syllables = {}
1260
1261     # generate the list with an even weighting
1262     for consonant in consonants:
1263         for vowel in vowels:
1264             syllables[consonant + vowel] = 1
1265
1266     # we'll build the name into this string
1267     name = ""
1268
1269     # create a name of random length from the syllables
1270     # Whitelist the random.randrange() call in bandit since it's not used for
1271     # security/cryptographic purposes
1272     for _syllable in range(random.randrange(2, 6)):  # nosec
1273         name += weighted_choice(syllables)
1274
1275     # strip any leading quotemark, capitalize and return the name
1276     return name.strip("'").capitalize()
1277
1278
1279 def replace_macros(user, text, is_input=False):
1280     """Replaces macros in text output."""
1281
1282     # third person pronouns
1283     pronouns = {
1284         "female": {"obj": "her", "pos": "hers", "sub": "she"},
1285         "male": {"obj": "him", "pos": "his", "sub": "he"},
1286         "neuter": {"obj": "it", "pos": "its", "sub": "it"}
1287     }
1288
1289     # a dict of replacement macros
1290     macros = {
1291         "eol": "\r\n",
1292         "bld": chr(27) + "[1m",
1293         "nrm": chr(27) + "[0m",
1294         "blk": chr(27) + "[30m",
1295         "blu": chr(27) + "[34m",
1296         "cyn": chr(27) + "[36m",
1297         "grn": chr(27) + "[32m",
1298         "mgt": chr(27) + "[35m",
1299         "red": chr(27) + "[31m",
1300         "yel": chr(27) + "[33m",
1301     }
1302
1303     # add dynamic macros where possible
1304     if user.account:
1305         account_name = user.account.get("name")
1306         if account_name:
1307             macros["account"] = account_name
1308     if user.avatar:
1309         avatar_gender = user.avatar.get("gender")
1310         if avatar_gender:
1311             macros["tpop"] = pronouns[avatar_gender]["obj"]
1312             macros["tppp"] = pronouns[avatar_gender]["pos"]
1313             macros["tpsp"] = pronouns[avatar_gender]["sub"]
1314
1315     # loop until broken
1316     while True:
1317
1318         # find and replace per the macros dict
1319         macro_start = text.find("$(")
1320         if macro_start == -1:
1321             break
1322         macro_end = text.find(")", macro_start) + 1
1323         macro = text[macro_start + 2:macro_end - 1]
1324         if macro in macros.keys():
1325             replacement = macros[macro]
1326
1327         # this is how we handle local file inclusion (dangerous!)
1328         elif macro.startswith("inc:"):
1329             incfile = mudpy.data.find_file(macro[4:], universe=universe)
1330             if os.path.exists(incfile):
1331                 incfd = codecs.open(incfile, "r", "utf-8")
1332                 replacement = ""
1333                 for line in incfd:
1334                     if line.endswith("\n") and not line.endswith("\r\n"):
1335                         line = line.replace("\n", "\r\n")
1336                     replacement += line
1337                 # lose the trailing eol
1338                 replacement = replacement[:-2]
1339             else:
1340                 replacement = ""
1341                 log("Couldn't read included " + incfile + " file.", 7)
1342
1343         # if we get here, log and replace it with null
1344         else:
1345             replacement = ""
1346             if not is_input:
1347                 log("Unexpected replacement macro " +
1348                     macro + " encountered.", 6)
1349
1350         # and now we act on the replacement
1351         text = text.replace("$(" + macro + ")", replacement)
1352
1353     # replace the look-like-a-macro sequence
1354     text = text.replace("$_(", "$(")
1355
1356     return text
1357
1358
1359 def escape_macros(value):
1360     """Escapes replacement macros in text."""
1361     if type(value) is str:
1362         return value.replace("$(", "$_(")
1363     else:
1364         return value
1365
1366
1367 def first_word(text, separator=" "):
1368     """Returns a tuple of the first word and the rest."""
1369     if text:
1370         if text.find(separator) > 0:
1371             return text.split(separator, 1)
1372         else:
1373             return text, ""
1374     else:
1375         return "", ""
1376
1377
1378 def on_pulse():
1379     """The things which should happen on each pulse, aside from reloads."""
1380
1381     # open the listening socket if it hasn't been already
1382     if not hasattr(universe, "listening_socket"):
1383         universe.initialize_server_socket()
1384
1385     # assign a user if a new connection is waiting
1386     user = check_for_connection(universe.listening_socket)
1387     if user:
1388         universe.userlist.append(user)
1389
1390     # iterate over the connected users
1391     for user in universe.userlist:
1392         user.pulse()
1393
1394     # add an element for counters if it doesn't exist
1395     if "counters" not in universe.groups.get("internal", {}):
1396         Element("internal.counters", universe)
1397
1398     # update the log every now and then
1399     if not universe.groups["internal"]["counters"].get("mark"):
1400         log(str(len(universe.userlist)) + " connection(s)")
1401         universe.groups["internal"]["counters"].set(
1402             "mark", universe.contents["mudpy.timing"].get("status")
1403         )
1404     else:
1405         universe.groups["internal"]["counters"].set(
1406             "mark", universe.groups["internal"]["counters"].get(
1407                 "mark"
1408             ) - 1
1409         )
1410
1411     # periodically save everything
1412     if not universe.groups["internal"]["counters"].get("save"):
1413         universe.save()
1414         universe.groups["internal"]["counters"].set(
1415             "save", universe.contents["mudpy.timing"].get("save")
1416         )
1417     else:
1418         universe.groups["internal"]["counters"].set(
1419             "save", universe.groups["internal"]["counters"].get(
1420                 "save"
1421             ) - 1
1422         )
1423
1424     # pause for a configurable amount of time (decimal seconds)
1425     time.sleep(universe.contents["mudpy.timing"].get("increment"))
1426
1427     # increase the elapsed increment counter
1428     universe.groups["internal"]["counters"].set(
1429         "elapsed", universe.groups["internal"]["counters"].get(
1430             "elapsed", 0
1431         ) + 1
1432     )
1433
1434
1435 def reload_data():
1436     """Reload all relevant objects."""
1437     universe.save()
1438     old_userlist = universe.userlist[:]
1439     for element in list(universe.contents.values()):
1440         element.destroy()
1441     universe.load()
1442     for user in old_userlist:
1443         user.reload()
1444
1445
1446 def check_for_connection(listening_socket):
1447     """Check for a waiting connection and return a new user object."""
1448
1449     # try to accept a new connection
1450     try:
1451         connection, address = listening_socket.accept()
1452     except BlockingIOError:
1453         return None
1454
1455     # note that we got one
1456     log("New connection from %s." % address[0], 2)
1457
1458     # disable blocking so we can proceed whether or not we can send/receive
1459     connection.setblocking(0)
1460
1461     # create a new user object
1462     user = User()
1463     log("Instantiated %s for %s." % (user, address[0]), 0)
1464
1465     # associate this connection with it
1466     user.connection = connection
1467
1468     # set the user's ipa from the connection's ipa
1469     user.address = address[0]
1470
1471     # let the client know we WILL EOR (RFC 885)
1472     mudpy.telnet.enable(user, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US)
1473     user.negotiation_pause = 2
1474
1475     # return the new user object
1476     return user
1477
1478
1479 def find_command(command_name):
1480     """Try to find a command by name or abbreviation."""
1481
1482     # lowercase the command
1483     command_name = command_name.lower()
1484
1485     command = None
1486     if command_name in universe.groups["command"]:
1487         # the command matches a command word for which we have data
1488         command = universe.groups["command"][command_name]
1489     else:
1490         for candidate in sorted(universe.groups["command"]):
1491             if candidate.startswith(command_name) and not universe.groups[
1492                     "command"][candidate].get("administrative"):
1493                 # the command matches the start of a command word and is not
1494                 # restricted to administrators
1495                 command = universe.groups["command"][candidate]
1496                 break
1497     return command
1498
1499
1500 def get_menu(state, error=None, choices=None):
1501     """Show the correct menu text to a user."""
1502
1503     # make sure we don't reuse a mutable sequence by default
1504     if choices is None:
1505         choices = {}
1506
1507     # get the description or error text
1508     message = get_menu_description(state, error)
1509
1510     # get menu choices for the current state
1511     message += get_formatted_menu_choices(state, choices)
1512
1513     # try to get a prompt, if it was defined
1514     message += get_menu_prompt(state)
1515
1516     # throw in the default choice, if it exists
1517     message += get_formatted_default_menu_choice(state)
1518
1519     # display a message indicating if echo is off
1520     message += get_echo_message(state)
1521
1522     # return the assembly of various strings defined above
1523     return message
1524
1525
1526 def menu_echo_on(state):
1527     """True if echo is on, false if it is off."""
1528     return universe.groups["menu"][state].get("echo", True)
1529
1530
1531 def get_echo_message(state):
1532     """Return a message indicating that echo is off."""
1533     if menu_echo_on(state):
1534         return ""
1535     else:
1536         return "(won't echo) "
1537
1538
1539 def get_default_menu_choice(state):
1540     """Return the default choice for a menu."""
1541     return universe.groups["menu"][state].get("default")
1542
1543
1544 def get_formatted_default_menu_choice(state):
1545     """Default menu choice foratted for inclusion in a prompt string."""
1546     default_choice = get_default_menu_choice(state)
1547     if default_choice:
1548         return "[$(red)" + default_choice + "$(nrm)] "
1549     else:
1550         return ""
1551
1552
1553 def get_menu_description(state, error):
1554     """Get the description or error text."""
1555
1556     # an error condition was raised by the handler
1557     if error:
1558
1559         # try to get an error message matching the condition
1560         # and current state
1561         description = universe.groups[
1562             "menu"][state].get("error_" + error)
1563         if not description:
1564             description = "That is not a valid choice..."
1565         description = "$(red)" + description + "$(nrm)"
1566
1567     # there was no error condition
1568     else:
1569
1570         # try to get a menu description for the current state
1571         description = universe.groups["menu"][state].get("description")
1572
1573     # return the description or error message
1574     if description:
1575         description += "$(eol)$(eol)"
1576     return description
1577
1578
1579 def get_menu_prompt(state):
1580     """Try to get a prompt, if it was defined."""
1581     prompt = universe.groups["menu"][state].get("prompt")
1582     if prompt:
1583         prompt += " "
1584     return prompt
1585
1586
1587 def get_menu_choices(user):
1588     """Return a dict of choice:meaning."""
1589     state = universe.groups["menu"][user.state]
1590     create_choices = state.get("create")
1591     if create_choices:
1592         choices = call_hook_function(create_choices, (user,))
1593     else:
1594         choices = {}
1595     ignores = []
1596     options = {}
1597     creates = {}
1598     for facet in state.facets():
1599         if facet.startswith("demand_") and not call_hook_function(
1600                 universe.groups["menu"][user.state].get(facet), (user,)):
1601             ignores.append(facet.split("_", 2)[1])
1602         elif facet.startswith("create_"):
1603             creates[facet] = facet.split("_", 2)[1]
1604         elif facet.startswith("choice_"):
1605             options[facet] = facet.split("_", 2)[1]
1606     for facet in creates.keys():
1607         if not creates[facet] in ignores:
1608             choices[creates[facet]] = call_hook_function(
1609                 state.get(facet), (user,))
1610     for facet in options.keys():
1611         if not options[facet] in ignores:
1612             choices[options[facet]] = state.get(facet)
1613     return choices
1614
1615
1616 def get_formatted_menu_choices(state, choices):
1617     """Returns a formatted string of menu choices."""
1618     choice_output = ""
1619     choice_keys = list(choices.keys())
1620     choice_keys.sort()
1621     for choice in choice_keys:
1622         choice_output += "   [$(red)" + choice + "$(nrm)]  " + choices[
1623             choice
1624         ] + "$(eol)"
1625     if choice_output:
1626         choice_output += "$(eol)"
1627     return choice_output
1628
1629
1630 def get_menu_branches(state):
1631     """Return a dict of choice:branch."""
1632     branches = {}
1633     for facet in universe.groups["menu"][state].facets():
1634         if facet.startswith("branch_"):
1635             branches[
1636                 facet.split("_", 2)[1]
1637             ] = universe.groups["menu"][state].get(facet)
1638     return branches
1639
1640
1641 def get_default_branch(state):
1642     """Return the default branch."""
1643     return universe.groups["menu"][state].get("branch")
1644
1645
1646 def get_choice_branch(user):
1647     """Returns the new state matching the given choice."""
1648     branches = get_menu_branches(user.state)
1649     if user.choice in branches.keys():
1650         return branches[user.choice]
1651     elif user.choice in user.menu_choices.keys():
1652         return get_default_branch(user.state)
1653     else:
1654         return ""
1655
1656
1657 def get_menu_actions(state):
1658     """Return a dict of choice:branch."""
1659     actions = {}
1660     for facet in universe.groups["menu"][state].facets():
1661         if facet.startswith("action_"):
1662             actions[
1663                 facet.split("_", 2)[1]
1664             ] = universe.groups["menu"][state].get(facet)
1665     return actions
1666
1667
1668 def get_default_action(state):
1669     """Return the default action."""
1670     return universe.groups["menu"][state].get("action")
1671
1672
1673 def get_choice_action(user):
1674     """Run any indicated script for the given choice."""
1675     actions = get_menu_actions(user.state)
1676     if user.choice in actions.keys():
1677         return actions[user.choice]
1678     elif user.choice in user.menu_choices.keys():
1679         return get_default_action(user.state)
1680     else:
1681         return ""
1682
1683
1684 def call_hook_function(fname, arglist):
1685     """Safely execute named function with supplied arguments, return result."""
1686
1687     # all functions relative to mudpy package
1688     function = mudpy
1689
1690     for component in fname.split("."):
1691         try:
1692             function = getattr(function, component)
1693         except AttributeError:
1694             log('Could not find mudpy.%s() for arguments "%s"'
1695                 % (fname, arglist), 7)
1696             function = None
1697             break
1698     if function:
1699         try:
1700             return function(*arglist)
1701         except Exception:
1702             log('Calling mudpy.%s(%s) raised an exception...\n%s'
1703                 % (fname, (*arglist,), traceback.format_exc()), 7)
1704
1705
1706 def handle_user_input(user):
1707     """The main handler, branches to a state-specific handler."""
1708
1709     # if the user's client echo is off, send a blank line for aesthetics
1710     if mudpy.telnet.is_enabled(user, mudpy.telnet.TELOPT_ECHO,
1711                                mudpy.telnet.US):
1712         user.send("", add_prompt=False, prepend_padding=False)
1713
1714     # check to make sure the state is expected, then call that handler
1715     try:
1716         globals()["handler_" + user.state](user)
1717     except KeyError:
1718         generic_menu_handler(user)
1719
1720     # since we got input, flag that the menu/prompt needs to be redisplayed
1721     user.menu_seen = False
1722
1723     # update the last_input timestamp while we're at it
1724     user.last_input = universe.get_time()
1725
1726
1727 def generic_menu_handler(user):
1728     """A generic menu choice handler."""
1729
1730     # get a lower-case representation of the next line of input
1731     if user.input_queue:
1732         user.choice = user.input_queue.pop(0)
1733         if user.choice:
1734             user.choice = user.choice.lower()
1735     else:
1736         user.choice = ""
1737     if not user.choice:
1738         user.choice = get_default_menu_choice(user.state)
1739     if user.choice in user.menu_choices:
1740         action = get_choice_action(user)
1741         if action:
1742             call_hook_function(action, (user,))
1743         new_state = get_choice_branch(user)
1744         if new_state:
1745             user.state = new_state
1746     else:
1747         user.error = "default"
1748
1749
1750 def handler_entering_account_name(user):
1751     """Handle the login account name."""
1752
1753     # get the next waiting line of input
1754     input_data = user.input_queue.pop(0)
1755
1756     # did the user enter anything?
1757     if input_data:
1758
1759         # keep only the first word and convert to lower-case
1760         name = input_data.lower()
1761
1762         # fail if there are non-alphanumeric characters
1763         if name != "".join(filter(
1764                 lambda x: x >= "0" and x <= "9" or x >= "a" and x <= "z",
1765                 name)):
1766             user.error = "bad_name"
1767
1768         # if that account exists, time to request a password
1769         elif name in universe.groups.get("account", {}):
1770             user.account = universe.groups["account"][name]
1771             user.state = "checking_password"
1772
1773         # otherwise, this could be a brand new user
1774         else:
1775             user.account = Element("account.%s" % name, universe)
1776             user.account.set("name", name)
1777             log("New user: " + name, 2)
1778             user.state = "checking_new_account_name"
1779
1780     # if the user entered nothing for a name, then buhbye
1781     else:
1782         user.state = "disconnecting"
1783
1784
1785 def handler_checking_password(user):
1786     """Handle the login account password."""
1787
1788     # get the next waiting line of input
1789     input_data = user.input_queue.pop(0)
1790
1791     if "mudpy.limit" in universe.contents:
1792         max_password_tries = universe.contents["mudpy.limit"].get(
1793             "password_tries", 3)
1794     else:
1795         max_password_tries = 3
1796
1797     # does the hashed input equal the stored hash?
1798     if mudpy.password.verify(input_data, user.account.get("passhash")):
1799
1800         # if so, set the username and load from cold storage
1801         if not user.replace_old_connections():
1802             user.authenticate()
1803             user.state = "main_utility"
1804
1805     # if at first your hashes don't match, try, try again
1806     elif user.password_tries < max_password_tries - 1:
1807         user.password_tries += 1
1808         user.error = "incorrect"
1809
1810     # we've exceeded the maximum number of password failures, so disconnect
1811     else:
1812         user.send(
1813             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1814         )
1815         user.state = "disconnecting"
1816
1817
1818 def handler_entering_new_password(user):
1819     """Handle a new password entry."""
1820
1821     # get the next waiting line of input
1822     input_data = user.input_queue.pop(0)
1823
1824     if "mudpy.limit" in universe.contents:
1825         max_password_tries = universe.contents["mudpy.limit"].get(
1826             "password_tries", 3)
1827     else:
1828         max_password_tries = 3
1829
1830     # make sure the password is strong--at least one upper, one lower and
1831     # one digit, seven or more characters in length
1832     if len(input_data) > 6 and len(
1833        list(filter(lambda x: x >= "0" and x <= "9", input_data))
1834        ) and len(
1835         list(filter(lambda x: x >= "A" and x <= "Z", input_data))
1836     ) and len(
1837         list(filter(lambda x: x >= "a" and x <= "z", input_data))
1838     ):
1839
1840         # hash and store it, then move on to verification
1841         user.account.set("passhash", mudpy.password.create(input_data))
1842         user.state = "verifying_new_password"
1843
1844     # the password was weak, try again if you haven't tried too many times
1845     elif user.password_tries < max_password_tries - 1:
1846         user.password_tries += 1
1847         user.error = "weak"
1848
1849     # too many tries, so adios
1850     else:
1851         user.send(
1852             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1853         )
1854         user.account.destroy()
1855         user.state = "disconnecting"
1856
1857
1858 def handler_verifying_new_password(user):
1859     """Handle the re-entered new password for verification."""
1860
1861     # get the next waiting line of input
1862     input_data = user.input_queue.pop(0)
1863
1864     if "mudpy.limit" in universe.contents:
1865         max_password_tries = universe.contents["mudpy.limit"].get(
1866             "password_tries", 3)
1867     else:
1868         max_password_tries = 3
1869
1870     # hash the input and match it to storage
1871     if mudpy.password.verify(input_data, user.account.get("passhash")):
1872         user.authenticate()
1873
1874         # the hashes matched, so go active
1875         if not user.replace_old_connections():
1876             user.state = "main_utility"
1877
1878     # go back to entering the new password as long as you haven't tried
1879     # too many times
1880     elif user.password_tries < max_password_tries - 1:
1881         user.password_tries += 1
1882         user.error = "differs"
1883         user.state = "entering_new_password"
1884
1885     # otherwise, sayonara
1886     else:
1887         user.send(
1888             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1889         )
1890         user.account.destroy()
1891         user.state = "disconnecting"
1892
1893
1894 def handler_active(user):
1895     """Handle input for active users."""
1896
1897     # get the next waiting line of input
1898     input_data = user.input_queue.pop(0)
1899
1900     # is there input?
1901     if input_data:
1902
1903         # split out the command and parameters
1904         actor = user.avatar
1905         mode = actor.get("mode")
1906         if mode and input_data.startswith("!"):
1907             command_name, parameters = first_word(input_data[1:])
1908         elif mode == "chat":
1909             command_name = "say"
1910             parameters = input_data
1911         else:
1912             command_name, parameters = first_word(input_data)
1913
1914         # expand to an actual command
1915         command = find_command(command_name)
1916
1917         # if it's allowed, do it
1918         ran = False
1919         if actor.can_run(command):
1920             # dereference the relative object path for the requested function
1921             # TODO(fungi) use call_hook_function() here instead
1922             action = mudpy
1923             action_fname = command.get("action", command.key)
1924             for component in action_fname.split("."):
1925                 try:
1926                     action = getattr(action, component)
1927                     ran = True
1928                 except AttributeError:
1929                     log('Could not find action function "%s" for command "%s"'
1930                         % (action_fname, command_name))
1931                     action = None
1932                     break
1933             if action:
1934                 try:
1935                     action(actor, parameters)
1936                 except Exception:
1937                     log('Command string "%s" from user %s raised an '
1938                         'exception...\n%s' % (
1939                             input_data, actor.owner.account.get("name"),
1940                             traceback.format_exc()))
1941                     mudpy.command.error(actor, input_data)
1942
1943         # if the command was not run, give an error
1944         if not ran:
1945             mudpy.command.error(actor, input_data)
1946
1947     # if no input, just idle back with a prompt
1948     else:
1949         user.send("", just_prompt=True)
1950
1951
1952 def daemonize(universe):
1953     """Fork and disassociate from everything."""
1954
1955     # only if this is what we're configured to do
1956     if "mudpy.process" in universe.contents and universe.contents[
1957             "mudpy.process"].get("daemon"):
1958
1959         # log before we start forking around, so the terminal gets the message
1960         log("Disassociating from the controlling terminal.")
1961
1962         # fork off and die, so we free up the controlling terminal
1963         if os.fork():
1964             os._exit(0)
1965
1966         # switch to a new process group
1967         os.setsid()
1968
1969         # fork some more, this time to free us from the old process group
1970         if os.fork():
1971             os._exit(0)
1972
1973         # reset the working directory so we don't needlessly tie up mounts
1974         os.chdir("/")
1975
1976         # clear the file creation mask so we can bend it to our will later
1977         os.umask(0)
1978
1979         # redirect stdin/stdout/stderr and close off their former descriptors
1980         for stdpipe in range(3):
1981             os.close(stdpipe)
1982         sys.stdin = codecs.open("/dev/null", "r", "utf-8")
1983         sys.__stdin__ = codecs.open("/dev/null", "r", "utf-8")
1984         sys.stdout = codecs.open("/dev/null", "w", "utf-8")
1985         sys.stderr = codecs.open("/dev/null", "w", "utf-8")
1986         sys.__stdout__ = codecs.open("/dev/null", "w", "utf-8")
1987         sys.__stderr__ = codecs.open("/dev/null", "w", "utf-8")
1988
1989
1990 def create_pidfile(universe):
1991     """Write a file containing the current process ID."""
1992     pid = str(os.getpid())
1993     log("Process ID: " + pid)
1994     if "mudpy.process" in universe.contents:
1995         file_name = universe.contents["mudpy.process"].get("pidfile", "")
1996     else:
1997         file_name = ""
1998     if file_name:
1999         if not os.path.isabs(file_name):
2000             file_name = os.path.join(universe.startdir, file_name)
2001         os.makedirs(os.path.dirname(file_name), exist_ok=True)
2002         file_descriptor = codecs.open(file_name, "w", "utf-8")
2003         file_descriptor.write(pid + "\n")
2004         file_descriptor.flush()
2005         file_descriptor.close()
2006
2007
2008 def remove_pidfile(universe):
2009     """Remove the file containing the current process ID."""
2010     if "mudpy.process" in universe.contents:
2011         file_name = universe.contents["mudpy.process"].get("pidfile", "")
2012     else:
2013         file_name = ""
2014     if file_name:
2015         if not os.path.isabs(file_name):
2016             file_name = os.path.join(universe.startdir, file_name)
2017         if os.access(file_name, os.W_OK):
2018             os.remove(file_name)
2019
2020
2021 def excepthook(excepttype, value, tracebackdata):
2022     """Handle uncaught exceptions."""
2023
2024     # assemble the list of errors into a single string
2025     message = "".join(
2026         traceback.format_exception(excepttype, value, tracebackdata)
2027     )
2028
2029     # try to log it, if possible
2030     try:
2031         log(message, 9)
2032     except Exception as e:
2033         # try to write it to stderr, if possible
2034         sys.stderr.write(message + "\nException while logging...\n%s" % e)
2035
2036
2037 def sighook(what, where):
2038     """Handle external signals."""
2039
2040     # a generic message
2041     message = "Caught signal: "
2042
2043     # for a hangup signal
2044     if what == signal.SIGHUP:
2045         message += "hangup (reloading)"
2046         universe.reload_flag = True
2047
2048     # for a terminate signal
2049     elif what == signal.SIGTERM:
2050         message += "terminate (halting)"
2051         universe.terminate_flag = True
2052
2053     # catchall for unexpected signals
2054     else:
2055         message += str(what) + " (unhandled)"
2056
2057     # log what happened
2058     log(message, 8)
2059
2060
2061 def override_excepthook():
2062     """Redefine sys.excepthook with our own."""
2063     sys.excepthook = excepthook
2064
2065
2066 def assign_sighook():
2067     """Assign a customized handler for some signals."""
2068     signal.signal(signal.SIGHUP, sighook)
2069     signal.signal(signal.SIGTERM, sighook)
2070
2071
2072 def setup():
2073     """This contains functions to be performed when starting the engine."""
2074
2075     # see if a configuration file was specified
2076     if len(sys.argv) > 1:
2077         conffile = sys.argv[1]
2078     else:
2079         conffile = ""
2080
2081     # the big bang
2082     global universe
2083     universe = Universe(conffile, True)
2084
2085     # report any loglines which accumulated during setup
2086     for logline in universe.setup_loglines:
2087         log(*logline)
2088     universe.setup_loglines = []
2089
2090     # fork and disassociate
2091     daemonize(universe)
2092
2093     # override the default exception handler so we get logging first thing
2094     override_excepthook()
2095
2096     # set up custom signal handlers
2097     assign_sighook()
2098
2099     # make the pidfile
2100     create_pidfile(universe)
2101
2102     # load and store diagnostic info
2103     universe.versions = mudpy.version.Versions("mudpy")
2104
2105     # log startup diagnostic messages
2106     log("On %s at %s" % (universe.versions.python_version, sys.executable), 1)
2107     log("Import path: %s" % ", ".join(sys.path), 1)
2108     log("Installed dependencies: %s" % universe.versions.dependencies_text, 1)
2109     log("Other python packages: %s" % universe.versions.environment_text, 1)
2110     log("Running version: %s" % universe.versions.version, 1)
2111     log("Initial directory: %s" % universe.startdir, 1)
2112     log("Command line: %s" % " ".join(sys.argv), 1)
2113
2114     # pass the initialized universe back
2115     return universe
2116
2117
2118 def finish():
2119     """These are functions performed when shutting down the engine."""
2120
2121     # the loop has terminated, so save persistent data
2122     universe.save()
2123
2124     # log a final message
2125     log("Shutting down now.")
2126
2127     # get rid of the pidfile
2128     remove_pidfile(universe)