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