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