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