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