Use the active state prompt as the default prompt
[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
667         # Start with the user's preference, if one was provided
668         prompt = self.account.get("prompt")
669
670         # If the user has not set a prompt, then immediately return the default
671         # provided for the current state
672         if not prompt:
673             return get_menu_prompt(self.state)
674
675         # Return the cooked prompt
676         return "%s " % prompt
677
678     def adjust_echoing(self):
679         """Adjust echoing to match state menu requirements."""
680         if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_ECHO,
681                                    mudpy.telnet.US):
682             if menu_echo_on(self.state):
683                 mudpy.telnet.disable(self, mudpy.telnet.TELOPT_ECHO,
684                                      mudpy.telnet.US)
685         elif not menu_echo_on(self.state):
686             mudpy.telnet.enable(self, mudpy.telnet.TELOPT_ECHO,
687                                 mudpy.telnet.US)
688
689     def remove(self):
690         """Remove a user from the list of connected users."""
691         log("Disconnecting account %s." % self, 0)
692         universe.userlist.remove(self)
693
694     def send(
695         self,
696         output,
697         eol="$(eol)",
698         raw=False,
699         flush=False,
700         add_prompt=True,
701         just_prompt=False,
702         add_terminator=False,
703         prepend_padding=True
704     ):
705         """Send arbitrary text to a connected user."""
706
707         # unless raw mode is on, clean it up all nice and pretty
708         if not raw:
709
710             # strip extra $(eol) off if present
711             while output.startswith("$(eol)"):
712                 output = output[6:]
713             while output.endswith("$(eol)"):
714                 output = output[:-6]
715             extra_lines = output.find("$(eol)$(eol)$(eol)")
716             while extra_lines > -1:
717                 output = output[:extra_lines] + output[extra_lines + 6:]
718                 extra_lines = output.find("$(eol)$(eol)$(eol)")
719
720             # start with a newline, append the message, then end
721             # with the optional eol string passed to this function
722             # and the ansi escape to return to normal text
723             if not just_prompt and prepend_padding:
724                 if (not self.output_queue or not
725                         self.output_queue[-1].endswith(b"\r\n")):
726                     output = "$(eol)" + output
727                 elif not self.output_queue[-1].endswith(
728                     b"\r\n\x1b[0m\r\n"
729                 ) and not self.output_queue[-1].endswith(
730                     b"\r\n\r\n"
731                 ):
732                     output = "$(eol)" + output
733             output += eol + chr(27) + "[0m"
734
735             # tack on a prompt if active
736             if self.state == "active":
737                 if not just_prompt:
738                     output += "$(eol)"
739                 if add_prompt:
740                     output += self.prompt()
741                     mode = self.avatar.get("mode")
742                     if mode:
743                         output += "(" + mode + ") "
744
745             # find and replace macros in the output
746             output = replace_macros(self, output)
747
748             # wrap the text at the client's width (min 40, 0 disables)
749             if self.columns:
750                 if self.columns < 40:
751                     wrap = 40
752                 else:
753                     wrap = self.columns
754                 output = wrap_ansi_text(output, wrap)
755
756             # if supported by the client, encode it utf-8
757             if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
758                                        mudpy.telnet.US):
759                 encoded_output = output.encode("utf-8")
760
761             # otherwise just send ascii
762             else:
763                 encoded_output = output.encode("ascii", "replace")
764
765             # end with a terminator if requested
766             if add_prompt or add_terminator:
767                 if mudpy.telnet.is_enabled(
768                         self, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US):
769                     encoded_output += mudpy.telnet.telnet_proto(
770                         mudpy.telnet.IAC, mudpy.telnet.EOR)
771                 elif not mudpy.telnet.is_enabled(
772                         self, mudpy.telnet.TELOPT_SGA, mudpy.telnet.US):
773                     encoded_output += mudpy.telnet.telnet_proto(
774                         mudpy.telnet.IAC, mudpy.telnet.GA)
775
776             # and tack it onto the queue
777             self.output_queue.append(encoded_output)
778
779             # if this is urgent, flush all pending output
780             if flush:
781                 self.flush()
782
783         # just dump raw bytes as requested
784         else:
785             self.output_queue.append(output)
786             self.flush()
787
788     def pulse(self):
789         """All the things to do to the user per increment."""
790
791         # if the world is terminating, disconnect
792         if universe.terminate_flag:
793             self.state = "disconnecting"
794             self.menu_seen = False
795
796         # check for an idle connection and act appropriately
797         else:
798             self.check_idle()
799
800         # if output is paused, decrement the counter
801         if self.state == "telopt_negotiation":
802             if self.negotiation_pause:
803                 self.negotiation_pause -= 1
804             else:
805                 self.state = "entering_account_name"
806
807         # show the user a menu as needed
808         elif not self.state == "active":
809             self.show_menu()
810
811         # flush any pending output in the queue
812         self.flush()
813
814         # disconnect users with the appropriate state
815         if self.state == "disconnecting":
816             self.quit()
817
818         # check for input and add it to the queue
819         self.enqueue_input()
820
821         # there is input waiting in the queue
822         if self.input_queue:
823             handle_user_input(self)
824
825     def flush(self):
826         """Try to send the last item in the queue and remove it."""
827         if self.output_queue:
828             try:
829                 self.connection.send(self.output_queue[0])
830             except (BrokenPipeError, ConnectionResetError):
831                 if self.account and self.account.get("name"):
832                     account = self.account.get("name")
833                 else:
834                     account = "an unknown user"
835                 self.state = "disconnecting"
836                 log("Disconnected while sending to %s." % account, 7)
837             del self.output_queue[0]
838
839     def enqueue_input(self):
840         """Process and enqueue any new input."""
841
842         # check for some input
843         try:
844             raw_input = self.connection.recv(1024)
845         except (BlockingIOError, OSError):
846             raw_input = b""
847
848         # we got something
849         if raw_input:
850
851             # tack this on to any previous partial
852             self.partial_input += raw_input
853
854             # reply to and remove any IAC negotiation codes
855             mudpy.telnet.negotiate_telnet_options(self)
856
857             # separate multiple input lines
858             new_input_lines = self.partial_input.split(b"\r\0")
859             if len(new_input_lines) == 1:
860                 new_input_lines = new_input_lines[0].split(b"\r\n")
861
862             # if input doesn't end in a newline, replace the
863             # held partial input with the last line of it
864             if not (
865                     self.partial_input.endswith(b"\r\0") or
866                     self.partial_input.endswith(b"\r\n")):
867                 self.partial_input = new_input_lines.pop()
868
869             # otherwise, chop off the extra null input and reset
870             # the held partial input
871             else:
872                 new_input_lines.pop()
873                 self.partial_input = b""
874
875             # iterate over the remaining lines
876             for line in new_input_lines:
877
878                 # strip off extra whitespace
879                 line = line.strip()
880
881                 # log non-printable characters remaining
882                 if not mudpy.telnet.is_enabled(
883                         self, mudpy.telnet.TELOPT_BINARY, mudpy.telnet.HIM):
884                     asciiline = bytes([x for x in line if 32 <= x <= 126])
885                     if line != asciiline:
886                         logline = "Non-ASCII characters from "
887                         if self.account and self.account.get("name"):
888                             logline += self.account.get("name") + ": "
889                         else:
890                             logline += "unknown user: "
891                         logline += repr(line)
892                         log(logline, 4)
893                         line = asciiline
894
895                 try:
896                     line = line.decode("utf-8")
897                 except UnicodeDecodeError:
898                     logline = "Non-UTF-8 sequence from "
899                     if self.account and self.account.get("name"):
900                         logline += self.account.get("name") + ": "
901                     else:
902                         logline += "unknown user: "
903                     logline += repr(line)
904                     log(logline, 4)
905                     return
906
907                 line = unicodedata.normalize("NFKC", line)
908
909                 # put on the end of the queue
910                 self.input_queue.append(line)
911
912     def new_avatar(self):
913         """Instantiate a new, unconfigured avatar for this user."""
914         counter = 0
915         while ("avatar_%s_%s" % (self.account.get("name"), counter)
916                 in universe.groups.get("actor", {}).keys()):
917             counter += 1
918         self.avatar = Element(
919             "actor.avatar_%s_%s" % (self.account.get("name"), counter),
920             universe)
921         self.avatar.append("inherit", "archetype.avatar")
922         self.account.append("avatars", self.avatar.key)
923         log("Created new avatar %s for user %s." % (
924                 self.avatar.key, self.account.get("name")), 0)
925
926     def delete_avatar(self, avatar):
927         """Remove an avatar from the world and from the user's list."""
928         if self.avatar is universe.contents[avatar]:
929             self.avatar = None
930         log("Deleting avatar %s for user %s." % (
931                 avatar, self.account.get("name")), 0)
932         universe.contents[avatar].destroy()
933         avatars = self.account.get("avatars")
934         avatars.remove(avatar)
935         self.account.set("avatars", avatars)
936
937     def activate_avatar_by_index(self, index):
938         """Enter the world with a particular indexed avatar."""
939         self.avatar = universe.contents[
940             self.account.get("avatars")[index]]
941         self.avatar.owner = self
942         self.state = "active"
943         log("Activated avatar %s (%s)." % (
944                 self.avatar.get("name"), self.avatar.key), 0)
945         self.avatar.go_home()
946
947     def deactivate_avatar(self):
948         """Have the active avatar leave the world."""
949         if self.avatar:
950             log("Deactivating avatar %s (%s) for user %s." % (
951                     self.avatar.get("name"), self.avatar.key,
952                     self.account.get("name")), 0)
953             current = self.avatar.get("location")
954             if current:
955                 self.avatar.set("default_location", current)
956                 self.avatar.echo_to_location(
957                     "You suddenly wonder where " + self.avatar.get(
958                         "name"
959                     ) + " went."
960                 )
961                 del universe.contents[current].contents[self.avatar.key]
962                 self.avatar.remove_facet("location")
963             self.avatar.owner = None
964             self.avatar = None
965
966     def destroy(self):
967         """Destroy the user and associated avatars."""
968         for avatar in self.account.get("avatars"):
969             self.delete_avatar(avatar)
970         log("Destroying account %s for user %s." % (
971                 self.account.get("name"), self), 0)
972         self.account.destroy()
973
974     def list_avatar_names(self):
975         """List names of assigned avatars."""
976         avatars = []
977         for avatar in self.account.get("avatars"):
978             try:
979                 avatars.append(universe.contents[avatar].get("name"))
980             except KeyError:
981                 log('Missing avatar "%s", possible data corruption.' %
982                     avatar, 6)
983         return avatars
984
985
986 def broadcast(message, add_prompt=True):
987     """Send a message to all connected users."""
988     for each_user in universe.userlist:
989         each_user.send("$(eol)" + message, add_prompt=add_prompt)
990
991
992 def log(message, level=0):
993     """Log a message."""
994
995     # a couple references we need
996     if "mudpy.log" in universe.contents:
997         file_name = universe.contents["mudpy.log"].get("file", "")
998         max_log_lines = universe.contents["mudpy.log"].get("lines", 0)
999         syslog_name = universe.contents["mudpy.log"].get("syslog", "")
1000     else:
1001         file_name = ""
1002         max_log_lines = 0
1003         syslog_name = ""
1004     timestamp = datetime.datetime.now().isoformat(' ')
1005
1006     # turn the message into a list of nonempty lines
1007     lines = [x for x in [(x.rstrip()) for x in message.split("\n")] if x != ""]
1008
1009     # send the timestamp and line to a file
1010     if file_name:
1011         if not os.path.isabs(file_name):
1012             file_name = os.path.join(universe.startdir, file_name)
1013         os.makedirs(os.path.dirname(file_name), exist_ok=True)
1014         file_descriptor = codecs.open(file_name, "a", "utf-8")
1015         for line in lines:
1016             file_descriptor.write(timestamp + " " + line + "\n")
1017         file_descriptor.flush()
1018         file_descriptor.close()
1019
1020     # send the timestamp and line to standard output
1021     if ("mudpy.log" in universe.contents and
1022             universe.contents["mudpy.log"].get("stdout")):
1023         for line in lines:
1024             print(timestamp + " " + line)
1025
1026     # send the line to the system log
1027     if syslog_name:
1028         syslog.openlog(
1029             syslog_name.encode("utf-8"),
1030             syslog.LOG_PID,
1031             syslog.LOG_INFO | syslog.LOG_DAEMON
1032         )
1033         for line in lines:
1034             syslog.syslog(line)
1035         syslog.closelog()
1036
1037     # display to connected administrators
1038     for user in universe.userlist:
1039         if user.state == "active" and user.account.get(
1040            "administrator"
1041            ) and user.account.get("loglevel", 0) <= level:
1042             # iterate over every line in the message
1043             full_message = ""
1044             for line in lines:
1045                 full_message += (
1046                     "$(bld)$(red)" + timestamp + " "
1047                     + line.replace("$(", "$_(") + "$(nrm)$(eol)")
1048             user.send(full_message, flush=True)
1049
1050     # add to the recent log list
1051     for line in lines:
1052         while 0 < len(universe.loglines) >= max_log_lines:
1053             del universe.loglines[0]
1054         universe.loglines.append((level, timestamp + " " + line))
1055
1056
1057 def get_loglines(level, start, stop):
1058     """Return a specific range of loglines filtered by level."""
1059
1060     # filter the log lines
1061     loglines = [x for x in universe.loglines if x[0] >= level]
1062
1063     # we need these in several places
1064     total_count = str(len(universe.loglines))
1065     filtered_count = len(loglines)
1066
1067     # don't proceed if there are no lines
1068     if filtered_count:
1069
1070         # can't start before the begining or at the end
1071         if start > filtered_count:
1072             start = filtered_count
1073         if start < 1:
1074             start = 1
1075
1076         # can't stop before we start
1077         if stop > start:
1078             stop = start
1079         elif stop < 1:
1080             stop = 1
1081
1082         # some preamble
1083         message = "There are " + str(total_count)
1084         message += " log lines in memory and " + str(filtered_count)
1085         message += " at or above level " + str(level) + "."
1086         message += " The matching lines from " + str(stop) + " to "
1087         message += str(start) + " are:$(eol)$(eol)"
1088
1089         # add the text from the selected lines
1090         if stop > 1:
1091             range_lines = loglines[-start:-(stop - 1)]
1092         else:
1093             range_lines = loglines[-start:]
1094         for line in range_lines:
1095             message += "   (" + str(line[0]) + ") " + line[1].replace(
1096                 "$(", "$_("
1097             ) + "$(eol)"
1098
1099     # there were no lines
1100     else:
1101         message = "None of the " + str(total_count)
1102         message += " lines in memory matches your request."
1103
1104     # pass it back
1105     return message
1106
1107
1108 def glyph_columns(character):
1109     """Convenience function to return the column width of a glyph."""
1110     if unicodedata.east_asian_width(character) in "FW":
1111         return 2
1112     else:
1113         return 1
1114
1115
1116 def wrap_ansi_text(text, width):
1117     """Wrap text with arbitrary width while ignoring ANSI colors."""
1118
1119     # the current position in the entire text string, including all
1120     # characters, printable or otherwise
1121     abs_pos = 0
1122
1123     # the current text position relative to the begining of the line,
1124     # ignoring color escape sequences
1125     rel_pos = 0
1126
1127     # the absolute and relative positions of the most recent whitespace
1128     # character
1129     last_abs_whitespace = 0
1130     last_rel_whitespace = 0
1131
1132     # whether the current character is part of a color escape sequence
1133     escape = False
1134
1135     # normalize any potentially composited unicode before we count it
1136     text = unicodedata.normalize("NFKC", text)
1137
1138     # iterate over each character from the begining of the text
1139     for each_character in text:
1140
1141         # the current character is the escape character
1142         if each_character == "\x1b" and not escape:
1143             escape = True
1144             rel_pos -= 1
1145
1146         # the current character is within an escape sequence
1147         elif escape:
1148             rel_pos -= 1
1149             if each_character == "m":
1150                 # the current character is m, which terminates the
1151                 # escape sequence
1152                 escape = False
1153
1154         # the current character is a space
1155         elif each_character == " ":
1156             last_abs_whitespace = abs_pos
1157             last_rel_whitespace = rel_pos
1158
1159         # the current character is a newline, so reset the relative
1160         # position too (start a new line)
1161         elif each_character == "\n":
1162             rel_pos = 0
1163             last_abs_whitespace = abs_pos
1164             last_rel_whitespace = rel_pos
1165
1166         # the current character meets the requested maximum line width, so we
1167         # need to wrap unless the current word is wider than the terminal (in
1168         # which case we let it do the wrapping instead)
1169         if last_rel_whitespace != 0 and (rel_pos > width or (
1170                 rel_pos > width - 1 and glyph_columns(each_character) == 2)):
1171
1172             # insert an eol in place of the last space
1173             text = (text[:last_abs_whitespace] + "\r\n" +
1174                     text[last_abs_whitespace + 1:])
1175
1176             # increase the absolute position because an eol is two
1177             # characters but the space it replaced was only one
1178             abs_pos += 1
1179
1180             # now we're at the begining of a new line, plus the
1181             # number of characters wrapped from the previous line
1182             rel_pos -= last_rel_whitespace
1183             last_rel_whitespace = 0
1184
1185         # as long as the character is not a carriage return and the
1186         # other above conditions haven't been met, count it as a
1187         # printable character
1188         elif each_character != "\r":
1189             rel_pos += glyph_columns(each_character)
1190             if each_character in (" ", "\n"):
1191                 last_abs_whitespace = abs_pos
1192                 last_rel_whitespace = rel_pos
1193
1194         # increase the absolute position for every character
1195         abs_pos += 1
1196
1197     # return the newly-wrapped text
1198     return text
1199
1200
1201 def weighted_choice(data):
1202     """Takes a dict weighted by value and returns a random key."""
1203
1204     # this will hold our expanded list of keys from the data
1205     expanded = []
1206
1207     # create the expanded list of keys
1208     for key in data.keys():
1209         for _count in range(data[key]):
1210             expanded.append(key)
1211
1212     # return one at random
1213     return random.choice(expanded)
1214
1215
1216 def random_name():
1217     """Returns a random character name."""
1218
1219     # the vowels and consonants needed to create romaji syllables
1220     vowels = [
1221         "a",
1222         "i",
1223         "u",
1224         "e",
1225         "o"
1226     ]
1227     consonants = [
1228         "'",
1229         "k",
1230         "z",
1231         "s",
1232         "sh",
1233         "z",
1234         "j",
1235         "t",
1236         "ch",
1237         "ts",
1238         "d",
1239         "n",
1240         "h",
1241         "f",
1242         "m",
1243         "y",
1244         "r",
1245         "w"
1246     ]
1247
1248     # this dict will hold our weighted list of syllables
1249     syllables = {}
1250
1251     # generate the list with an even weighting
1252     for consonant in consonants:
1253         for vowel in vowels:
1254             syllables[consonant + vowel] = 1
1255
1256     # we'll build the name into this string
1257     name = ""
1258
1259     # create a name of random length from the syllables
1260     for _syllable in range(random.randrange(2, 6)):
1261         name += weighted_choice(syllables)
1262
1263     # strip any leading quotemark, capitalize and return the name
1264     return name.strip("'").capitalize()
1265
1266
1267 def replace_macros(user, text, is_input=False):
1268     """Replaces macros in text output."""
1269
1270     # third person pronouns
1271     pronouns = {
1272         "female": {"obj": "her", "pos": "hers", "sub": "she"},
1273         "male": {"obj": "him", "pos": "his", "sub": "he"},
1274         "neuter": {"obj": "it", "pos": "its", "sub": "it"}
1275     }
1276
1277     # a dict of replacement macros
1278     macros = {
1279         "eol": "\r\n",
1280         "bld": chr(27) + "[1m",
1281         "nrm": chr(27) + "[0m",
1282         "blk": chr(27) + "[30m",
1283         "blu": chr(27) + "[34m",
1284         "cyn": chr(27) + "[36m",
1285         "grn": chr(27) + "[32m",
1286         "mgt": chr(27) + "[35m",
1287         "red": chr(27) + "[31m",
1288         "yel": chr(27) + "[33m",
1289     }
1290
1291     # add dynamic macros where possible
1292     if user.account:
1293         account_name = user.account.get("name")
1294         if account_name:
1295             macros["account"] = account_name
1296     if user.avatar:
1297         avatar_gender = user.avatar.get("gender")
1298         if avatar_gender:
1299             macros["tpop"] = pronouns[avatar_gender]["obj"]
1300             macros["tppp"] = pronouns[avatar_gender]["pos"]
1301             macros["tpsp"] = pronouns[avatar_gender]["sub"]
1302
1303     # loop until broken
1304     while True:
1305
1306         # find and replace per the macros dict
1307         macro_start = text.find("$(")
1308         if macro_start == -1:
1309             break
1310         macro_end = text.find(")", macro_start) + 1
1311         macro = text[macro_start + 2:macro_end - 1]
1312         if macro in macros.keys():
1313             replacement = macros[macro]
1314
1315         # this is how we handle local file inclusion (dangerous!)
1316         elif macro.startswith("inc:"):
1317             incfile = mudpy.data.find_file(macro[4:], universe=universe)
1318             if os.path.exists(incfile):
1319                 incfd = codecs.open(incfile, "r", "utf-8")
1320                 replacement = ""
1321                 for line in incfd:
1322                     if line.endswith("\n") and not line.endswith("\r\n"):
1323                         line = line.replace("\n", "\r\n")
1324                     replacement += line
1325                 # lose the trailing eol
1326                 replacement = replacement[:-2]
1327             else:
1328                 replacement = ""
1329                 log("Couldn't read included " + incfile + " file.", 7)
1330
1331         # if we get here, log and replace it with null
1332         else:
1333             replacement = ""
1334             if not is_input:
1335                 log("Unexpected replacement macro " +
1336                     macro + " encountered.", 6)
1337
1338         # and now we act on the replacement
1339         text = text.replace("$(" + macro + ")", replacement)
1340
1341     # replace the look-like-a-macro sequence
1342     text = text.replace("$_(", "$(")
1343
1344     return text
1345
1346
1347 def escape_macros(value):
1348     """Escapes replacement macros in text."""
1349     if type(value) is str:
1350         return value.replace("$(", "$_(")
1351     else:
1352         return value
1353
1354
1355 def first_word(text, separator=" "):
1356     """Returns a tuple of the first word and the rest."""
1357     if text:
1358         if text.find(separator) > 0:
1359             return text.split(separator, 1)
1360         else:
1361             return text, ""
1362     else:
1363         return "", ""
1364
1365
1366 def on_pulse():
1367     """The things which should happen on each pulse, aside from reloads."""
1368
1369     # open the listening socket if it hasn't been already
1370     if not hasattr(universe, "listening_socket"):
1371         universe.initialize_server_socket()
1372
1373     # assign a user if a new connection is waiting
1374     user = check_for_connection(universe.listening_socket)
1375     if user:
1376         universe.userlist.append(user)
1377
1378     # iterate over the connected users
1379     for user in universe.userlist:
1380         user.pulse()
1381
1382     # add an element for counters if it doesn't exist
1383     if "counters" not in universe.groups.get("internal", {}):
1384         Element("internal.counters", universe)
1385
1386     # update the log every now and then
1387     if not universe.groups["internal"]["counters"].get("mark"):
1388         log(str(len(universe.userlist)) + " connection(s)")
1389         universe.groups["internal"]["counters"].set(
1390             "mark", universe.contents["mudpy.timing"].get("status")
1391         )
1392     else:
1393         universe.groups["internal"]["counters"].set(
1394             "mark", universe.groups["internal"]["counters"].get(
1395                 "mark"
1396             ) - 1
1397         )
1398
1399     # periodically save everything
1400     if not universe.groups["internal"]["counters"].get("save"):
1401         universe.save()
1402         universe.groups["internal"]["counters"].set(
1403             "save", universe.contents["mudpy.timing"].get("save")
1404         )
1405     else:
1406         universe.groups["internal"]["counters"].set(
1407             "save", universe.groups["internal"]["counters"].get(
1408                 "save"
1409             ) - 1
1410         )
1411
1412     # pause for a configurable amount of time (decimal seconds)
1413     time.sleep(universe.contents["mudpy.timing"].get("increment"))
1414
1415     # increase the elapsed increment counter
1416     universe.groups["internal"]["counters"].set(
1417         "elapsed", universe.groups["internal"]["counters"].get(
1418             "elapsed", 0
1419         ) + 1
1420     )
1421
1422
1423 def reload_data():
1424     """Reload all relevant objects."""
1425     universe.save()
1426     old_userlist = universe.userlist[:]
1427     for element in list(universe.contents.values()):
1428         element.destroy()
1429     universe.load()
1430     for user in old_userlist:
1431         user.reload()
1432
1433
1434 def check_for_connection(listening_socket):
1435     """Check for a waiting connection and return a new user object."""
1436
1437     # try to accept a new connection
1438     try:
1439         connection, address = listening_socket.accept()
1440     except BlockingIOError:
1441         return None
1442
1443     # note that we got one
1444     log("New connection from %s." % address[0], 2)
1445
1446     # disable blocking so we can proceed whether or not we can send/receive
1447     connection.setblocking(0)
1448
1449     # create a new user object
1450     user = User()
1451     log("Instantiated %s for %s." % (user, address[0]), 0)
1452
1453     # associate this connection with it
1454     user.connection = connection
1455
1456     # set the user's ipa from the connection's ipa
1457     user.address = address[0]
1458
1459     # let the client know we WILL EOR (RFC 885)
1460     mudpy.telnet.enable(user, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US)
1461     user.negotiation_pause = 2
1462
1463     # return the new user object
1464     return user
1465
1466
1467 def get_menu(state, error=None, choices=None):
1468     """Show the correct menu text to a user."""
1469
1470     # make sure we don't reuse a mutable sequence by default
1471     if choices is None:
1472         choices = {}
1473
1474     # get the description or error text
1475     message = get_menu_description(state, error)
1476
1477     # get menu choices for the current state
1478     message += get_formatted_menu_choices(state, choices)
1479
1480     # try to get a prompt, if it was defined
1481     message += get_menu_prompt(state)
1482
1483     # throw in the default choice, if it exists
1484     message += get_formatted_default_menu_choice(state)
1485
1486     # display a message indicating if echo is off
1487     message += get_echo_message(state)
1488
1489     # return the assembly of various strings defined above
1490     return message
1491
1492
1493 def menu_echo_on(state):
1494     """True if echo is on, false if it is off."""
1495     return universe.groups["menu"][state].get("echo", True)
1496
1497
1498 def get_echo_message(state):
1499     """Return a message indicating that echo is off."""
1500     if menu_echo_on(state):
1501         return ""
1502     else:
1503         return "(won't echo) "
1504
1505
1506 def get_default_menu_choice(state):
1507     """Return the default choice for a menu."""
1508     return universe.groups["menu"][state].get("default")
1509
1510
1511 def get_formatted_default_menu_choice(state):
1512     """Default menu choice foratted for inclusion in a prompt string."""
1513     default_choice = get_default_menu_choice(state)
1514     if default_choice:
1515         return "[$(red)" + default_choice + "$(nrm)] "
1516     else:
1517         return ""
1518
1519
1520 def get_menu_description(state, error):
1521     """Get the description or error text."""
1522
1523     # an error condition was raised by the handler
1524     if error:
1525
1526         # try to get an error message matching the condition
1527         # and current state
1528         description = universe.groups[
1529             "menu"][state].get("error_" + error)
1530         if not description:
1531             description = "That is not a valid choice..."
1532         description = "$(red)" + description + "$(nrm)"
1533
1534     # there was no error condition
1535     else:
1536
1537         # try to get a menu description for the current state
1538         description = universe.groups["menu"][state].get("description")
1539
1540     # return the description or error message
1541     if description:
1542         description += "$(eol)$(eol)"
1543     return description
1544
1545
1546 def get_menu_prompt(state):
1547     """Try to get a prompt, if it was defined."""
1548     prompt = universe.groups["menu"][state].get("prompt")
1549     if prompt:
1550         prompt += " "
1551     return prompt
1552
1553
1554 def get_menu_choices(user):
1555     """Return a dict of choice:meaning."""
1556     menu = universe.groups["menu"][user.state]
1557     create_choices = menu.get("create")
1558     if create_choices:
1559         choices = eval(create_choices)
1560     else:
1561         choices = {}
1562     ignores = []
1563     options = {}
1564     creates = {}
1565     for facet in menu.facets():
1566         if facet.startswith("demand_") and not eval(
1567            universe.groups["menu"][user.state].get(facet)
1568            ):
1569             ignores.append(facet.split("_", 2)[1])
1570         elif facet.startswith("create_"):
1571             creates[facet] = facet.split("_", 2)[1]
1572         elif facet.startswith("choice_"):
1573             options[facet] = facet.split("_", 2)[1]
1574     for facet in creates.keys():
1575         if not creates[facet] in ignores:
1576             choices[creates[facet]] = eval(menu.get(facet))
1577     for facet in options.keys():
1578         if not options[facet] in ignores:
1579             choices[options[facet]] = menu.get(facet)
1580     return choices
1581
1582
1583 def get_formatted_menu_choices(state, choices):
1584     """Returns a formatted string of menu choices."""
1585     choice_output = ""
1586     choice_keys = list(choices.keys())
1587     choice_keys.sort()
1588     for choice in choice_keys:
1589         choice_output += "   [$(red)" + choice + "$(nrm)]  " + choices[
1590             choice
1591         ] + "$(eol)"
1592     if choice_output:
1593         choice_output += "$(eol)"
1594     return choice_output
1595
1596
1597 def get_menu_branches(state):
1598     """Return a dict of choice:branch."""
1599     branches = {}
1600     for facet in universe.groups["menu"][state].facets():
1601         if facet.startswith("branch_"):
1602             branches[
1603                 facet.split("_", 2)[1]
1604             ] = universe.groups["menu"][state].get(facet)
1605     return branches
1606
1607
1608 def get_default_branch(state):
1609     """Return the default branch."""
1610     return universe.groups["menu"][state].get("branch")
1611
1612
1613 def get_choice_branch(user, choice):
1614     """Returns the new state matching the given choice."""
1615     branches = get_menu_branches(user.state)
1616     if choice in branches.keys():
1617         return branches[choice]
1618     elif choice in user.menu_choices.keys():
1619         return get_default_branch(user.state)
1620     else:
1621         return ""
1622
1623
1624 def get_menu_actions(state):
1625     """Return a dict of choice:branch."""
1626     actions = {}
1627     for facet in universe.groups["menu"][state].facets():
1628         if facet.startswith("action_"):
1629             actions[
1630                 facet.split("_", 2)[1]
1631             ] = universe.groups["menu"][state].get(facet)
1632     return actions
1633
1634
1635 def get_default_action(state):
1636     """Return the default action."""
1637     return universe.groups["menu"][state].get("action")
1638
1639
1640 def get_choice_action(user, choice):
1641     """Run any indicated script for the given choice."""
1642     actions = get_menu_actions(user.state)
1643     if choice in actions.keys():
1644         return actions[choice]
1645     elif choice in user.menu_choices.keys():
1646         return get_default_action(user.state)
1647     else:
1648         return ""
1649
1650
1651 def handle_user_input(user):
1652     """The main handler, branches to a state-specific handler."""
1653
1654     # if the user's client echo is off, send a blank line for aesthetics
1655     if mudpy.telnet.is_enabled(user, mudpy.telnet.TELOPT_ECHO,
1656                                mudpy.telnet.US):
1657         user.send("", add_prompt=False, prepend_padding=False)
1658
1659     # check to make sure the state is expected, then call that handler
1660     if "handler_" + user.state in globals():
1661         exec("handler_" + user.state + "(user)")
1662     else:
1663         generic_menu_handler(user)
1664
1665     # since we got input, flag that the menu/prompt needs to be redisplayed
1666     user.menu_seen = False
1667
1668     # update the last_input timestamp while we're at it
1669     user.last_input = universe.get_time()
1670
1671
1672 def generic_menu_handler(user):
1673     """A generic menu choice handler."""
1674
1675     # get a lower-case representation of the next line of input
1676     if user.input_queue:
1677         choice = user.input_queue.pop(0)
1678         if choice:
1679             choice = choice.lower()
1680     else:
1681         choice = ""
1682     if not choice:
1683         choice = get_default_menu_choice(user.state)
1684     if choice in user.menu_choices:
1685         exec(get_choice_action(user, choice))
1686         new_state = get_choice_branch(user, choice)
1687         if new_state:
1688             user.state = new_state
1689     else:
1690         user.error = "default"
1691
1692
1693 def handler_entering_account_name(user):
1694     """Handle the login account name."""
1695
1696     # get the next waiting line of input
1697     input_data = user.input_queue.pop(0)
1698
1699     # did the user enter anything?
1700     if input_data:
1701
1702         # keep only the first word and convert to lower-case
1703         name = input_data.lower()
1704
1705         # fail if there are non-alphanumeric characters
1706         if name != "".join(filter(
1707                 lambda x: x >= "0" and x <= "9" or x >= "a" and x <= "z",
1708                 name)):
1709             user.error = "bad_name"
1710
1711         # if that account exists, time to request a password
1712         elif name in universe.groups.get("account", {}):
1713             user.account = universe.groups["account"][name]
1714             user.state = "checking_password"
1715
1716         # otherwise, this could be a brand new user
1717         else:
1718             user.account = Element("account.%s" % name, universe)
1719             user.account.set("name", name)
1720             log("New user: " + name, 2)
1721             user.state = "checking_new_account_name"
1722
1723     # if the user entered nothing for a name, then buhbye
1724     else:
1725         user.state = "disconnecting"
1726
1727
1728 def handler_checking_password(user):
1729     """Handle the login account password."""
1730
1731     # get the next waiting line of input
1732     input_data = user.input_queue.pop(0)
1733
1734     if "mudpy.limit" in universe.contents:
1735         max_password_tries = universe.contents["mudpy.limit"].get(
1736             "password_tries", 3)
1737     else:
1738         max_password_tries = 3
1739
1740     # does the hashed input equal the stored hash?
1741     if mudpy.password.verify(input_data, user.account.get("passhash")):
1742
1743         # if so, set the username and load from cold storage
1744         if not user.replace_old_connections():
1745             user.authenticate()
1746             user.state = "main_utility"
1747
1748     # if at first your hashes don't match, try, try again
1749     elif user.password_tries < max_password_tries - 1:
1750         user.password_tries += 1
1751         user.error = "incorrect"
1752
1753     # we've exceeded the maximum number of password failures, so disconnect
1754     else:
1755         user.send(
1756             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1757         )
1758         user.state = "disconnecting"
1759
1760
1761 def handler_entering_new_password(user):
1762     """Handle a new password entry."""
1763
1764     # get the next waiting line of input
1765     input_data = user.input_queue.pop(0)
1766
1767     if "mudpy.limit" in universe.contents:
1768         max_password_tries = universe.contents["mudpy.limit"].get(
1769             "password_tries", 3)
1770     else:
1771         max_password_tries = 3
1772
1773     # make sure the password is strong--at least one upper, one lower and
1774     # one digit, seven or more characters in length
1775     if len(input_data) > 6 and len(
1776        list(filter(lambda x: x >= "0" and x <= "9", input_data))
1777        ) and len(
1778         list(filter(lambda x: x >= "A" and x <= "Z", input_data))
1779     ) and len(
1780         list(filter(lambda x: x >= "a" and x <= "z", input_data))
1781     ):
1782
1783         # hash and store it, then move on to verification
1784         user.account.set("passhash", mudpy.password.create(input_data))
1785         user.state = "verifying_new_password"
1786
1787     # the password was weak, try again if you haven't tried too many times
1788     elif user.password_tries < max_password_tries - 1:
1789         user.password_tries += 1
1790         user.error = "weak"
1791
1792     # too many tries, so adios
1793     else:
1794         user.send(
1795             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1796         )
1797         user.account.destroy()
1798         user.state = "disconnecting"
1799
1800
1801 def handler_verifying_new_password(user):
1802     """Handle the re-entered new password for verification."""
1803
1804     # get the next waiting line of input
1805     input_data = user.input_queue.pop(0)
1806
1807     if "mudpy.limit" in universe.contents:
1808         max_password_tries = universe.contents["mudpy.limit"].get(
1809             "password_tries", 3)
1810     else:
1811         max_password_tries = 3
1812
1813     # hash the input and match it to storage
1814     if mudpy.password.verify(input_data, user.account.get("passhash")):
1815         user.authenticate()
1816
1817         # the hashes matched, so go active
1818         if not user.replace_old_connections():
1819             user.state = "main_utility"
1820
1821     # go back to entering the new password as long as you haven't tried
1822     # too many times
1823     elif user.password_tries < max_password_tries - 1:
1824         user.password_tries += 1
1825         user.error = "differs"
1826         user.state = "entering_new_password"
1827
1828     # otherwise, sayonara
1829     else:
1830         user.send(
1831             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1832         )
1833         user.account.destroy()
1834         user.state = "disconnecting"
1835
1836
1837 def handler_active(user):
1838     """Handle input for active users."""
1839
1840     # get the next waiting line of input
1841     input_data = user.input_queue.pop(0)
1842
1843     # is there input?
1844     if input_data:
1845
1846         # split out the command and parameters
1847         actor = user.avatar
1848         mode = actor.get("mode")
1849         if mode and input_data.startswith("!"):
1850             command_name, parameters = first_word(input_data[1:])
1851         elif mode == "chat":
1852             command_name = "say"
1853             parameters = input_data
1854         else:
1855             command_name, parameters = first_word(input_data)
1856
1857         # lowercase the command
1858         command_name = command_name.lower()
1859
1860         # the command matches a command word for which we have data
1861         if command_name in universe.groups["command"]:
1862             command = universe.groups["command"][command_name]
1863         else:
1864             command = None
1865
1866         # if it's allowed, do it
1867         if actor.can_run(command):
1868             exec(command.get("action"))
1869
1870         # otherwise, give an error
1871         elif command_name:
1872             mudpy.command.error(actor, input_data)
1873
1874     # if no input, just idle back with a prompt
1875     else:
1876         user.send("", just_prompt=True)
1877
1878
1879 def daemonize(universe):
1880     """Fork and disassociate from everything."""
1881
1882     # only if this is what we're configured to do
1883     if "mudpy.process" in universe.contents and universe.contents[
1884             "mudpy.process"].get("daemon"):
1885
1886         # log before we start forking around, so the terminal gets the message
1887         log("Disassociating from the controlling terminal.")
1888
1889         # fork off and die, so we free up the controlling terminal
1890         if os.fork():
1891             os._exit(0)
1892
1893         # switch to a new process group
1894         os.setsid()
1895
1896         # fork some more, this time to free us from the old process group
1897         if os.fork():
1898             os._exit(0)
1899
1900         # reset the working directory so we don't needlessly tie up mounts
1901         os.chdir("/")
1902
1903         # clear the file creation mask so we can bend it to our will later
1904         os.umask(0)
1905
1906         # redirect stdin/stdout/stderr and close off their former descriptors
1907         for stdpipe in range(3):
1908             os.close(stdpipe)
1909         sys.stdin = codecs.open("/dev/null", "r", "utf-8")
1910         sys.__stdin__ = codecs.open("/dev/null", "r", "utf-8")
1911         sys.stdout = codecs.open("/dev/null", "w", "utf-8")
1912         sys.stderr = codecs.open("/dev/null", "w", "utf-8")
1913         sys.__stdout__ = codecs.open("/dev/null", "w", "utf-8")
1914         sys.__stderr__ = codecs.open("/dev/null", "w", "utf-8")
1915
1916
1917 def create_pidfile(universe):
1918     """Write a file containing the current process ID."""
1919     pid = str(os.getpid())
1920     log("Process ID: " + pid)
1921     if "mudpy.process" in universe.contents:
1922         file_name = universe.contents["mudpy.process"].get("pidfile", "")
1923     else:
1924         file_name = ""
1925     if file_name:
1926         if not os.path.isabs(file_name):
1927             file_name = os.path.join(universe.startdir, file_name)
1928         os.makedirs(os.path.dirname(file_name), exist_ok=True)
1929         file_descriptor = codecs.open(file_name, "w", "utf-8")
1930         file_descriptor.write(pid + "\n")
1931         file_descriptor.flush()
1932         file_descriptor.close()
1933
1934
1935 def remove_pidfile(universe):
1936     """Remove the file containing the current process ID."""
1937     if "mudpy.process" in universe.contents:
1938         file_name = universe.contents["mudpy.process"].get("pidfile", "")
1939     else:
1940         file_name = ""
1941     if file_name:
1942         if not os.path.isabs(file_name):
1943             file_name = os.path.join(universe.startdir, file_name)
1944         if os.access(file_name, os.W_OK):
1945             os.remove(file_name)
1946
1947
1948 def excepthook(excepttype, value, tracebackdata):
1949     """Handle uncaught exceptions."""
1950
1951     # assemble the list of errors into a single string
1952     message = "".join(
1953         traceback.format_exception(excepttype, value, tracebackdata)
1954     )
1955
1956     # try to log it, if possible
1957     try:
1958         log(message, 9)
1959     except Exception as e:
1960         # try to write it to stderr, if possible
1961         sys.stderr.write(message + "\nException while logging...\n%s" % e)
1962
1963
1964 def sighook(what, where):
1965     """Handle external signals."""
1966
1967     # a generic message
1968     message = "Caught signal: "
1969
1970     # for a hangup signal
1971     if what == signal.SIGHUP:
1972         message += "hangup (reloading)"
1973         universe.reload_flag = True
1974
1975     # for a terminate signal
1976     elif what == signal.SIGTERM:
1977         message += "terminate (halting)"
1978         universe.terminate_flag = True
1979
1980     # catchall for unexpected signals
1981     else:
1982         message += str(what) + " (unhandled)"
1983
1984     # log what happened
1985     log(message, 8)
1986
1987
1988 def override_excepthook():
1989     """Redefine sys.excepthook with our own."""
1990     sys.excepthook = excepthook
1991
1992
1993 def assign_sighook():
1994     """Assign a customized handler for some signals."""
1995     signal.signal(signal.SIGHUP, sighook)
1996     signal.signal(signal.SIGTERM, sighook)
1997
1998
1999 def setup():
2000     """This contains functions to be performed when starting the engine."""
2001
2002     # see if a configuration file was specified
2003     if len(sys.argv) > 1:
2004         conffile = sys.argv[1]
2005     else:
2006         conffile = ""
2007
2008     # the big bang
2009     global universe
2010     universe = Universe(conffile, True)
2011
2012     # report any loglines which accumulated during setup
2013     for logline in universe.setup_loglines:
2014         log(*logline)
2015     universe.setup_loglines = []
2016
2017     # fork and disassociate
2018     daemonize(universe)
2019
2020     # override the default exception handler so we get logging first thing
2021     override_excepthook()
2022
2023     # set up custom signal handlers
2024     assign_sighook()
2025
2026     # make the pidfile
2027     create_pidfile(universe)
2028
2029     # load and store diagnostic info
2030     universe.versions = mudpy.version.Versions("mudpy")
2031
2032     # log startup diagnostic messages
2033     log("On %s at %s" % (universe.versions.python_version, sys.executable), 1)
2034     log("Import path: %s" % ", ".join(sys.path), 1)
2035     log("Installed dependencies: %s" % universe.versions.dependencies_text, 1)
2036     log("Other python packages: %s" % universe.versions.environment_text, 1)
2037     log("Started %s with command line: %s" % (
2038         universe.versions.version, " ".join(sys.argv)), 1)
2039
2040     # pass the initialized universe back
2041     return universe
2042
2043
2044 def finish():
2045     """These are functions performed when shutting down the engine."""
2046
2047     # the loop has terminated, so save persistent data
2048     universe.save()
2049
2050     # log a final message
2051     log("Shutting down now.")
2052
2053     # get rid of the pidfile
2054     remove_pidfile(universe)