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