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