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