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