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