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