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