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