fca279abee5e98c7caf6ef15c6e2956264863724
[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.columns = 79
492         self.connection = None
493         self.error = ""
494         self.input_queue = []
495         self.last_address = ""
496         self.last_input = universe.get_time()
497         self.menu_choices = {}
498         self.menu_seen = False
499         self.negotiation_pause = 0
500         self.output_queue = []
501         self.partial_input = b""
502         self.password_tries = 0
503         self.state = "telopt_negotiation"
504         self.telopts = {}
505         self.universe = universe
506
507     def quit(self):
508         """Log, close the connection and remove."""
509         if self.account:
510             name = self.account.get("name", self)
511         else:
512             name = self
513         log("Logging out %s" % name, 2)
514         self.deactivate_avatar()
515         self.connection.close()
516         self.remove()
517
518     def check_idle(self):
519         """Warn or disconnect idle users as appropriate."""
520         idletime = universe.get_time() - self.last_input
521         linkdead_dict = universe.contents[
522             "mudpy.timing.idle.disconnect"].facets()
523         if self.state in linkdead_dict:
524             linkdead_state = self.state
525         else:
526             linkdead_state = "default"
527         if idletime > linkdead_dict[linkdead_state]:
528             self.send(
529                 "$(eol)$(red)You've done nothing for far too long... goodbye!"
530                 + "$(nrm)$(eol)",
531                 flush=True,
532                 add_prompt=False
533             )
534             logline = "Disconnecting "
535             if self.account and self.account.get("name"):
536                 logline += self.account.get("name")
537             else:
538                 logline += "an unknown user"
539             logline += (" after idling too long in the " + self.state
540                         + " state.")
541             log(logline, 2)
542             self.state = "disconnecting"
543             self.menu_seen = False
544         idle_dict = universe.contents["mudpy.timing.idle.warn"].facets()
545         if self.state in idle_dict:
546             idle_state = self.state
547         else:
548             idle_state = "default"
549         if idletime == idle_dict[idle_state]:
550             self.send(
551                 "$(eol)$(red)If you continue to be unproductive, "
552                 + "you'll be shown the door...$(nrm)$(eol)"
553             )
554
555     def reload(self):
556         """Save, load a new user and relocate the connection."""
557
558         # copy old attributes
559         attributes = self.__dict__
560
561         # get out of the list
562         self.remove()
563
564         # get rid of the old user object
565         del(self)
566
567         # create a new user object
568         new_user = User()
569
570         # set everything equivalent
571         new_user.__dict__ = attributes
572
573         # the avatar needs a new owner
574         if new_user.avatar:
575             new_user.account = universe.contents[new_user.account.key]
576             new_user.avatar = universe.contents[new_user.avatar.key]
577             new_user.avatar.owner = new_user
578
579         # add it to the list
580         universe.userlist.append(new_user)
581
582     def replace_old_connections(self):
583         """Disconnect active users with the same name."""
584
585         # the default return value
586         return_value = False
587
588         # iterate over each user in the list
589         for old_user in universe.userlist:
590
591             # the name is the same but it's not us
592             if hasattr(
593                old_user, "account"
594                ) and old_user.account and old_user.account.get(
595                 "name"
596             ) == self.account.get(
597                 "name"
598             ) and old_user is not self:
599
600                 # make a note of it
601                 log(
602                     "User " + self.account.get(
603                         "name"
604                     ) + " reconnected--closing old connection to "
605                     + old_user.address + ".",
606                     2
607                 )
608                 old_user.send(
609                     "$(eol)$(red)New connection from " + self.address
610                     + ". Terminating old connection...$(nrm)$(eol)",
611                     flush=True,
612                     add_prompt=False
613                 )
614
615                 # close the old connection
616                 old_user.connection.close()
617
618                 # replace the old connection with this one
619                 old_user.send(
620                     "$(eol)$(red)Taking over old connection from "
621                     + old_user.address + ".$(nrm)"
622                 )
623                 old_user.connection = self.connection
624                 old_user.last_address = old_user.address
625                 old_user.address = self.address
626
627                 # take this one out of the list and delete
628                 self.remove()
629                 del(self)
630                 return_value = True
631                 break
632
633         # true if an old connection was replaced, false if not
634         return return_value
635
636     def authenticate(self):
637         """Flag the user as authenticated and disconnect duplicates."""
638         if self.state != "authenticated":
639             self.authenticated = True
640             log("User %s authenticated for account %s." % (
641                     self, self.account.subkey), 2)
642             if ("mudpy.limit" in universe.contents and self.account.subkey in
643                     universe.contents["mudpy.limit"].get("admins")):
644                 self.account.set("administrator", True)
645                 log("Account %s is an administrator." % (
646                         self.account.subkey), 2)
647
648     def show_menu(self):
649         """Send the user their current menu."""
650         if not self.menu_seen:
651             self.menu_choices = get_menu_choices(self)
652             self.send(
653                 get_menu(self.state, self.error, self.menu_choices),
654                 "",
655                 add_terminator=True
656             )
657             self.menu_seen = True
658             self.error = False
659             self.adjust_echoing()
660
661     def prompt(self):
662         """"Generate and return an input prompt."""
663
664         # Start with the user's preference, if one was provided
665         prompt = self.account.get("prompt")
666
667         # If the user has not set a prompt, then immediately return the default
668         # provided for the current state
669         if not prompt:
670             return get_menu_prompt(self.state)
671
672         # Allow including the World clock state
673         if "$_(time)" in prompt:
674             prompt = prompt.replace(
675                 "$_(time)",
676                 str(universe.groups["internal"]["counters"].get("elapsed")))
677
678         # Append a single space for clear separation from user input
679         if prompt[-1] != " ":
680             prompt = "%s " % prompt
681
682         # Return the cooked prompt
683         return prompt
684
685     def adjust_echoing(self):
686         """Adjust echoing to match state menu requirements."""
687         if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_ECHO,
688                                    mudpy.telnet.US):
689             if menu_echo_on(self.state):
690                 mudpy.telnet.disable(self, mudpy.telnet.TELOPT_ECHO,
691                                      mudpy.telnet.US)
692         elif not menu_echo_on(self.state):
693             mudpy.telnet.enable(self, mudpy.telnet.TELOPT_ECHO,
694                                 mudpy.telnet.US)
695
696     def remove(self):
697         """Remove a user from the list of connected users."""
698         log("Disconnecting account %s." % self, 0)
699         universe.userlist.remove(self)
700
701     def send(
702         self,
703         output,
704         eol="$(eol)",
705         raw=False,
706         flush=False,
707         add_prompt=True,
708         just_prompt=False,
709         add_terminator=False,
710         prepend_padding=True
711     ):
712         """Send arbitrary text to a connected user."""
713
714         # unless raw mode is on, clean it up all nice and pretty
715         if not raw:
716
717             # strip extra $(eol) off if present
718             while output.startswith("$(eol)"):
719                 output = output[6:]
720             while output.endswith("$(eol)"):
721                 output = output[:-6]
722             extra_lines = output.find("$(eol)$(eol)$(eol)")
723             while extra_lines > -1:
724                 output = output[:extra_lines] + output[extra_lines + 6:]
725                 extra_lines = output.find("$(eol)$(eol)$(eol)")
726
727             # start with a newline, append the message, then end
728             # with the optional eol string passed to this function
729             # and the ansi escape to return to normal text
730             if not just_prompt and prepend_padding:
731                 if (not self.output_queue or not
732                         self.output_queue[-1].endswith(b"\r\n")):
733                     output = "$(eol)" + output
734                 elif not self.output_queue[-1].endswith(
735                     b"\r\n\x1b[0m\r\n"
736                 ) and not self.output_queue[-1].endswith(
737                     b"\r\n\r\n"
738                 ):
739                     output = "$(eol)" + output
740             output += eol + chr(27) + "[0m"
741
742             # tack on a prompt if active
743             if self.state == "active":
744                 if not just_prompt:
745                     output += "$(eol)"
746                 if add_prompt:
747                     output += self.prompt()
748                     mode = self.avatar.get("mode")
749                     if mode:
750                         output += "(" + mode + ") "
751
752             # find and replace macros in the output
753             output = replace_macros(self, output)
754
755             # wrap the text at the client's width (min 40, 0 disables)
756             if self.columns:
757                 if self.columns < 40:
758                     wrap = 40
759                 else:
760                     wrap = self.columns
761                 output = wrap_ansi_text(output, wrap)
762
763             # if supported by the client, encode it utf-8
764             if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
765                                        mudpy.telnet.US):
766                 encoded_output = output.encode("utf-8")
767
768             # otherwise just send ascii
769             else:
770                 encoded_output = output.encode("ascii", "replace")
771
772             # end with a terminator if requested
773             if add_prompt or add_terminator:
774                 if mudpy.telnet.is_enabled(
775                         self, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US):
776                     encoded_output += mudpy.telnet.telnet_proto(
777                         mudpy.telnet.IAC, mudpy.telnet.EOR)
778                 elif not mudpy.telnet.is_enabled(
779                         self, mudpy.telnet.TELOPT_SGA, mudpy.telnet.US):
780                     encoded_output += mudpy.telnet.telnet_proto(
781                         mudpy.telnet.IAC, mudpy.telnet.GA)
782
783             # and tack it onto the queue
784             self.output_queue.append(encoded_output)
785
786             # if this is urgent, flush all pending output
787             if flush:
788                 self.flush()
789
790         # just dump raw bytes as requested
791         else:
792             self.output_queue.append(output)
793             self.flush()
794
795     def pulse(self):
796         """All the things to do to the user per increment."""
797
798         # if the world is terminating, disconnect
799         if universe.terminate_flag:
800             self.state = "disconnecting"
801             self.menu_seen = False
802
803         # check for an idle connection and act appropriately
804         else:
805             self.check_idle()
806
807         # if output is paused, decrement the counter
808         if self.state == "telopt_negotiation":
809             if self.negotiation_pause:
810                 self.negotiation_pause -= 1
811             else:
812                 self.state = "entering_account_name"
813
814         # show the user a menu as needed
815         elif not self.state == "active":
816             self.show_menu()
817
818         # flush any pending output in the queue
819         self.flush()
820
821         # disconnect users with the appropriate state
822         if self.state == "disconnecting":
823             self.quit()
824
825         # check for input and add it to the queue
826         self.enqueue_input()
827
828         # there is input waiting in the queue
829         if self.input_queue:
830             handle_user_input(self)
831
832     def flush(self):
833         """Try to send the last item in the queue and remove it."""
834         if self.output_queue:
835             try:
836                 self.connection.send(self.output_queue[0])
837             except (BrokenPipeError, ConnectionResetError):
838                 if self.account and self.account.get("name"):
839                     account = self.account.get("name")
840                 else:
841                     account = "an unknown user"
842                 self.state = "disconnecting"
843                 log("Disconnected while sending to %s." % account, 7)
844             del self.output_queue[0]
845
846     def enqueue_input(self):
847         """Process and enqueue any new input."""
848
849         # check for some input
850         try:
851             raw_input = self.connection.recv(1024)
852         except (BlockingIOError, OSError):
853             raw_input = b""
854
855         # we got something
856         if raw_input:
857
858             # tack this on to any previous partial
859             self.partial_input += raw_input
860
861             # reply to and remove any IAC negotiation codes
862             mudpy.telnet.negotiate_telnet_options(self)
863
864             # separate multiple input lines
865             new_input_lines = self.partial_input.split(b"\r\0")
866             if len(new_input_lines) == 1:
867                 new_input_lines = new_input_lines[0].split(b"\r\n")
868
869             # if input doesn't end in a newline, replace the
870             # held partial input with the last line of it
871             if not (
872                     self.partial_input.endswith(b"\r\0") or
873                     self.partial_input.endswith(b"\r\n")):
874                 self.partial_input = new_input_lines.pop()
875
876             # otherwise, chop off the extra null input and reset
877             # the held partial input
878             else:
879                 new_input_lines.pop()
880                 self.partial_input = b""
881
882             # iterate over the remaining lines
883             for line in new_input_lines:
884
885                 # strip off extra whitespace
886                 line = line.strip()
887
888                 # log non-printable characters remaining
889                 if not mudpy.telnet.is_enabled(
890                         self, mudpy.telnet.TELOPT_BINARY, mudpy.telnet.HIM):
891                     asciiline = bytes([x for x in line if 32 <= x <= 126])
892                     if line != asciiline:
893                         logline = "Non-ASCII characters from "
894                         if self.account and self.account.get("name"):
895                             logline += self.account.get("name") + ": "
896                         else:
897                             logline += "unknown user: "
898                         logline += repr(line)
899                         log(logline, 4)
900                         line = asciiline
901
902                 try:
903                     line = line.decode("utf-8")
904                 except UnicodeDecodeError:
905                     logline = "Non-UTF-8 sequence from "
906                     if self.account and self.account.get("name"):
907                         logline += self.account.get("name") + ": "
908                     else:
909                         logline += "unknown user: "
910                     logline += repr(line)
911                     log(logline, 4)
912                     return
913
914                 line = unicodedata.normalize("NFKC", line)
915
916                 # put on the end of the queue
917                 self.input_queue.append(line)
918
919     def new_avatar(self):
920         """Instantiate a new, unconfigured avatar for this user."""
921         counter = 0
922         while ("avatar_%s_%s" % (self.account.get("name"), counter)
923                 in universe.groups.get("actor", {}).keys()):
924             counter += 1
925         self.avatar = Element(
926             "actor.avatar_%s_%s" % (self.account.get("name"), counter),
927             universe)
928         self.avatar.append("inherit", "archetype.avatar")
929         self.account.append("avatars", self.avatar.key)
930         log("Created new avatar %s for user %s." % (
931                 self.avatar.key, self.account.get("name")), 0)
932
933     def delete_avatar(self, avatar):
934         """Remove an avatar from the world and from the user's list."""
935         if self.avatar is universe.contents[avatar]:
936             self.avatar = None
937         log("Deleting avatar %s for user %s." % (
938                 avatar, self.account.get("name")), 0)
939         universe.contents[avatar].destroy()
940         avatars = self.account.get("avatars")
941         avatars.remove(avatar)
942         self.account.set("avatars", avatars)
943
944     def activate_avatar_by_index(self, index):
945         """Enter the world with a particular indexed avatar."""
946         self.avatar = universe.contents[
947             self.account.get("avatars")[index]]
948         self.avatar.owner = self
949         self.state = "active"
950         log("Activated avatar %s (%s)." % (
951                 self.avatar.get("name"), self.avatar.key), 0)
952         self.avatar.go_home()
953
954     def deactivate_avatar(self):
955         """Have the active avatar leave the world."""
956         if self.avatar:
957             log("Deactivating avatar %s (%s) for user %s." % (
958                     self.avatar.get("name"), self.avatar.key,
959                     self.account.get("name")), 0)
960             current = self.avatar.get("location")
961             if current:
962                 self.avatar.set("default_location", current)
963                 self.avatar.echo_to_location(
964                     "You suddenly wonder where " + self.avatar.get(
965                         "name"
966                     ) + " went."
967                 )
968                 del universe.contents[current].contents[self.avatar.key]
969                 self.avatar.remove_facet("location")
970             self.avatar.owner = None
971             self.avatar = None
972
973     def destroy(self):
974         """Destroy the user and associated avatars."""
975         for avatar in self.account.get("avatars"):
976             self.delete_avatar(avatar)
977         log("Destroying account %s for user %s." % (
978                 self.account.get("name"), self), 0)
979         self.account.destroy()
980
981     def list_avatar_names(self):
982         """List names of assigned avatars."""
983         avatars = []
984         for avatar in self.account.get("avatars"):
985             try:
986                 avatars.append(universe.contents[avatar].get("name"))
987             except KeyError:
988                 log('Missing avatar "%s", possible data corruption.' %
989                     avatar, 6)
990         return avatars
991
992
993 def broadcast(message, add_prompt=True):
994     """Send a message to all connected users."""
995     for each_user in universe.userlist:
996         each_user.send("$(eol)" + message, add_prompt=add_prompt)
997
998
999 def log(message, level=0):
1000     """Log a message."""
1001
1002     # a couple references we need
1003     if "mudpy.log" in universe.contents:
1004         file_name = universe.contents["mudpy.log"].get("file", "")
1005         max_log_lines = universe.contents["mudpy.log"].get("lines", 0)
1006         syslog_name = universe.contents["mudpy.log"].get("syslog", "")
1007     else:
1008         file_name = ""
1009         max_log_lines = 0
1010         syslog_name = ""
1011     timestamp = datetime.datetime.now().isoformat(' ')
1012
1013     # turn the message into a list of nonempty lines
1014     lines = [x for x in [(x.rstrip()) for x in message.split("\n")] if x != ""]
1015
1016     # send the timestamp and line to a file
1017     if file_name:
1018         if not os.path.isabs(file_name):
1019             file_name = os.path.join(universe.startdir, file_name)
1020         os.makedirs(os.path.dirname(file_name), exist_ok=True)
1021         file_descriptor = codecs.open(file_name, "a", "utf-8")
1022         for line in lines:
1023             file_descriptor.write(timestamp + " " + line + "\n")
1024         file_descriptor.flush()
1025         file_descriptor.close()
1026
1027     # send the timestamp and line to standard output
1028     if ("mudpy.log" in universe.contents and
1029             universe.contents["mudpy.log"].get("stdout")):
1030         for line in lines:
1031             print(timestamp + " " + line)
1032
1033     # send the line to the system log
1034     if syslog_name:
1035         syslog.openlog(
1036             syslog_name.encode("utf-8"),
1037             syslog.LOG_PID,
1038             syslog.LOG_INFO | syslog.LOG_DAEMON
1039         )
1040         for line in lines:
1041             syslog.syslog(line)
1042         syslog.closelog()
1043
1044     # display to connected administrators
1045     for user in universe.userlist:
1046         if user.state == "active" and user.account.get(
1047            "administrator"
1048            ) and user.account.get("loglevel", 0) <= level:
1049             # iterate over every line in the message
1050             full_message = ""
1051             for line in lines:
1052                 full_message += (
1053                     "$(bld)$(red)" + timestamp + " "
1054                     + line.replace("$(", "$_(") + "$(nrm)$(eol)")
1055             user.send(full_message, flush=True)
1056
1057     # add to the recent log list
1058     for line in lines:
1059         while 0 < len(universe.loglines) >= max_log_lines:
1060             del universe.loglines[0]
1061         universe.loglines.append((level, timestamp + " " + line))
1062
1063
1064 def get_loglines(level, start, stop):
1065     """Return a specific range of loglines filtered by level."""
1066
1067     # filter the log lines
1068     loglines = [x for x in universe.loglines if x[0] >= level]
1069
1070     # we need these in several places
1071     total_count = str(len(universe.loglines))
1072     filtered_count = len(loglines)
1073
1074     # don't proceed if there are no lines
1075     if filtered_count:
1076
1077         # can't start before the begining or at the end
1078         if start > filtered_count:
1079             start = filtered_count
1080         if start < 1:
1081             start = 1
1082
1083         # can't stop before we start
1084         if stop > start:
1085             stop = start
1086         elif stop < 1:
1087             stop = 1
1088
1089         # some preamble
1090         message = "There are " + str(total_count)
1091         message += " log lines in memory and " + str(filtered_count)
1092         message += " at or above level " + str(level) + "."
1093         message += " The matching lines from " + str(stop) + " to "
1094         message += str(start) + " are:$(eol)$(eol)"
1095
1096         # add the text from the selected lines
1097         if stop > 1:
1098             range_lines = loglines[-start:-(stop - 1)]
1099         else:
1100             range_lines = loglines[-start:]
1101         for line in range_lines:
1102             message += "   (" + str(line[0]) + ") " + line[1].replace(
1103                 "$(", "$_("
1104             ) + "$(eol)"
1105
1106     # there were no lines
1107     else:
1108         message = "None of the " + str(total_count)
1109         message += " lines in memory matches your request."
1110
1111     # pass it back
1112     return message
1113
1114
1115 def glyph_columns(character):
1116     """Convenience function to return the column width of a glyph."""
1117     if unicodedata.east_asian_width(character) in "FW":
1118         return 2
1119     else:
1120         return 1
1121
1122
1123 def wrap_ansi_text(text, width):
1124     """Wrap text with arbitrary width while ignoring ANSI colors."""
1125
1126     # the current position in the entire text string, including all
1127     # characters, printable or otherwise
1128     abs_pos = 0
1129
1130     # the current text position relative to the begining of the line,
1131     # ignoring color escape sequences
1132     rel_pos = 0
1133
1134     # the absolute and relative positions of the most recent whitespace
1135     # character
1136     last_abs_whitespace = 0
1137     last_rel_whitespace = 0
1138
1139     # whether the current character is part of a color escape sequence
1140     escape = False
1141
1142     # normalize any potentially composited unicode before we count it
1143     text = unicodedata.normalize("NFKC", text)
1144
1145     # iterate over each character from the begining of the text
1146     for each_character in text:
1147
1148         # the current character is the escape character
1149         if each_character == "\x1b" and not escape:
1150             escape = True
1151             rel_pos -= 1
1152
1153         # the current character is within an escape sequence
1154         elif escape:
1155             rel_pos -= 1
1156             if each_character == "m":
1157                 # the current character is m, which terminates the
1158                 # escape sequence
1159                 escape = False
1160
1161         # the current character is a space
1162         elif each_character == " ":
1163             last_abs_whitespace = abs_pos
1164             last_rel_whitespace = rel_pos
1165
1166         # the current character is a newline, so reset the relative
1167         # position too (start a new line)
1168         elif each_character == "\n":
1169             rel_pos = 0
1170             last_abs_whitespace = abs_pos
1171             last_rel_whitespace = rel_pos
1172
1173         # the current character meets the requested maximum line width, so we
1174         # need to wrap unless the current word is wider than the terminal (in
1175         # which case we let it do the wrapping instead)
1176         if last_rel_whitespace != 0 and (rel_pos > width or (
1177                 rel_pos > width - 1 and glyph_columns(each_character) == 2)):
1178
1179             # insert an eol in place of the last space
1180             text = (text[:last_abs_whitespace] + "\r\n" +
1181                     text[last_abs_whitespace + 1:])
1182
1183             # increase the absolute position because an eol is two
1184             # characters but the space it replaced was only one
1185             abs_pos += 1
1186
1187             # now we're at the begining of a new line, plus the
1188             # number of characters wrapped from the previous line
1189             rel_pos -= last_rel_whitespace
1190             last_rel_whitespace = 0
1191
1192         # as long as the character is not a carriage return and the
1193         # other above conditions haven't been met, count it as a
1194         # printable character
1195         elif each_character != "\r":
1196             rel_pos += glyph_columns(each_character)
1197             if each_character in (" ", "\n"):
1198                 last_abs_whitespace = abs_pos
1199                 last_rel_whitespace = rel_pos
1200
1201         # increase the absolute position for every character
1202         abs_pos += 1
1203
1204     # return the newly-wrapped text
1205     return text
1206
1207
1208 def weighted_choice(data):
1209     """Takes a dict weighted by value and returns a random key."""
1210
1211     # this will hold our expanded list of keys from the data
1212     expanded = []
1213
1214     # create the expanded list of keys
1215     for key in data.keys():
1216         for _count in range(data[key]):
1217             expanded.append(key)
1218
1219     # return one at random
1220     return random.choice(expanded)
1221
1222
1223 def random_name():
1224     """Returns a random character name."""
1225
1226     # the vowels and consonants needed to create romaji syllables
1227     vowels = [
1228         "a",
1229         "i",
1230         "u",
1231         "e",
1232         "o"
1233     ]
1234     consonants = [
1235         "'",
1236         "k",
1237         "z",
1238         "s",
1239         "sh",
1240         "z",
1241         "j",
1242         "t",
1243         "ch",
1244         "ts",
1245         "d",
1246         "n",
1247         "h",
1248         "f",
1249         "m",
1250         "y",
1251         "r",
1252         "w"
1253     ]
1254
1255     # this dict will hold our weighted list of syllables
1256     syllables = {}
1257
1258     # generate the list with an even weighting
1259     for consonant in consonants:
1260         for vowel in vowels:
1261             syllables[consonant + vowel] = 1
1262
1263     # we'll build the name into this string
1264     name = ""
1265
1266     # create a name of random length from the syllables
1267     for _syllable in range(random.randrange(2, 6)):
1268         name += weighted_choice(syllables)
1269
1270     # strip any leading quotemark, capitalize and return the name
1271     return name.strip("'").capitalize()
1272
1273
1274 def replace_macros(user, text, is_input=False):
1275     """Replaces macros in text output."""
1276
1277     # third person pronouns
1278     pronouns = {
1279         "female": {"obj": "her", "pos": "hers", "sub": "she"},
1280         "male": {"obj": "him", "pos": "his", "sub": "he"},
1281         "neuter": {"obj": "it", "pos": "its", "sub": "it"}
1282     }
1283
1284     # a dict of replacement macros
1285     macros = {
1286         "eol": "\r\n",
1287         "bld": chr(27) + "[1m",
1288         "nrm": chr(27) + "[0m",
1289         "blk": chr(27) + "[30m",
1290         "blu": chr(27) + "[34m",
1291         "cyn": chr(27) + "[36m",
1292         "grn": chr(27) + "[32m",
1293         "mgt": chr(27) + "[35m",
1294         "red": chr(27) + "[31m",
1295         "yel": chr(27) + "[33m",
1296     }
1297
1298     # add dynamic macros where possible
1299     if user.account:
1300         account_name = user.account.get("name")
1301         if account_name:
1302             macros["account"] = account_name
1303     if user.avatar:
1304         avatar_gender = user.avatar.get("gender")
1305         if avatar_gender:
1306             macros["tpop"] = pronouns[avatar_gender]["obj"]
1307             macros["tppp"] = pronouns[avatar_gender]["pos"]
1308             macros["tpsp"] = pronouns[avatar_gender]["sub"]
1309
1310     # loop until broken
1311     while True:
1312
1313         # find and replace per the macros dict
1314         macro_start = text.find("$(")
1315         if macro_start == -1:
1316             break
1317         macro_end = text.find(")", macro_start) + 1
1318         macro = text[macro_start + 2:macro_end - 1]
1319         if macro in macros.keys():
1320             replacement = macros[macro]
1321
1322         # this is how we handle local file inclusion (dangerous!)
1323         elif macro.startswith("inc:"):
1324             incfile = mudpy.data.find_file(macro[4:], universe=universe)
1325             if os.path.exists(incfile):
1326                 incfd = codecs.open(incfile, "r", "utf-8")
1327                 replacement = ""
1328                 for line in incfd:
1329                     if line.endswith("\n") and not line.endswith("\r\n"):
1330                         line = line.replace("\n", "\r\n")
1331                     replacement += line
1332                 # lose the trailing eol
1333                 replacement = replacement[:-2]
1334             else:
1335                 replacement = ""
1336                 log("Couldn't read included " + incfile + " file.", 7)
1337
1338         # if we get here, log and replace it with null
1339         else:
1340             replacement = ""
1341             if not is_input:
1342                 log("Unexpected replacement macro " +
1343                     macro + " encountered.", 6)
1344
1345         # and now we act on the replacement
1346         text = text.replace("$(" + macro + ")", replacement)
1347
1348     # replace the look-like-a-macro sequence
1349     text = text.replace("$_(", "$(")
1350
1351     return text
1352
1353
1354 def escape_macros(value):
1355     """Escapes replacement macros in text."""
1356     if type(value) is str:
1357         return value.replace("$(", "$_(")
1358     else:
1359         return value
1360
1361
1362 def first_word(text, separator=" "):
1363     """Returns a tuple of the first word and the rest."""
1364     if text:
1365         if text.find(separator) > 0:
1366             return text.split(separator, 1)
1367         else:
1368             return text, ""
1369     else:
1370         return "", ""
1371
1372
1373 def on_pulse():
1374     """The things which should happen on each pulse, aside from reloads."""
1375
1376     # open the listening socket if it hasn't been already
1377     if not hasattr(universe, "listening_socket"):
1378         universe.initialize_server_socket()
1379
1380     # assign a user if a new connection is waiting
1381     user = check_for_connection(universe.listening_socket)
1382     if user:
1383         universe.userlist.append(user)
1384
1385     # iterate over the connected users
1386     for user in universe.userlist:
1387         user.pulse()
1388
1389     # add an element for counters if it doesn't exist
1390     if "counters" not in universe.groups.get("internal", {}):
1391         Element("internal.counters", universe)
1392
1393     # update the log every now and then
1394     if not universe.groups["internal"]["counters"].get("mark"):
1395         log(str(len(universe.userlist)) + " connection(s)")
1396         universe.groups["internal"]["counters"].set(
1397             "mark", universe.contents["mudpy.timing"].get("status")
1398         )
1399     else:
1400         universe.groups["internal"]["counters"].set(
1401             "mark", universe.groups["internal"]["counters"].get(
1402                 "mark"
1403             ) - 1
1404         )
1405
1406     # periodically save everything
1407     if not universe.groups["internal"]["counters"].get("save"):
1408         universe.save()
1409         universe.groups["internal"]["counters"].set(
1410             "save", universe.contents["mudpy.timing"].get("save")
1411         )
1412     else:
1413         universe.groups["internal"]["counters"].set(
1414             "save", universe.groups["internal"]["counters"].get(
1415                 "save"
1416             ) - 1
1417         )
1418
1419     # pause for a configurable amount of time (decimal seconds)
1420     time.sleep(universe.contents["mudpy.timing"].get("increment"))
1421
1422     # increase the elapsed increment counter
1423     universe.groups["internal"]["counters"].set(
1424         "elapsed", universe.groups["internal"]["counters"].get(
1425             "elapsed", 0
1426         ) + 1
1427     )
1428
1429
1430 def reload_data():
1431     """Reload all relevant objects."""
1432     universe.save()
1433     old_userlist = universe.userlist[:]
1434     for element in list(universe.contents.values()):
1435         element.destroy()
1436     universe.load()
1437     for user in old_userlist:
1438         user.reload()
1439
1440
1441 def check_for_connection(listening_socket):
1442     """Check for a waiting connection and return a new user object."""
1443
1444     # try to accept a new connection
1445     try:
1446         connection, address = listening_socket.accept()
1447     except BlockingIOError:
1448         return None
1449
1450     # note that we got one
1451     log("New connection from %s." % address[0], 2)
1452
1453     # disable blocking so we can proceed whether or not we can send/receive
1454     connection.setblocking(0)
1455
1456     # create a new user object
1457     user = User()
1458     log("Instantiated %s for %s." % (user, address[0]), 0)
1459
1460     # associate this connection with it
1461     user.connection = connection
1462
1463     # set the user's ipa from the connection's ipa
1464     user.address = address[0]
1465
1466     # let the client know we WILL EOR (RFC 885)
1467     mudpy.telnet.enable(user, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US)
1468     user.negotiation_pause = 2
1469
1470     # return the new user object
1471     return user
1472
1473
1474 def find_command(command_name):
1475     """Try to find a command by name or abbreviation."""
1476
1477     # lowercase the command
1478     command_name = command_name.lower()
1479
1480     command = None
1481     if command_name in universe.groups["command"]:
1482         # the command matches a command word for which we have data
1483         command = universe.groups["command"][command_name]
1484     else:
1485         for candidate in sorted(universe.groups["command"]):
1486             if candidate.startswith(command_name) and not universe.groups[
1487                     "command"][candidate].get("administrative"):
1488                 # the command matches the start of a command word and is not
1489                 # restricted to administrators
1490                 command = universe.groups["command"][candidate]
1491                 break
1492     return command
1493
1494
1495 def get_menu(state, error=None, choices=None):
1496     """Show the correct menu text to a user."""
1497
1498     # make sure we don't reuse a mutable sequence by default
1499     if choices is None:
1500         choices = {}
1501
1502     # get the description or error text
1503     message = get_menu_description(state, error)
1504
1505     # get menu choices for the current state
1506     message += get_formatted_menu_choices(state, choices)
1507
1508     # try to get a prompt, if it was defined
1509     message += get_menu_prompt(state)
1510
1511     # throw in the default choice, if it exists
1512     message += get_formatted_default_menu_choice(state)
1513
1514     # display a message indicating if echo is off
1515     message += get_echo_message(state)
1516
1517     # return the assembly of various strings defined above
1518     return message
1519
1520
1521 def menu_echo_on(state):
1522     """True if echo is on, false if it is off."""
1523     return universe.groups["menu"][state].get("echo", True)
1524
1525
1526 def get_echo_message(state):
1527     """Return a message indicating that echo is off."""
1528     if menu_echo_on(state):
1529         return ""
1530     else:
1531         return "(won't echo) "
1532
1533
1534 def get_default_menu_choice(state):
1535     """Return the default choice for a menu."""
1536     return universe.groups["menu"][state].get("default")
1537
1538
1539 def get_formatted_default_menu_choice(state):
1540     """Default menu choice foratted for inclusion in a prompt string."""
1541     default_choice = get_default_menu_choice(state)
1542     if default_choice:
1543         return "[$(red)" + default_choice + "$(nrm)] "
1544     else:
1545         return ""
1546
1547
1548 def get_menu_description(state, error):
1549     """Get the description or error text."""
1550
1551     # an error condition was raised by the handler
1552     if error:
1553
1554         # try to get an error message matching the condition
1555         # and current state
1556         description = universe.groups[
1557             "menu"][state].get("error_" + error)
1558         if not description:
1559             description = "That is not a valid choice..."
1560         description = "$(red)" + description + "$(nrm)"
1561
1562     # there was no error condition
1563     else:
1564
1565         # try to get a menu description for the current state
1566         description = universe.groups["menu"][state].get("description")
1567
1568     # return the description or error message
1569     if description:
1570         description += "$(eol)$(eol)"
1571     return description
1572
1573
1574 def get_menu_prompt(state):
1575     """Try to get a prompt, if it was defined."""
1576     prompt = universe.groups["menu"][state].get("prompt")
1577     if prompt:
1578         prompt += " "
1579     return prompt
1580
1581
1582 def get_menu_choices(user):
1583     """Return a dict of choice:meaning."""
1584     menu = universe.groups["menu"][user.state]
1585     create_choices = menu.get("create")
1586     if create_choices:
1587         choices = eval(create_choices)
1588     else:
1589         choices = {}
1590     ignores = []
1591     options = {}
1592     creates = {}
1593     for facet in menu.facets():
1594         if facet.startswith("demand_") and not eval(
1595            universe.groups["menu"][user.state].get(facet)
1596            ):
1597             ignores.append(facet.split("_", 2)[1])
1598         elif facet.startswith("create_"):
1599             creates[facet] = facet.split("_", 2)[1]
1600         elif facet.startswith("choice_"):
1601             options[facet] = facet.split("_", 2)[1]
1602     for facet in creates.keys():
1603         if not creates[facet] in ignores:
1604             choices[creates[facet]] = eval(menu.get(facet))
1605     for facet in options.keys():
1606         if not options[facet] in ignores:
1607             choices[options[facet]] = menu.get(facet)
1608     return choices
1609
1610
1611 def get_formatted_menu_choices(state, choices):
1612     """Returns a formatted string of menu choices."""
1613     choice_output = ""
1614     choice_keys = list(choices.keys())
1615     choice_keys.sort()
1616     for choice in choice_keys:
1617         choice_output += "   [$(red)" + choice + "$(nrm)]  " + choices[
1618             choice
1619         ] + "$(eol)"
1620     if choice_output:
1621         choice_output += "$(eol)"
1622     return choice_output
1623
1624
1625 def get_menu_branches(state):
1626     """Return a dict of choice:branch."""
1627     branches = {}
1628     for facet in universe.groups["menu"][state].facets():
1629         if facet.startswith("branch_"):
1630             branches[
1631                 facet.split("_", 2)[1]
1632             ] = universe.groups["menu"][state].get(facet)
1633     return branches
1634
1635
1636 def get_default_branch(state):
1637     """Return the default branch."""
1638     return universe.groups["menu"][state].get("branch")
1639
1640
1641 def get_choice_branch(user, choice):
1642     """Returns the new state matching the given choice."""
1643     branches = get_menu_branches(user.state)
1644     if choice in branches.keys():
1645         return branches[choice]
1646     elif choice in user.menu_choices.keys():
1647         return get_default_branch(user.state)
1648     else:
1649         return ""
1650
1651
1652 def get_menu_actions(state):
1653     """Return a dict of choice:branch."""
1654     actions = {}
1655     for facet in universe.groups["menu"][state].facets():
1656         if facet.startswith("action_"):
1657             actions[
1658                 facet.split("_", 2)[1]
1659             ] = universe.groups["menu"][state].get(facet)
1660     return actions
1661
1662
1663 def get_default_action(state):
1664     """Return the default action."""
1665     return universe.groups["menu"][state].get("action")
1666
1667
1668 def get_choice_action(user, choice):
1669     """Run any indicated script for the given choice."""
1670     actions = get_menu_actions(user.state)
1671     if choice in actions.keys():
1672         return actions[choice]
1673     elif choice in user.menu_choices.keys():
1674         return get_default_action(user.state)
1675     else:
1676         return ""
1677
1678
1679 def handle_user_input(user):
1680     """The main handler, branches to a state-specific handler."""
1681
1682     # if the user's client echo is off, send a blank line for aesthetics
1683     if mudpy.telnet.is_enabled(user, mudpy.telnet.TELOPT_ECHO,
1684                                mudpy.telnet.US):
1685         user.send("", add_prompt=False, prepend_padding=False)
1686
1687     # check to make sure the state is expected, then call that handler
1688     if "handler_" + user.state in globals():
1689         exec("handler_" + user.state + "(user)")
1690     else:
1691         generic_menu_handler(user)
1692
1693     # since we got input, flag that the menu/prompt needs to be redisplayed
1694     user.menu_seen = False
1695
1696     # update the last_input timestamp while we're at it
1697     user.last_input = universe.get_time()
1698
1699
1700 def generic_menu_handler(user):
1701     """A generic menu choice handler."""
1702
1703     # get a lower-case representation of the next line of input
1704     if user.input_queue:
1705         choice = user.input_queue.pop(0)
1706         if choice:
1707             choice = choice.lower()
1708     else:
1709         choice = ""
1710     if not choice:
1711         choice = get_default_menu_choice(user.state)
1712     if choice in user.menu_choices:
1713         exec(get_choice_action(user, choice))
1714         new_state = get_choice_branch(user, choice)
1715         if new_state:
1716             user.state = new_state
1717     else:
1718         user.error = "default"
1719
1720
1721 def handler_entering_account_name(user):
1722     """Handle the login account name."""
1723
1724     # get the next waiting line of input
1725     input_data = user.input_queue.pop(0)
1726
1727     # did the user enter anything?
1728     if input_data:
1729
1730         # keep only the first word and convert to lower-case
1731         name = input_data.lower()
1732
1733         # fail if there are non-alphanumeric characters
1734         if name != "".join(filter(
1735                 lambda x: x >= "0" and x <= "9" or x >= "a" and x <= "z",
1736                 name)):
1737             user.error = "bad_name"
1738
1739         # if that account exists, time to request a password
1740         elif name in universe.groups.get("account", {}):
1741             user.account = universe.groups["account"][name]
1742             user.state = "checking_password"
1743
1744         # otherwise, this could be a brand new user
1745         else:
1746             user.account = Element("account.%s" % name, universe)
1747             user.account.set("name", name)
1748             log("New user: " + name, 2)
1749             user.state = "checking_new_account_name"
1750
1751     # if the user entered nothing for a name, then buhbye
1752     else:
1753         user.state = "disconnecting"
1754
1755
1756 def handler_checking_password(user):
1757     """Handle the login account password."""
1758
1759     # get the next waiting line of input
1760     input_data = user.input_queue.pop(0)
1761
1762     if "mudpy.limit" in universe.contents:
1763         max_password_tries = universe.contents["mudpy.limit"].get(
1764             "password_tries", 3)
1765     else:
1766         max_password_tries = 3
1767
1768     # does the hashed input equal the stored hash?
1769     if mudpy.password.verify(input_data, user.account.get("passhash")):
1770
1771         # if so, set the username and load from cold storage
1772         if not user.replace_old_connections():
1773             user.authenticate()
1774             user.state = "main_utility"
1775
1776     # if at first your hashes don't match, try, try again
1777     elif user.password_tries < max_password_tries - 1:
1778         user.password_tries += 1
1779         user.error = "incorrect"
1780
1781     # we've exceeded the maximum number of password failures, so disconnect
1782     else:
1783         user.send(
1784             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1785         )
1786         user.state = "disconnecting"
1787
1788
1789 def handler_entering_new_password(user):
1790     """Handle a new password entry."""
1791
1792     # get the next waiting line of input
1793     input_data = user.input_queue.pop(0)
1794
1795     if "mudpy.limit" in universe.contents:
1796         max_password_tries = universe.contents["mudpy.limit"].get(
1797             "password_tries", 3)
1798     else:
1799         max_password_tries = 3
1800
1801     # make sure the password is strong--at least one upper, one lower and
1802     # one digit, seven or more characters in length
1803     if len(input_data) > 6 and len(
1804        list(filter(lambda x: x >= "0" and x <= "9", input_data))
1805        ) and len(
1806         list(filter(lambda x: x >= "A" and x <= "Z", input_data))
1807     ) and len(
1808         list(filter(lambda x: x >= "a" and x <= "z", input_data))
1809     ):
1810
1811         # hash and store it, then move on to verification
1812         user.account.set("passhash", mudpy.password.create(input_data))
1813         user.state = "verifying_new_password"
1814
1815     # the password was weak, try again if you haven't tried too many times
1816     elif user.password_tries < max_password_tries - 1:
1817         user.password_tries += 1
1818         user.error = "weak"
1819
1820     # too many tries, so adios
1821     else:
1822         user.send(
1823             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1824         )
1825         user.account.destroy()
1826         user.state = "disconnecting"
1827
1828
1829 def handler_verifying_new_password(user):
1830     """Handle the re-entered new password for verification."""
1831
1832     # get the next waiting line of input
1833     input_data = user.input_queue.pop(0)
1834
1835     if "mudpy.limit" in universe.contents:
1836         max_password_tries = universe.contents["mudpy.limit"].get(
1837             "password_tries", 3)
1838     else:
1839         max_password_tries = 3
1840
1841     # hash the input and match it to storage
1842     if mudpy.password.verify(input_data, user.account.get("passhash")):
1843         user.authenticate()
1844
1845         # the hashes matched, so go active
1846         if not user.replace_old_connections():
1847             user.state = "main_utility"
1848
1849     # go back to entering the new password as long as you haven't tried
1850     # too many times
1851     elif user.password_tries < max_password_tries - 1:
1852         user.password_tries += 1
1853         user.error = "differs"
1854         user.state = "entering_new_password"
1855
1856     # otherwise, sayonara
1857     else:
1858         user.send(
1859             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1860         )
1861         user.account.destroy()
1862         user.state = "disconnecting"
1863
1864
1865 def handler_active(user):
1866     """Handle input for active users."""
1867
1868     # get the next waiting line of input
1869     input_data = user.input_queue.pop(0)
1870
1871     # is there input?
1872     if input_data:
1873
1874         # split out the command and parameters
1875         actor = user.avatar
1876         mode = actor.get("mode")
1877         if mode and input_data.startswith("!"):
1878             command_name, parameters = first_word(input_data[1:])
1879         elif mode == "chat":
1880             command_name = "say"
1881             parameters = input_data
1882         else:
1883             command_name, parameters = first_word(input_data)
1884
1885         # expand to an actual command
1886         command = find_command(command_name)
1887
1888         # if it's allowed, do it
1889         ran = False
1890         if actor.can_run(command):
1891             # dereference the relative object path for the requested function
1892             action = mudpy
1893             action_fname = command.get("action", command.key)
1894             for component in action_fname.split("."):
1895                 try:
1896                     action = getattr(action, component)
1897                     ran = True
1898                 except AttributeError:
1899                     log('Could not find action function "%s" for command "%s"'
1900                         % (action_fname, command_name))
1901                     action = None
1902                     break
1903             if action:
1904                 try:
1905                     action(actor, parameters)
1906                 except Exception:
1907                     log('Command string "%s" from user %s raised an '
1908                         'exception...\n%s' % (
1909                             input_data, actor.owner.account.get("name"),
1910                             traceback.format_exc()))
1911                     mudpy.command.error(actor, input_data)
1912
1913         # if the command was not run, give an error
1914         if not ran:
1915             mudpy.command.error(actor, input_data)
1916
1917     # if no input, just idle back with a prompt
1918     else:
1919         user.send("", just_prompt=True)
1920
1921
1922 def daemonize(universe):
1923     """Fork and disassociate from everything."""
1924
1925     # only if this is what we're configured to do
1926     if "mudpy.process" in universe.contents and universe.contents[
1927             "mudpy.process"].get("daemon"):
1928
1929         # log before we start forking around, so the terminal gets the message
1930         log("Disassociating from the controlling terminal.")
1931
1932         # fork off and die, so we free up the controlling terminal
1933         if os.fork():
1934             os._exit(0)
1935
1936         # switch to a new process group
1937         os.setsid()
1938
1939         # fork some more, this time to free us from the old process group
1940         if os.fork():
1941             os._exit(0)
1942
1943         # reset the working directory so we don't needlessly tie up mounts
1944         os.chdir("/")
1945
1946         # clear the file creation mask so we can bend it to our will later
1947         os.umask(0)
1948
1949         # redirect stdin/stdout/stderr and close off their former descriptors
1950         for stdpipe in range(3):
1951             os.close(stdpipe)
1952         sys.stdin = codecs.open("/dev/null", "r", "utf-8")
1953         sys.__stdin__ = codecs.open("/dev/null", "r", "utf-8")
1954         sys.stdout = codecs.open("/dev/null", "w", "utf-8")
1955         sys.stderr = codecs.open("/dev/null", "w", "utf-8")
1956         sys.__stdout__ = codecs.open("/dev/null", "w", "utf-8")
1957         sys.__stderr__ = codecs.open("/dev/null", "w", "utf-8")
1958
1959
1960 def create_pidfile(universe):
1961     """Write a file containing the current process ID."""
1962     pid = str(os.getpid())
1963     log("Process ID: " + pid)
1964     if "mudpy.process" in universe.contents:
1965         file_name = universe.contents["mudpy.process"].get("pidfile", "")
1966     else:
1967         file_name = ""
1968     if file_name:
1969         if not os.path.isabs(file_name):
1970             file_name = os.path.join(universe.startdir, file_name)
1971         os.makedirs(os.path.dirname(file_name), exist_ok=True)
1972         file_descriptor = codecs.open(file_name, "w", "utf-8")
1973         file_descriptor.write(pid + "\n")
1974         file_descriptor.flush()
1975         file_descriptor.close()
1976
1977
1978 def remove_pidfile(universe):
1979     """Remove the file containing the current process ID."""
1980     if "mudpy.process" in universe.contents:
1981         file_name = universe.contents["mudpy.process"].get("pidfile", "")
1982     else:
1983         file_name = ""
1984     if file_name:
1985         if not os.path.isabs(file_name):
1986             file_name = os.path.join(universe.startdir, file_name)
1987         if os.access(file_name, os.W_OK):
1988             os.remove(file_name)
1989
1990
1991 def excepthook(excepttype, value, tracebackdata):
1992     """Handle uncaught exceptions."""
1993
1994     # assemble the list of errors into a single string
1995     message = "".join(
1996         traceback.format_exception(excepttype, value, tracebackdata)
1997     )
1998
1999     # try to log it, if possible
2000     try:
2001         log(message, 9)
2002     except Exception as e:
2003         # try to write it to stderr, if possible
2004         sys.stderr.write(message + "\nException while logging...\n%s" % e)
2005
2006
2007 def sighook(what, where):
2008     """Handle external signals."""
2009
2010     # a generic message
2011     message = "Caught signal: "
2012
2013     # for a hangup signal
2014     if what == signal.SIGHUP:
2015         message += "hangup (reloading)"
2016         universe.reload_flag = True
2017
2018     # for a terminate signal
2019     elif what == signal.SIGTERM:
2020         message += "terminate (halting)"
2021         universe.terminate_flag = True
2022
2023     # catchall for unexpected signals
2024     else:
2025         message += str(what) + " (unhandled)"
2026
2027     # log what happened
2028     log(message, 8)
2029
2030
2031 def override_excepthook():
2032     """Redefine sys.excepthook with our own."""
2033     sys.excepthook = excepthook
2034
2035
2036 def assign_sighook():
2037     """Assign a customized handler for some signals."""
2038     signal.signal(signal.SIGHUP, sighook)
2039     signal.signal(signal.SIGTERM, sighook)
2040
2041
2042 def setup():
2043     """This contains functions to be performed when starting the engine."""
2044
2045     # see if a configuration file was specified
2046     if len(sys.argv) > 1:
2047         conffile = sys.argv[1]
2048     else:
2049         conffile = ""
2050
2051     # the big bang
2052     global universe
2053     universe = Universe(conffile, True)
2054
2055     # report any loglines which accumulated during setup
2056     for logline in universe.setup_loglines:
2057         log(*logline)
2058     universe.setup_loglines = []
2059
2060     # fork and disassociate
2061     daemonize(universe)
2062
2063     # override the default exception handler so we get logging first thing
2064     override_excepthook()
2065
2066     # set up custom signal handlers
2067     assign_sighook()
2068
2069     # make the pidfile
2070     create_pidfile(universe)
2071
2072     # load and store diagnostic info
2073     universe.versions = mudpy.version.Versions("mudpy")
2074
2075     # log startup diagnostic messages
2076     log("On %s at %s" % (universe.versions.python_version, sys.executable), 1)
2077     log("Import path: %s" % ", ".join(sys.path), 1)
2078     log("Installed dependencies: %s" % universe.versions.dependencies_text, 1)
2079     log("Other python packages: %s" % universe.versions.environment_text, 1)
2080     log("Running version: %s" % universe.versions.version, 1)
2081     log("Initial directory: %s" % universe.startdir, 1)
2082     log("Command line: %s" % " ".join(sys.argv), 1)
2083
2084     # pass the initialized universe back
2085     return universe
2086
2087
2088 def finish():
2089     """These are functions performed when shutting down the engine."""
2090
2091     # the loop has terminated, so save persistent data
2092     universe.save()
2093
2094     # log a final message
2095     log("Shutting down now.")
2096
2097     # get rid of the pidfile
2098     remove_pidfile(universe)