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